feat(v0.6): config file parser + resolver + source attribution

Phase 1 foundation. Adds:

- internal/config/configfile.go — strict-key YAML parser for the
  global config file. Unknown top-level keys invalidate the entire
  file (no partial apply); future schema versions are rejected with
  an upgrade message. Platform-appropriate path (XDG_CONFIG_HOME or
  ~/.config on Unix, %APPDATA% on native Windows).

- internal/config/resolver.go — Resolver / ResolvedSetting /
  SettingSource (Builtin, ConfigFileSrc, EnvVar, CLIFlag,
  PlatformOverride). Each setting carries provenance plus an
  Override chain so callers can render "env var (overrides config
  file: X)" lines. session_mode applies the native-Windows
  platform override at the resolver layer with the configured
  value chained as Override. Exports SetConfigPathForTest and
  SetIsNativeWindowsForTest so cross-package tests can isolate
  themselves from host config or simulate the override.

- internal/config/config.go — legacy ResolveRoot / ResolveAgent /
  ResolveSeedDir / ResolveProjectRoot / ResolveSessionMode now
  delegate to LoadResolver so config-file values take effect for
  entry commands. ResolveProjectRoot preserves the "" sentinel for
  built-in source so SearchRoots and doctor checkProjectRoot keep
  their existing fallback semantics. ResolveSessionMode preserves
  the v0.5.3 unknown-value stderr warning, distinguishing env-var
  and config-file sources in the message.

- Tests: 6 LoadConfigFile cases (missing, basic, partial, unknown
  key, future schema, malformed YAML), 3 ConfigFilePath cases
  (XDG / ~/.config / %APPDATA%), 11 resolver cases including
  layering, override chains, path expansion, platform override,
  and SettingSource string rendering.

- Test isolation: vulnerable tests in config_test.go and
  config_roots_test.go that asserted Builtin-source defaults now
  call SetConfigPathForTest to point at a nonexistent path,
  insulating them from developer-host config files. Three cmd
  tests that asserted persistent session_mode behavior now call
  SetIsNativeWindowsForTest to disable the platform override
  (Phase 1 introduces the override; the layering-only behavior
  these tests cover is tested separately in resolver_test.go).

No new dependencies (gopkg.in/yaml.v3 was already in go.mod).
No version bump (lands at the end of Phase 3 per the v0.6 spec).
This commit is contained in:
2026-05-14 21:47:25 -04:00
parent 8304545840
commit 6f80c8bf5c
9 changed files with 970 additions and 39 deletions
+288
View File
@@ -0,0 +1,288 @@
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
}
// 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.
func (r *Resolver) DefaultAgent() ResolvedSetting {
cfgVal := ""
if r.cfg != nil {
cfgVal = r.cfg.DefaultAgent
}
return r.stringSetting("default_agent", "CTASK_AGENT",
"claude", cfgVal, r.envAgent, nil)
}
// 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")
}