Files
ctask/internal/config/resolver.go
T
typebasedio a61f900c86 feat(v0.6): --agent flag on ctask new selects agent type
Resolver gains SetCLIFlagAgent; DefaultAgent now layers CLIFlag above
EnvVar so doctor/info attribution renders the correct precedence chain
(CLIFlag overrides EnvVar overrides ConfigFile overrides Builtin).

ctask new --agent <type> writes agent.type into the new workspace's
task.yaml. Resolution and validation run before workspace.Create, so
--agent custom without a companion command refuses ("type custom
requires command") with no half-created workspace left on disk. The
deferred Phase 1 test TestCLIFlagOverridesEnvVar lands here.

--agent on resume/last/attach is unchanged (one-shot agent.command
override on the AgentSpec — Open Q 1).
2026-05-15 11:11:16 -04:00

321 lines
10 KiB
Go

package config
import (
"fmt"
"os"
"path/filepath"
"runtime"
)
// SettingSource records where a resolved setting's value came from.
// Defined high-to-low priority order is implicit in the resolver:
// CLIFlag > EnvVar > ConfigFileSrc > Builtin. PlatformOverride is a
// special-case post-resolution adjustment (currently used only for
// session_mode on native Windows).
type SettingSource int
const (
// Builtin: ctask's compiled-in default.
Builtin SettingSource = iota
// ConfigFileSrc: value came from the global config.yaml.
// Named with the "Src" suffix to avoid colliding with the
// ConfigFile type in this package.
ConfigFileSrc
// EnvVar: value came from an environment variable.
EnvVar
// CLIFlag: value came from a CLI flag. Reserved for v0.6 Phase 2
// (--agent) and later flags; no Phase 1 setting reaches this source.
CLIFlag
// PlatformOverride: ctask itself overrode the user-configured value
// because the requested setting is unsupported on the current
// platform (currently: persistent session_mode on native Windows).
PlatformOverride
)
// String returns the human-readable label used in doctor/info output.
func (s SettingSource) String() string {
switch s {
case Builtin:
return "built-in default"
case ConfigFileSrc:
return "config file"
case EnvVar:
return "env var"
case CLIFlag:
return "CLI flag"
case PlatformOverride:
return "platform override"
default:
return fmt.Sprintf("unknown source (%d)", int(s))
}
}
// ResolvedSetting carries a single setting's effective value plus
// provenance. Override (if non-nil) chains to the next-lower-priority
// source that contributed a value but was overridden — letting
// doctor/info render lines like "source: CTASK_AGENT env var
// (overrides config file: claude)".
type ResolvedSetting struct {
Key string
Value string
Source SettingSource
EnvName string // for EnvVar source, the env-var name
Override *ResolvedSetting
}
// Resolver carries the loaded config file plus a snapshot of relevant
// env vars so callers can compute each setting's effective value and
// its source. Build one with LoadResolver() per command invocation;
// reuse across calls within the same invocation to keep doctor/info
// output internally consistent.
type Resolver struct {
ConfigPath string // path consulted (regardless of whether file exists)
ConfigErr error // non-nil when config file failed to load
cfg *ConfigFile
envCtaskRoot string
envProjectRoot string
envSeedDir string
envAgent string
envSessionMode string
envEditor string
// cliFlagAgent, when non-empty, is the value of the --agent CLI flag.
// Set by SetCLIFlagAgent after Cobra parses flags; consulted by
// DefaultAgent as the highest-priority layer (SettingSource.CLIFlag).
cliFlagAgent string
}
// SetCLIFlagAgent records the value of the --agent CLI flag for
// resolution. Cmd-layer code calls this AFTER Cobra parses flags, so the
// flag value can participate in source attribution. Empty string means
// the flag was not passed; the env-var layer is consulted instead.
//
// Currently used only by `ctask new` (where --agent is a type selector,
// per v0.6 spec §5 + Open Q 1). Other commands' --agent flags act as
// one-shot agent.command overrides on the AgentSpec, NOT as resolver
// inputs — they do not call this setter.
func (r *Resolver) SetCLIFlagAgent(value string) {
r.cliFlagAgent = value
}
// configPathForTest, when non-empty, overrides ConfigFilePath() for
// the duration of a test. The legacy production code path consults
// ConfigFilePath() directly.
var configPathForTest string
// SetConfigPathForTest points the resolver at path for the duration
// of t. Use this in any test that depends on Builtin-source resolution
// (so a real ~/.config/ctask/config.yaml on the developer host cannot
// pollute the assertion). Pass a path under t.TempDir() to guarantee
// the file does not exist.
func SetConfigPathForTest(t interface{ Cleanup(func()) }, path string) {
prev := configPathForTest
configPathForTest = path
t.Cleanup(func() { configPathForTest = prev })
}
// isNativeWindowsForTest lets tests simulate the native-Windows-no-WSL
// platform regardless of the actual GOOS. Production callers use the
// real check via isNativeWindows().
var isNativeWindowsForTest func() bool
// SetIsNativeWindowsForTest replaces the native-Windows detector for
// the duration of t. Use it from tests outside the config package
// that need to exercise the session_mode platform-override path
// (or, conversely, disable the override on a Windows host so a test
// can assert pre-override layering). Restored via t.Cleanup.
func SetIsNativeWindowsForTest(t interface{ Cleanup(func()) }, f func() bool) {
prev := isNativeWindowsForTest
isNativeWindowsForTest = f
t.Cleanup(func() { isNativeWindowsForTest = prev })
}
// isNativeWindows reports whether ctask is running on Windows outside
// of a WSL distro. WSL exports WSL_DISTRO_NAME for any process started
// from a WSL shell.
func isNativeWindows() bool {
if isNativeWindowsForTest != nil {
return isNativeWindowsForTest()
}
return runtime.GOOS == "windows" && os.Getenv("WSL_DISTRO_NAME") == ""
}
// resolverPath returns the config-file path the resolver should read,
// honoring the test override when present.
func resolverPath() string {
if configPathForTest != "" {
return configPathForTest
}
return ConfigFilePath()
}
// LoadResolver reads the config file (if present) and snapshots the
// environment variables the resolver needs. Always returns a non-nil
// Resolver — when the config file is missing or invalid, the resolver
// still serves builtin and env-derived values.
func LoadResolver() *Resolver {
r := &Resolver{ConfigPath: resolverPath()}
cfg, err := LoadConfigFile(r.ConfigPath)
if err != nil {
r.ConfigErr = err
} else if cfg != nil {
r.cfg = cfg
}
r.envCtaskRoot = os.Getenv("CTASK_ROOT")
r.envProjectRoot = os.Getenv("CTASK_PROJECT_ROOT")
r.envSeedDir = os.Getenv("CTASK_SEED_DIR")
r.envAgent = os.Getenv("CTASK_AGENT")
r.envSessionMode = os.Getenv("CTASK_SESSION_MODE")
r.envEditor = os.Getenv("EDITOR")
return r
}
// stringSetting layers Builtin → ConfigFileSrc → EnvVar for a single
// string-valued key. Empty values at any layer are treated as "not
// supplied" and skipped. transform, when non-nil, is applied to every
// non-empty layer's value (used for path expansion).
func (r *Resolver) stringSetting(key, envName, builtin, cfgVal, envVal string, transform func(string) string) ResolvedSetting {
apply := func(v string) string {
if transform == nil {
return v
}
return transform(v)
}
s := ResolvedSetting{Key: key, Value: apply(builtin), Source: Builtin}
if cfgVal != "" {
prev := s
s = ResolvedSetting{
Key: key,
Value: apply(cfgVal),
Source: ConfigFileSrc,
Override: &prev,
}
}
if envVal != "" {
prev := s
s = ResolvedSetting{
Key: key,
Value: apply(envVal),
Source: EnvVar,
EnvName: envName,
Override: &prev,
}
}
return s
}
// CtaskRoot resolves the workspace root path with source attribution.
func (r *Resolver) CtaskRoot() ResolvedSetting {
cfgVal := ""
if r.cfg != nil {
cfgVal = r.cfg.CtaskRoot
}
return r.stringSetting("ctask_root", "CTASK_ROOT",
defaultCtaskRoot(), cfgVal, r.envCtaskRoot, expandPath)
}
// ProjectRoot resolves the project-workspace root. Its built-in
// default is derived from the resolved ctask_root, mirroring the
// SearchRoots() v0.5 fallback (<ctask_root>/projects). The Builtin
// chain therefore contains the derived path, not an empty string.
func (r *Resolver) ProjectRoot() ResolvedSetting {
cfgVal := ""
if r.cfg != nil {
cfgVal = r.cfg.ProjectRoot
}
builtin := filepath.Join(r.CtaskRoot().Value, "projects")
return r.stringSetting("project_root", "CTASK_PROJECT_ROOT",
builtin, cfgVal, r.envProjectRoot, expandPath)
}
// SeedDir resolves the user general seed directory. Built-in default
// is the platform-default seed location.
func (r *Resolver) SeedDir() ResolvedSetting {
cfgVal := ""
if r.cfg != nil {
cfgVal = r.cfg.SeedDir
}
return r.stringSetting("seed_dir", "CTASK_SEED_DIR",
defaultSeedDir("seed"), cfgVal, r.envSeedDir, expandPath)
}
// DefaultAgent resolves the default agent command. Layering is
// Builtin → ConfigFile → EnvVar → CLIFlag: when SetCLIFlagAgent has
// recorded a non-empty --agent value, it wins and the previously
// resolved setting is chained as Override so doctor/info can render the
// full precedence path.
func (r *Resolver) DefaultAgent() ResolvedSetting {
cfgVal := ""
if r.cfg != nil {
cfgVal = r.cfg.DefaultAgent
}
base := r.stringSetting("default_agent", "CTASK_AGENT",
"claude", cfgVal, r.envAgent, nil)
if r.cliFlagAgent != "" {
prev := base
return ResolvedSetting{
Key: "default_agent",
Value: r.cliFlagAgent,
Source: CLIFlag,
Override: &prev,
}
}
return base
}
// DefaultCategory resolves the default category for new workspaces.
// No env-var equivalent in v0.6 Phase 1.
func (r *Resolver) DefaultCategory() ResolvedSetting {
cfgVal := ""
if r.cfg != nil {
cfgVal = r.cfg.DefaultCategory
}
return r.stringSetting("default_category", "",
"tasks", cfgVal, "", nil)
}
// Editor resolves the user's text editor (used by future ctask edit).
// Env var is the conventional EDITOR.
func (r *Resolver) Editor() ResolvedSetting {
cfgVal := ""
if r.cfg != nil {
cfgVal = r.cfg.Editor
}
return r.stringSetting("editor", "EDITOR",
"", cfgVal, r.envEditor, nil)
}
// SessionMode resolves the launch session mode. Layers Builtin (direct)
// → ConfigFile → EnvVar, then applies the native-Windows platform
// override: when the resolved value is "persistent" but the host is
// native Windows, the value is forced to "direct" with PlatformOverride
// as the source and the previously resolved setting chained as
// Override (so doctor can render "configured: persistent").
func (r *Resolver) SessionMode() ResolvedSetting {
cfgVal := ""
if r.cfg != nil {
cfgVal = r.cfg.SessionMode
}
s := r.stringSetting("session_mode", "CTASK_SESSION_MODE",
"direct", cfgVal, r.envSessionMode, nil)
if s.Value == "persistent" && isNativeWindows() {
prev := s
s = ResolvedSetting{
Key: "session_mode",
Value: "direct",
Source: PlatformOverride,
Override: &prev,
}
}
return s
}
// defaultCtaskRoot returns the built-in default for the workspace root.
func defaultCtaskRoot() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, "ai-workspaces")
}