Files
ctask/internal/config/config.go
T
typebasedio 6f80c8bf5c 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).
2026-05-14 21:47:25 -04:00

193 lines
6.1 KiB
Go

package config
import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
)
// ResolveRoot returns the absolute workspace root path.
// v0.6: resolution order is CLI flag > CTASK_ROOT env var > config file
// (ctask_root key) > built-in default (~/ai-workspaces or
// %USERPROFILE%\ai-workspaces). No CLI flag participates in Phase 1.
func ResolveRoot() string {
return LoadResolver().CtaskRoot().Value
}
// ResolveAgent returns the agent command.
// v0.6: resolution order is CLI flag > CTASK_AGENT env var > config file
// (default_agent key) > built-in default ("claude"). No CLI flag
// participates in Phase 1.
func ResolveAgent() string {
return LoadResolver().DefaultAgent().Value
}
// ResolveSeedDir returns the user general seed directory.
// v0.6: resolution order is CTASK_SEED_DIR env var > config file
// (seed_dir key) > built-in default (%APPDATA%\ctask\seed on Windows,
// ~/.config/ctask/seed on Unix).
func ResolveSeedDir() string {
return LoadResolver().SeedDir().Value
}
// ResolveProjectSeedDir returns the user project seed directory.
// Reads CTASK_SEED_PROJECT_DIR; falls back to %APPDATA%\ctask\seed-project on Windows
// or ~/.config/ctask/seed-project on Unix.
// v0.6 Phase 1 does not give this setting a config-file equivalent
// (spec lists only seed_dir, singular).
func ResolveProjectSeedDir() string {
if v := os.Getenv("CTASK_SEED_PROJECT_DIR"); v != "" {
return expandPath(v)
}
return defaultSeedDir("seed-project")
}
// ResolveProjectRoot returns the project workspace root override.
// Returns empty string when no user-supplied value is set (config file
// or env var). The "" sentinel is load-bearing for SearchRoots() and
// doctor's checkProjectRoot, both of which apply their own fallback
// (the v0.5 $CTASK_ROOT/projects/ default).
//
// v0.6: a project_root value supplied via the config file or
// CTASK_PROJECT_ROOT env var is returned verbatim; a missing/empty
// value still returns "".
func ResolveProjectRoot() string {
s := LoadResolver().ProjectRoot()
if s.Source == Builtin {
return ""
}
return s.Value
}
// SearchRoots returns the deduplicated list of workspace roots that all query
// and listing operations must consult. Always includes CTASK_ROOT. When
// CTASK_PROJECT_ROOT is set, adds that (unless it duplicates CTASK_ROOT).
// When CTASK_PROJECT_ROOT is unset, adds $CTASK_ROOT/projects/ so that
// projects created under the default category are discoverable from any
// shell without per-session env var setup.
func SearchRoots() []string {
taskRoot := ResolveRoot()
roots := []string{taskRoot}
seen := map[string]struct{}{searchRootKey(taskRoot): {}}
add := func(p string) {
if p == "" {
return
}
key := searchRootKey(p)
if _, dup := seen[key]; dup {
return
}
seen[key] = struct{}{}
roots = append(roots, p)
}
projRoot := ResolveProjectRoot()
if projRoot != "" {
add(projRoot)
} else {
// Default fallback: projects live under $CTASK_ROOT/projects/ when
// no override is set. Explicit search root makes discovery work
// from any shell.
add(filepath.Join(taskRoot, "projects"))
}
return roots
}
// searchRootKey returns the dedup key for a root path: cleaned, and
// lower-cased on Windows for case-insensitive comparison.
func searchRootKey(p string) string {
c := filepath.Clean(p)
if runtime.GOOS == "windows" {
return strings.ToLower(c)
}
return c
}
// samePath reports whether two absolute paths refer to the same directory
// on the current platform. Uses case-insensitive compare on Windows.
func samePath(a, b string) bool {
ac := filepath.Clean(a)
bc := filepath.Clean(b)
if runtime.GOOS == "windows" {
return strings.EqualFold(ac, bc)
}
return ac == bc
}
// EnvVars returns the environment variables to export into child sessions.
// taskType must be "task" or "project"; an empty value defaults to "task".
// launchDir is the workspace-relative project subdirectory for projects,
// or empty for tasks and pre-v0.5 projects.
func EnvVars(slug, mode, root, workspace, category, taskType, launchDir string) map[string]string {
if taskType == "" {
taskType = "task"
}
return map[string]string{
"CTASK_TASK": slug,
"CTASK_MODE": mode,
"CTASK_ROOT": root,
"CTASK_WORKSPACE": workspace,
"CTASK_CATEGORY": category,
"CTASK_TYPE": taskType,
"CTASK_LAUNCH_DIR": launchDir,
}
}
// defaultSeedDir returns the platform-default location for a seed directory leaf.
func defaultSeedDir(leaf string) string {
if runtime.GOOS == "windows" {
appData := os.Getenv("APPDATA")
if appData == "" {
home, _ := os.UserHomeDir()
return filepath.Join(home, "AppData", "Roaming", "ctask", leaf)
}
return filepath.Join(appData, "ctask", leaf)
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", "ctask", leaf)
}
// ResolveSessionMode returns "direct" or "persistent" — the effective
// launch session mode after resolving CLI flag > env var > config file
// > built-in default ("direct"), with the platform-override rule
// applied (persistent → direct on native Windows). Unknown values from
// the env var or config file are coerced to "direct" with a one-line
// stderr warning, preserving the v0.5.3 behavior.
//
// Used by entry commands (new, resume, last, open) to dispatch between
// the standard session.Run path and the tmux-backed persistent path.
func ResolveSessionMode() string {
r := LoadResolver()
s := r.SessionMode()
if s.Value == "direct" || s.Value == "persistent" {
return s.Value
}
// Unknown value path — preserve the v0.5.3 stderr warning. Source
// of the bad value is reported so the user can find where to fix it.
switch s.Source {
case EnvVar:
fmt.Fprintf(os.Stderr,
"[ctask] warning: CTASK_SESSION_MODE=%q is not recognized; using direct mode\n", s.Value)
case ConfigFileSrc:
fmt.Fprintf(os.Stderr,
"[ctask] warning: config session_mode=%q is not recognized; using direct mode\n", s.Value)
}
return "direct"
}
// expandPath expands a leading ~/ and resolves to an absolute path when possible.
func expandPath(p string) string {
if strings.HasPrefix(p, "~/") || p == "~" {
home, _ := os.UserHomeDir()
p = filepath.Join(home, p[1:])
}
abs, err := filepath.Abs(p)
if err != nil {
return p
}
return abs
}