Files
ctask/internal/config/config.go
T
typebasedio 47430a1b1e feat(v0.5): include \$CTASK_ROOT/projects/ in SearchRoots by default
When CTASK_PROJECT_ROOT is unset, SearchRoots now appends
\$CTASK_ROOT/projects/ so that projects created under the default
category are discoverable from any shell without per-session env var
setup. Dedupe in scanAllRoots prevents double-counting workspaces that
are reachable both via the depth-2 scan under CTASK_ROOT and via the
new explicit search root. Adds a named regression test asserting no
duplicates appear in either ResolveQuery or ListWorkspaces results.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:51:07 -04:00

163 lines
4.5 KiB
Go

package config
import (
"os"
"path/filepath"
"runtime"
"strings"
)
// ResolveRoot returns the absolute workspace root path.
// Reads CTASK_ROOT env var, falls back to ~/ai-workspaces (or %USERPROFILE%\ai-workspaces on Windows).
func ResolveRoot() string {
root := os.Getenv("CTASK_ROOT")
if root == "" {
home, _ := os.UserHomeDir()
return filepath.Join(home, "ai-workspaces")
}
return expandPath(root)
}
// ResolveAgent returns the agent command.
// Reads CTASK_AGENT env var, falls back to "claude".
func ResolveAgent() string {
agent := os.Getenv("CTASK_AGENT")
if agent == "" {
return "claude"
}
return agent
}
// ResolveSeedDir returns the user general seed directory.
// Reads CTASK_SEED_DIR; falls back to %APPDATA%\ctask\seed on Windows or
// ~/.config/ctask/seed on Unix.
func ResolveSeedDir() string {
if v := os.Getenv("CTASK_SEED_DIR"); v != "" {
return expandPath(v)
}
return defaultSeedDir("seed")
}
// 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.
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 if CTASK_PROJECT_ROOT is not set; callers should fall back
// to ResolveRoot() in that case.
func ResolveProjectRoot() string {
v := os.Getenv("CTASK_PROJECT_ROOT")
if v == "" {
return ""
}
return expandPath(v)
}
// 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)
}
// 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
}