@@ -494,6 +494,67 @@ export function isLocalSettings(value: unknown): value is LocalSettings {
494494 return true ;
495495}
496496
497+ // Drop keys whose value is undefined so JSON omit + optional Settings fields stay
498+ // aligned. Value transforms (normalize, clamp, enum checks) happen before this —
499+ // the helper only filters undefined, it does not validate.
500+ type DefinedFields < T > = {
501+ [ K in keyof T as undefined extends T [ K ] ? ( T [ K ] extends undefined ? never : K ) : K ] : Exclude <
502+ T [ K ] ,
503+ undefined
504+ > ;
505+ } ;
506+
507+ function pickDefined < T extends Record < string , unknown > > ( fields : T ) : DefinedFields < T > {
508+ const out : Record < string , unknown > = { } ;
509+ for ( const [ key , value ] of Object . entries ( fields ) ) {
510+ if ( value !== undefined ) out [ key ] = value ;
511+ }
512+ return out as DefinedFields < T > ;
513+ }
514+
515+ // Every optional Settings key must appear here so a new type field without a
516+ // load-path assignment fails at compile time instead of silently dropping on
517+ // the next load/save cycle.
518+ type OptionalSettingsFields = {
519+ [ K in Exclude < keyof Settings , "providers" > ] : Settings [ K ] | undefined ;
520+ } ;
521+
522+ type OptionalLocalSettingsFields = {
523+ [ K in keyof LocalSettings ] : LocalSettings [ K ] | undefined ;
524+ } ;
525+
526+ /** Optional global settings keys the load path is required to consider. */
527+ export const GLOBAL_SETTINGS_OPTIONAL_KEYS = [
528+ "defaultProvider" ,
529+ "mcpServers" ,
530+ "tiers" ,
531+ "workflowProfiles" ,
532+ "plugins" ,
533+ "pluginPaths" ,
534+ "discoverClaudePlugins" ,
535+ "web" ,
536+ "hiddenCommands" ,
537+ "onboarded" ,
538+ "compactionMode" ,
539+ "maxConcurrentSubAgents" ,
540+ "subagentMaxTurns" ,
541+ "sessionMode" ,
542+ "agentModelFallback" ,
543+ "shell" ,
544+ "tools" ,
545+ "telemetry" ,
546+ ] as const satisfies readonly ( keyof OptionalSettingsFields ) [ ] ;
547+
548+ /** Optional local settings keys the load path is required to consider. */
549+ export const LOCAL_SETTINGS_OPTIONAL_KEYS = [
550+ "provider" ,
551+ "model" ,
552+ "reasoningEffort" ,
553+ "mcpServers" ,
554+ "sessionMode" ,
555+ "env" ,
556+ ] as const satisfies readonly ( keyof OptionalLocalSettingsFields ) [ ] ;
557+
497558export async function loadSettings ( path : string ) : Promise < Settings | null > {
498559 let raw : string ;
499560 try {
@@ -523,37 +584,42 @@ export async function loadSettings(path: string): Promise<Settings | null> {
523584 `settings: "workflowPlugins"/"agentPlugins" are no longer supported and will be dropped. Install those plugins under .corbits/plugins/ (or via /plugins "add by path") and enable them in /plugins.\n` ,
524585 ) ;
525586 }
587+ // Transforms (normalize/clamp/enum) first; pickDefined only drops undefined.
588+ const optional : OptionalSettingsFields = {
589+ defaultProvider : s . defaultProvider as string | undefined ,
590+ mcpServers : s . mcpServers !== undefined ? normalizeMcpServers ( s . mcpServers ) : undefined ,
591+ tiers : s . tiers as Settings [ "tiers" ] | undefined ,
592+ workflowProfiles : s . workflowProfiles as Settings [ "workflowProfiles" ] | undefined ,
593+ plugins : s . plugins as Settings [ "plugins" ] | undefined ,
594+ pluginPaths : s . pluginPaths as string [ ] | undefined ,
595+ discoverClaudePlugins : s . discoverClaudePlugins === true ? true : undefined ,
596+ web : s . web as string | undefined ,
597+ hiddenCommands : s . hiddenCommands as string [ ] | undefined ,
598+ onboarded : s . onboarded !== undefined ? Boolean ( s . onboarded ) : undefined ,
599+ compactionMode :
600+ s . compactionMode === "llm" || s . compactionMode === "pruning" ? s . compactionMode : undefined ,
601+ maxConcurrentSubAgents :
602+ s . maxConcurrentSubAgents !== undefined
603+ ? clampMaxConcurrentSubAgents ( s . maxConcurrentSubAgents as number )
604+ : undefined ,
605+ subagentMaxTurns :
606+ s . subagentMaxTurns !== undefined
607+ ? clampSubAgentMaxTurns ( s . subagentMaxTurns as number )
608+ : undefined ,
609+ sessionMode :
610+ s . sessionMode === "single" || s . sessionMode === "orchestrator" ? s . sessionMode : undefined ,
611+ agentModelFallback :
612+ s . agentModelFallback === "active" || s . agentModelFallback === "none"
613+ ? s . agentModelFallback
614+ : undefined ,
615+ shell : s . shell as Settings [ "shell" ] | undefined ,
616+ tools : s . tools as Settings [ "tools" ] | undefined ,
617+ telemetry : s . telemetry as Settings [ "telemetry" ] | undefined ,
618+ } ;
526619 return {
527620 providers : s . providers as Settings [ "providers" ] ,
528- ...( s . defaultProvider !== undefined ? { defaultProvider : s . defaultProvider as string } : { } ) ,
529- ...( s . mcpServers !== undefined ? { mcpServers : normalizeMcpServers ( s . mcpServers ) } : { } ) ,
530- ...( s . tiers !== undefined ? { tiers : s . tiers as Settings [ "tiers" ] } : { } ) ,
531- ...( s . workflowProfiles !== undefined ? { workflowProfiles : s . workflowProfiles as Settings [ "workflowProfiles" ] } : { } ) ,
532- ...( s . plugins !== undefined ? { plugins : s . plugins as Settings [ "plugins" ] } : { } ) ,
533- ...( s . pluginPaths !== undefined ? { pluginPaths : s . pluginPaths as string [ ] } : { } ) ,
534- ...( s . discoverClaudePlugins === true ? { discoverClaudePlugins : true } : { } ) ,
535- ...( s . web !== undefined ? { web : s . web as string } : { } ) ,
536- ...( s . hiddenCommands !== undefined ? { hiddenCommands : s . hiddenCommands as string [ ] } : { } ) ,
537- ...( s . onboarded !== undefined ? { onboarded : Boolean ( s . onboarded ) } : { } ) ,
538- ...( s . compactionMode === "llm" || s . compactionMode === "pruning"
539- ? { compactionMode : s . compactionMode }
540- : { } ) ,
541- ...( s . maxConcurrentSubAgents !== undefined
542- ? { maxConcurrentSubAgents : clampMaxConcurrentSubAgents ( s . maxConcurrentSubAgents as number ) }
543- : { } ) ,
544- ...( s . subagentMaxTurns !== undefined
545- ? { subagentMaxTurns : clampSubAgentMaxTurns ( s . subagentMaxTurns as number ) }
546- : { } ) ,
547- ...( s . sessionMode === "single" || s . sessionMode === "orchestrator"
548- ? { sessionMode : s . sessionMode }
549- : { } ) ,
550- ...( s . agentModelFallback === "active" || s . agentModelFallback === "none"
551- ? { agentModelFallback : s . agentModelFallback }
552- : { } ) ,
553- ...( s . shell !== undefined ? { shell : s . shell as Settings [ "shell" ] } : { } ) ,
554- ...( s . tools !== undefined ? { tools : s . tools as Settings [ "tools" ] } : { } ) ,
555- ...( s . telemetry !== undefined ? { telemetry : s . telemetry as Settings [ "telemetry" ] } : { } ) ,
556- } as Settings ;
621+ ...pickDefined ( optional ) ,
622+ } ;
557623}
558624
559625export async function loadLocalSettings ( path : string ) : Promise < LocalSettings | null > {
@@ -572,19 +638,20 @@ export async function loadLocalSettings(path: string): Promise<LocalSettings | n
572638 }
573639 if ( ! isLocalSettings ( parsed ) ) {
574640 throw new Error (
575- `Invalid local settings in ${ path } : only "provider", "model", "reasoningEffort", "mcpServers", and "sessionMode " are allowed (no credentials).` ,
641+ `Invalid local settings in ${ path } : only "provider", "model", "reasoningEffort", "mcpServers", "sessionMode", and "env " are allowed (no credentials).` ,
576642 ) ;
577643 }
578644 const s = parsed as Record < string , unknown > ;
579- return {
580- ...( s . provider !== undefined ? { provider : s . provider as string } : { } ) ,
581- ...( s . model !== undefined ? { model : s . model as string } : { } ) ,
582- ...( s . reasoningEffort !== undefined ? { reasoningEffort : s . reasoningEffort as ReasoningEffort } : { } ) ,
583- ...( s . mcpServers !== undefined ? { mcpServers : normalizeMcpServers ( s . mcpServers ) } : { } ) ,
584- ...( s . sessionMode === "single" || s . sessionMode === "orchestrator"
585- ? { sessionMode : s . sessionMode }
586- : { } ) ,
587- } as LocalSettings ;
645+ const optional : OptionalLocalSettingsFields = {
646+ provider : s . provider as string | undefined ,
647+ model : s . model as string | undefined ,
648+ reasoningEffort : s . reasoningEffort as ReasoningEffort | undefined ,
649+ mcpServers : s . mcpServers !== undefined ? normalizeMcpServers ( s . mcpServers ) : undefined ,
650+ sessionMode :
651+ s . sessionMode === "single" || s . sessionMode === "orchestrator" ? s . sessionMode : undefined ,
652+ env : s . env as Record < string , string > | undefined ,
653+ } ;
654+ return pickDefined ( optional ) ;
588655}
589656
590657// Resolve the base for a read-modify-write of the global settings file.
0 commit comments