11import { existsSync , readFileSync } from 'fs'
2+ import { execFileSync } from 'child_process'
23import { isAbsolute , join } from 'path'
34import { homedir } from 'os'
45import type { ActionKind , ActionPlan , PlannedChange } from './types.js'
56import { sha256 } from './backup.js'
7+ import {
8+ ALWAYSLOAD_MIN_VERSION ,
9+ ALWAYSLOAD_STARTUP_CAP_SECONDS ,
10+ ENABLE_TOOL_SEARCH_VAR ,
11+ parseVersion ,
12+ versionPredates ,
13+ } from '../optimize.js'
614import type { WasteFinding } from '../optimize.js'
715
816// Turns an optimize finding into a concrete, journaled file-mutation plan.
@@ -14,6 +22,9 @@ export type PlanContext = {
1422 homeDir ?: string
1523 cwd ?: string
1624 shell ?: string
25+ // Installed Claude Code version (null when undeterminable). Injectable so
26+ // tests never shell out; production defaults to probing `claude --version`.
27+ claudeVersion ?: ( ) => string | null
1728}
1829
1930export type BuiltPlan = {
@@ -34,11 +45,32 @@ type ResolvedPaths = {
3445 projectSettings : string
3546 projectSettingsLocal : string
3647 userClaudeJson : string
48+ userSettings : string
3749 skillsDir : string
3850 agentsDir : string
3951 commandsDir : string
4052 projectClaudeMd : string
4153 shellRc : string
54+ // Not a path, but resolved from the same context: the injectable installed-
55+ // version probe the defer-alwaysload version gate consults.
56+ claudeVersion : ( ) => string | null
57+ }
58+
59+ // `claude --version` prints e.g. "2.1.130 (Claude Code)"; any failure (binary
60+ // missing, timeout, non-zero exit) yields null and version-gated plans
61+ // degrade to a manual note instead of guessing.
62+ const CLAUDE_VERSION_PROBE_TIMEOUT_MS = 3000
63+
64+ function probeClaudeVersion ( ) : string | null {
65+ try {
66+ return execFileSync ( 'claude' , [ '--version' ] , {
67+ encoding : 'utf-8' ,
68+ timeout : CLAUDE_VERSION_PROBE_TIMEOUT_MS ,
69+ stdio : [ 'ignore' , 'pipe' , 'ignore' ] ,
70+ } ) . trim ( )
71+ } catch {
72+ return null
73+ }
4274}
4375
4476function resolvePaths ( ctx : PlanContext ) : ResolvedPaths {
@@ -52,11 +84,13 @@ function resolvePaths(ctx: PlanContext): ResolvedPaths {
5284 projectSettings : join ( cwd , '.claude' , 'settings.json' ) ,
5385 projectSettingsLocal : join ( cwd , '.claude' , 'settings.local.json' ) ,
5486 userClaudeJson : join ( homeDir , '.claude.json' ) ,
87+ userSettings : join ( homeDir , '.claude' , 'settings.json' ) ,
5588 skillsDir : join ( homeDir , '.claude' , 'skills' ) ,
5689 agentsDir : join ( homeDir , '.claude' , 'agents' ) ,
5790 commandsDir : join ( homeDir , '.claude' , 'commands' ) ,
5891 projectClaudeMd : join ( cwd , 'CLAUDE.md' ) ,
5992 shellRc : join ( homeDir , / z s h / . test ( shell ) ? '.zshrc' : '.bashrc' ) ,
93+ claudeVersion : ctx . claudeVersion ?? probeClaudeVersion ,
6094 }
6195}
6296
@@ -74,6 +108,9 @@ function buildPlan(finding: WasteFinding, r: ResolvedPaths): BuiltPlan {
74108 case 'mcp-low-coverage' : return buildMcpRemove ( finding , r )
75109 case 'unused-mcp' : return buildMcpRemove ( finding , r )
76110 case 'mcp-project-scope' : return buildMcpProjectScope ( finding , r )
111+ case 'mcp-deferral-off' : return buildDeferEnable ( finding , r )
112+ case 'mcp-alwaysload-hygiene' : return buildDeferAlwaysLoad ( finding , r )
113+ case 'mcp-defer-threshold' : return buildDeferThreshold ( finding , r )
77114 case 'unused-skills' : return buildArchive ( finding , r , 'skill' )
78115 case 'unused-agents' : return buildArchive ( finding , r , 'agent' )
79116 case 'unused-commands' : return buildArchive ( finding , r , 'command' )
@@ -230,15 +267,20 @@ function projectRemovalNote(server: string, entries: string[], homeDir: string):
230267 return `removes ${ server } from ${ entries . length } project ${ noun } : ${ entries . map ( e => shortPath ( e , homeDir ) ) . join ( ', ' ) } `
231268}
232269
270+ // Accumulates per-file preview annotations; repeats on a path join with ";".
271+ function pathNoteAdder ( pathNotes : Record < string , string > ) : ( path : string , note : string ) => void {
272+ return ( path , note ) => {
273+ pathNotes [ path ] = pathNotes [ path ] ? `${ pathNotes [ path ] } ; ${ note } ` : note
274+ }
275+ }
276+
233277function buildMcpRemove ( finding : WasteFinding , r : ResolvedPaths ) : BuiltPlan {
234278 const servers = finding . apply ?. kind === 'mcp-remove' ? finding . apply . servers : [ ]
235279 const searchPaths = [ r . projectMcpJson , r . projectSettings , r . projectSettingsLocal , r . userClaudeJson ]
236280 const docs = new ConfigDocs ( r . homeDir )
237281 const skips : string [ ] = [ ]
238282 const pathNotes : Record < string , string > = { }
239- const addPathNote = ( path : string , note : string ) : void => {
240- pathNotes [ path ] = pathNotes [ path ] ? `${ pathNotes [ path ] } ; ${ note } ` : note
241- }
283+ const addPathNote = pathNoteAdder ( pathNotes )
242284
243285 for ( const server of servers ) {
244286 let removed = false
@@ -268,9 +310,7 @@ function buildMcpProjectScope(finding: WasteFinding, r: ResolvedPaths): BuiltPla
268310 const docs = new ConfigDocs ( r . homeDir )
269311 const skips : string [ ] = [ ]
270312 const pathNotes : Record < string , string > = { }
271- const addPathNote = ( path : string , note : string ) : void => {
272- pathNotes [ path ] = pathNotes [ path ] ? `${ pathNotes [ path ] } ; ${ note } ` : note
273- }
313+ const addPathNote = pathNoteAdder ( pathNotes )
274314
275315 for ( const { server, keepProjects, removeProjects } of entries ) {
276316 const keepers = keepProjects . filter ( p => isAbsolute ( p ) )
@@ -335,6 +375,238 @@ function mcpPlan(kind: ActionKind, findingId: string, description: string, chang
335375 return { kind, findingId, description, changes }
336376}
337377
378+ // ---------------------------------------------------------------------------
379+ // MCP deferral plans — defer-enable / defer-alwaysload / defer-threshold (#614)
380+ // ---------------------------------------------------------------------------
381+
382+ // ENABLE_TOOL_SEARCH and mcpServers config are read once at Claude Code
383+ // process start, so an applied plan changes nothing for sessions already
384+ // running. Stated on every deferral plan.
385+ const NEXT_SESSION_NOTE = 'takes effect on the next session (this config is read at Claude Code start)'
386+
387+ // findDeferralEnvSetting (src/optimize.ts) reports shell-profile hits with
388+ // exactly this scope string; the plan layer keys its refusal on it.
389+ const SHELL_PROFILE_SCOPE = 'shell profile'
390+
391+ const SHELL_TOOL_SEARCH_LINE = new RegExp ( `^\\s*(?:export\\s+)?${ ENABLE_TOOL_SEARCH_VAR } \\s*=.*$` , 'm' )
392+
393+ function envContainer ( state : DocState ) : Record < string , unknown > | null {
394+ const env = state . doc . env
395+ return env && typeof env === 'object' ? env as Record < string , unknown > : null
396+ }
397+
398+ // An emptied env object stays in place, matching deleteServer's convention
399+ // for emptied mcpServers containers.
400+ function deleteEnvKey ( state : DocState , key : string ) : boolean {
401+ const env = envContainer ( state )
402+ if ( ! env || ! ( key in env ) ) return false
403+ delete env [ key ]
404+ state . dirty = true
405+ return true
406+ }
407+
408+ function setEnvKey ( state : DocState , key : string , value : string ) : void {
409+ const env = envContainer ( state ) ?? ( state . doc . env = { } ) as Record < string , unknown >
410+ env [ key ] = value
411+ state . dirty = true
412+ }
413+
414+ // Shell rc files only ever receive marker-block APPENDS (markerChange);
415+ // deleting or rewriting arbitrary user lines is out. Deferral overrides
416+ // found in a profile get precise by-hand instructions naming the exact file
417+ // and line instead of a plan. `replacement` switches the instruction from
418+ // "delete the line" to "change it to <replacement>".
419+ function shellOverrideManualNotes ( path : string , homeDir : string , replacement ?: string ) : string [ ] {
420+ const shown = shortPath ( path , homeDir )
421+ let content : string | null = null
422+ try {
423+ content = readFileSync ( path , 'utf-8' )
424+ } catch {
425+ content = null
426+ }
427+ const line = content ?. match ( SHELL_TOOL_SEARCH_LINE ) ?. [ 0 ] ?. trim ( )
428+ if ( ! line ) {
429+ return [ `manual: ${ ENABLE_TOOL_SEARCH_VAR } was reported in ${ shown } but no such line is there now; nothing to change` ]
430+ }
431+ // Keep the original line's `export ` prefix in the suggested replacement —
432+ // a user following the note verbatim would otherwise lose it.
433+ const replacementLine = replacement !== undefined && line . startsWith ( 'export ' ) && ! replacement . startsWith ( 'export ' )
434+ ? `export ${ replacement } `
435+ : replacement
436+ const action = replacementLine === undefined
437+ ? `delete the line \`${ line } \` from ${ shown } `
438+ : `in ${ shown } , change the line \`${ line } \` to \`${ replacementLine } \``
439+ return [ `manual: ${ action } yourself — codeburn only appends marker blocks to shell files and never edits user lines` ]
440+ }
441+
442+ // mcp-deferral-off -> defer-enable. Only two causes are auto-appliable:
443+ // removing a stale ENABLE_TOOL_SEARCH=false from a settings file, and (for
444+ // the future part-3 verifier) forcing =true once a proxy is verified. The
445+ // rest refuse with instructions: an unknown proxy because an explicit
446+ // override makes requests FAIL outright on proxies that don't forward
447+ // tool_reference blocks (live-docs fact), Vertex because default-off there
448+ // is a platform property, old-version because the fix is `claude update`.
449+ function buildDeferEnable ( finding : WasteFinding , r : ResolvedPaths ) : BuiltPlan {
450+ const payload = finding . apply ?. kind === 'defer-enable' ? finding . apply : null
451+ if ( ! payload ) return { plan : null , notes : [ ] }
452+
453+ switch ( payload . cause ) {
454+ case 'env-false' : {
455+ if ( ! payload . settingPath ) return { plan : null , notes : [ ] }
456+ if ( payload . settingScope === SHELL_PROFILE_SCOPE ) {
457+ return { plan : null , notes : shellOverrideManualNotes ( payload . settingPath , r . homeDir ) }
458+ }
459+ const docs = new ConfigDocs ( r . homeDir )
460+ const state = docs . load ( payload . settingPath )
461+ if ( ! state ) return { plan : null , notes : docs . errorNotes ( ) }
462+ if ( ! deleteEnvKey ( state , ENABLE_TOOL_SEARCH_VAR ) ) {
463+ return { plan : null , notes : [ `skipped: ${ ENABLE_TOOL_SEARCH_VAR } is no longer set in ${ shortPath ( payload . settingPath , r . homeDir ) } ` ] }
464+ }
465+ return {
466+ plan : mcpPlan (
467+ 'defer-enable' ,
468+ finding . id ,
469+ `Remove the ${ ENABLE_TOOL_SEARCH_VAR } =${ payload . value ?? 'false' } override from ${ shortPath ( payload . settingPath , r . homeDir ) } ` ,
470+ docs . changes ( ) ,
471+ ) ,
472+ notes : [ `restores default-on MCP tool deferral; ${ NEXT_SESSION_NOTE } ` ] ,
473+ }
474+ }
475+ case 'proxy-unknown' : {
476+ const where = payload . settingPath ? ` configured in ${ payload . settingScope ?? 'settings' } (${ shortPath ( payload . settingPath , r . homeDir ) } )` : ''
477+ return {
478+ plan : null ,
479+ notes : [
480+ `not auto-applied: setting ${ ENABLE_TOOL_SEARCH_VAR } =true would force deferral back on, but requests fail outright on proxies that don't forward tool_reference blocks. ` +
481+ `Verify that the proxy${ where } forwards them before setting the override.` ,
482+ ] ,
483+ }
484+ }
485+ case 'proxy-verified' : {
486+ // Part-3 verifier confirmed the proxy forwards tool_reference blocks,
487+ // so the explicit opt-in is safe. User settings env is the target: it
488+ // covers every project without touching shell files.
489+ const docs = new ConfigDocs ( r . homeDir )
490+ const state = docs . load ( r . userSettings )
491+ if ( ! state ) return { plan : null , notes : docs . errorNotes ( ) }
492+ setEnvKey ( state , ENABLE_TOOL_SEARCH_VAR , 'true' )
493+ return {
494+ plan : mcpPlan (
495+ 'defer-enable' ,
496+ finding . id ,
497+ `Set ${ ENABLE_TOOL_SEARCH_VAR } =true in ${ shortPath ( r . userSettings , r . homeDir ) } (proxy verified to forward tool_reference blocks)` ,
498+ docs . changes ( ) ,
499+ ) ,
500+ notes : [ `enables MCP tool deferral through the verified proxy; ${ NEXT_SESSION_NOTE } ` ] ,
501+ }
502+ }
503+ case 'vertex' :
504+ return {
505+ plan : null ,
506+ notes : [
507+ `manual: tool search is disabled by default on Vertex AI — a platform property, not a config error. ` +
508+ `Opt in yourself with \`export ${ ENABLE_TOOL_SEARCH_VAR } =true\` if your Vertex setup supports it.` ,
509+ ] ,
510+ }
511+ case 'old-version' :
512+ return {
513+ plan : null ,
514+ notes : [ 'manual: every observed Claude Code version predates default-on tool search; run `claude update`' ] ,
515+ }
516+ }
517+ }
518+
519+ // mcp-alwaysload-hygiene -> defer-alwaysload: drop `"alwaysLoad": true` from
520+ // the flagged servers in the exact config files the finding recorded.
521+ function buildDeferAlwaysLoad ( finding : WasteFinding , r : ResolvedPaths ) : BuiltPlan {
522+ const entries = finding . apply ?. kind === 'defer-alwaysload' ? finding . apply . servers : [ ]
523+ if ( entries . length === 0 ) return { plan : null , notes : [ ] }
524+
525+ // Version gate: server-level alwaysLoad shipped in v2.1.121. Below that
526+ // (or undeterminable) the key is inert — tools defer normally and there is
527+ // no startup block — so "removing the cost" would be a false claim.
528+ const installed = r . claudeVersion ( )
529+ const parsed = installed === null ? null : parseVersion ( installed )
530+ if ( parsed === null ) {
531+ return { plan : null , notes : [ `skipped: could not determine the installed Claude Code version; removing alwaysLoad is only meaningful on v${ ALWAYSLOAD_MIN_VERSION } +` ] }
532+ }
533+ if ( versionPredates ( installed ! , ALWAYSLOAD_MIN_VERSION ) ) {
534+ return { plan : null , notes : [ `skipped: installed Claude Code v${ parsed . join ( '.' ) } predates v${ ALWAYSLOAD_MIN_VERSION } , where server-level alwaysLoad shipped; the pin is inert there` ] }
535+ }
536+
537+ const docs = new ConfigDocs ( r . homeDir )
538+ const skips : string [ ] = [ ]
539+ const pathNotes : Record < string , string > = { }
540+ const addPathNote = pathNoteAdder ( pathNotes )
541+
542+ for ( const { server, paths } of entries ) {
543+ let removed = false
544+ for ( const path of paths ) {
545+ const state = docs . load ( path )
546+ if ( ! state ) continue
547+ const container = state . doc . mcpServers
548+ if ( ! container || typeof container !== 'object' ) continue
549+ const key = findServerKey ( container as Record < string , unknown > , server )
550+ if ( ! key ) continue
551+ const entry = ( container as Record < string , unknown > ) [ key ]
552+ if ( ! entry || typeof entry !== 'object' || ( entry as Record < string , unknown > ) [ 'alwaysLoad' ] !== true ) continue
553+ delete ( entry as Record < string , unknown > ) [ 'alwaysLoad' ]
554+ state . dirty = true
555+ removed = true
556+ // alwaysLoad also blocks session startup on the server's connection
557+ // (capped at 5s), so unpinning removes that startup cost too.
558+ addPathNote ( path , `unpins ${ server } : its schema defers on demand and session startup no longer blocks up to ${ ALWAYSLOAD_STARTUP_CAP_SECONDS } s on its connection` )
559+ }
560+ if ( ! removed ) skips . push ( `skipped ${ server } : no "alwaysLoad": true entry found in its config files` )
561+ }
562+
563+ const changes = docs . changes ( )
564+ const notes = [ ...docs . errorNotes ( ) , ...skips ]
565+ if ( changes . length === 0 ) return { plan : null , notes }
566+ return {
567+ plan : mcpPlan ( 'defer-alwaysload' , finding . id , `Unpin ${ entries . length === 1 ? 'an alwaysLoad MCP server' : 'alwaysLoad MCP servers' } ` , changes ) ,
568+ notes : [ ...notes , NEXT_SESSION_NOTE ] ,
569+ ...( Object . keys ( pathNotes ) . length > 0 ? { pathNotes } : { } ) ,
570+ }
571+ }
572+
573+ // mcp-defer-threshold -> defer-threshold: retune the ENABLE_TOOL_SEARCH auto
574+ // override in place. The detector found the key in config, so this is always
575+ // a value rewrite (or removal), never an env-object creation.
576+ function buildDeferThreshold ( finding : WasteFinding , r : ResolvedPaths ) : BuiltPlan {
577+ const payload = finding . apply ?. kind === 'defer-threshold' ? finding . apply : null
578+ if ( ! payload ) return { plan : null , notes : [ ] }
579+ if ( payload . settingScope === SHELL_PROFILE_SCOPE ) {
580+ const replacement = payload . removeOverride ? undefined : `${ ENABLE_TOOL_SEARCH_VAR } =auto:${ payload . recommendedPercent } `
581+ return { plan : null , notes : shellOverrideManualNotes ( payload . settingPath , r . homeDir , replacement ) }
582+ }
583+
584+ const docs = new ConfigDocs ( r . homeDir )
585+ const state = docs . load ( payload . settingPath )
586+ if ( ! state ) return { plan : null , notes : docs . errorNotes ( ) }
587+ const env = envContainer ( state )
588+ if ( ! env || ! ( ENABLE_TOOL_SEARCH_VAR in env ) ) {
589+ return { plan : null , notes : [ `skipped: ${ ENABLE_TOOL_SEARCH_VAR } is no longer set in ${ shortPath ( payload . settingPath , r . homeDir ) } ` ] }
590+ }
591+ if ( payload . removeOverride ) {
592+ // Defs already exceed the default auto threshold: the override is pure
593+ // downside, so deleting it restores default auto behavior, which defers.
594+ delete env [ ENABLE_TOOL_SEARCH_VAR ]
595+ } else {
596+ env [ ENABLE_TOOL_SEARCH_VAR ] = `auto:${ payload . recommendedPercent } `
597+ }
598+ state . dirty = true
599+
600+ const shown = shortPath ( payload . settingPath , r . homeDir )
601+ const description = payload . removeOverride
602+ ? `Remove the ${ ENABLE_TOOL_SEARCH_VAR } =${ payload . value } override from ${ shown } (the default auto threshold already defers this volume)`
603+ : `Tighten ${ ENABLE_TOOL_SEARCH_VAR } to auto:${ payload . recommendedPercent } in ${ shown } `
604+ return {
605+ plan : mcpPlan ( 'defer-threshold' , finding . id , description , docs . changes ( ) ) ,
606+ notes : [ NEXT_SESSION_NOTE ] ,
607+ }
608+ }
609+
338610// ---------------------------------------------------------------------------
339611// Archive unused skills / agents / commands
340612// ---------------------------------------------------------------------------
0 commit comments