@@ -33,29 +33,61 @@ func initConfig() {
3333 // This finds the nearest .taskwing, go.mod, package.json, etc.
3434 projectCtx := detectProjectRoot ()
3535
36- // ALWAYS add global config path first (highest priority for user settings)
37- viper .AddConfigPath (filepath .Join (home , ".taskwing" ))
36+ // Layered config loading: global first, then project merges on top.
37+ // Resolution order: project > profile > global > env vars > defaults
38+ viper .SetConfigType ("yaml" )
3839
39- // Add detected project root's .taskwing directory for project-specific config
40- if projectCtx != nil && projectCtx .RootPath != "" {
41- projectConfigPath := filepath .Join (projectCtx .RootPath , ".taskwing" )
42- if info , err := os .Stat (projectConfigPath ); err == nil && info .IsDir () {
43- viper .AddConfigPath (projectConfigPath )
40+ // 1. Load global config as base layer
41+ globalConfigFile := filepath .Join (home , ".taskwing" , "config.yaml" )
42+ if _ , err := os .Stat (globalConfigFile ); err == nil {
43+ viper .SetConfigFile (globalConfigFile )
44+ if err := viper .ReadInConfig (); err == nil {
45+ if viper .GetBool ("verbose" ) && ! viper .GetBool ("json" ) {
46+ fmt .Fprintln (os .Stderr , "Loaded global config:" , globalConfigFile )
47+ }
4448 }
4549 }
4650
47- // Legacy: Also check CWD's .taskwing directory (for backwards compatibility)
48- if _ , err := os .Stat (".taskwing" ); ! os .IsNotExist (err ) {
49- viper .AddConfigPath (".taskwing" )
51+ // 2. Load profile config (merges on top of global)
52+ // Check env var first, then scan os.Args for --profile flag
53+ // (Cobra hasn't parsed flags yet when initConfig runs)
54+ profileName := os .Getenv ("TASKWING_PROFILE" )
55+ if profileName == "" {
56+ profileName = viper .GetString ("profile" ) // from global config.yaml
57+ }
58+ if profileName == "" {
59+ profileName = scanFlagFromArgs ("profile" )
60+ }
61+ if profileName != "" && filepath .Base (profileName ) == profileName && ! strings .Contains (profileName , ".." ) {
62+ profileFile := filepath .Join (home , ".taskwing" , "profiles" , profileName + ".yaml" )
63+ if _ , err := os .Stat (profileFile ); err == nil {
64+ profileViper := viper .New ()
65+ profileViper .SetConfigFile (profileFile )
66+ if err := profileViper .ReadInConfig (); err == nil {
67+ if err := viper .MergeConfigMap (profileViper .AllSettings ()); err == nil {
68+ if viper .GetBool ("verbose" ) && ! viper .GetBool ("json" ) {
69+ fmt .Fprintln (os .Stderr , "Loaded profile config:" , profileFile )
70+ }
71+ }
72+ }
73+ }
5074 }
5175
52- viper .SetConfigName ("config" ) // looks for config.yaml
53- viper .SetConfigType ("yaml" )
54-
55- // Attempt to read the configuration file
56- if err := viper .ReadInConfig (); err == nil {
57- if viper .GetBool ("verbose" ) && ! viper .GetBool ("json" ) {
58- fmt .Fprintln (os .Stderr , "Using config file:" , viper .ConfigFileUsed ())
76+ // 3. Load project config from global store (merges on top, highest file-based priority)
77+ if projectCtx != nil && projectCtx .RootPath != "" {
78+ if storePath , err := config .GetProjectStorePath (projectCtx .RootPath ); err == nil {
79+ projectConfigFile := filepath .Join (storePath , "config.yaml" )
80+ if _ , err := os .Stat (projectConfigFile ); err == nil {
81+ projectViper := viper .New ()
82+ projectViper .SetConfigFile (projectConfigFile )
83+ if err := projectViper .ReadInConfig (); err == nil {
84+ if err := viper .MergeConfigMap (projectViper .AllSettings ()); err == nil {
85+ if viper .GetBool ("verbose" ) && ! viper .GetBool ("json" ) {
86+ fmt .Fprintln (os .Stderr , "Loaded project config:" , projectConfigFile )
87+ }
88+ }
89+ }
90+ }
5991 }
6092 }
6193
@@ -66,11 +98,8 @@ func initConfig() {
6698 viper .SetDefault ("preview" , false )
6799
68100 // Memory store path: do NOT set a default here.
69- // GetMemoryBasePath() has FAIL-FAST semantics:
70- // 1. If user sets memory.path in config → use that
71- // 2. Detected project root → use {project_root}/.taskwing/memory
72- // 3. Otherwise → return error (no silent fallbacks)
73- // For non-project commands (help, version), GetMemoryBasePathOrGlobal() provides ~/.taskwing fallback.
101+ // GetMemoryBasePath() resolves to ~/.taskwing/projects/<slug>/ via GetProjectStorePath.
102+ // For non-project commands (help, version), GetMemoryBasePathOrGlobal() provides fallback.
74103
75104 // LLM defaults (for bootstrap scanner)
76105 // Do NOT set defaults for llm.provider, llm.apiKey, or llm.model
@@ -115,3 +144,18 @@ func detectProjectRoot() *project.Context {
115144
116145 return ctx
117146}
147+
148+ // scanFlagFromArgs extracts a flag value from os.Args before Cobra parses them.
149+ // Supports --flag=value and --flag value forms.
150+ func scanFlagFromArgs (name string ) string {
151+ prefix := "--" + name
152+ for i , arg := range os .Args {
153+ if arg == prefix && i + 1 < len (os .Args ) {
154+ return os .Args [i + 1 ]
155+ }
156+ if val , ok := strings .CutPrefix (arg , prefix + "=" ); ok {
157+ return val
158+ }
159+ }
160+ return ""
161+ }
0 commit comments