a61f900c86
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).
321 lines
10 KiB
Go
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")
|
|
}
|