From c0bfcf53a64f9dc56c286fdaac81a089a73c6b4c Mon Sep 17 00:00:00 2001 From: CorticalCode <212432458+CorticalCode@users.noreply.github.com> Date: Mon, 13 Jul 2026 09:52:33 -0500 Subject: [PATCH 1/9] feat(claude-settings): route Claude settings I/O through an optional targetPath All load/save/backup, install-state readers, install/uninstall, and widget hook sync accept an explicit target file, defaulting to today's user-global settings.json. installStatusLine resolves its target once so a statusLine and its hooks always land in the same file. Re-derives the targetPath hook-routing approach from sirmalloc's 25595a4 onto current main. Groundwork for per-project installs (#351). --- src/utils/__tests__/claude-settings.test.ts | 175 ++++++++++++++++++++ src/utils/__tests__/hooks.test.ts | 84 +++++++++- src/utils/claude-settings.ts | 77 +++++---- src/utils/hooks.ts | 18 +- 4 files changed, 311 insertions(+), 43 deletions(-) diff --git a/src/utils/__tests__/claude-settings.test.ts b/src/utils/__tests__/claude-settings.test.ts index 9301ad7a..f656dcda 100644 --- a/src/utils/__tests__/claude-settings.test.ts +++ b/src/utils/__tests__/claude-settings.test.ts @@ -28,6 +28,7 @@ import { isInstalled, isKnownCommand, loadClaudeSettings, + loadClaudeSettingsSync, saveClaudeSettings, setRefreshInterval, uninstallStatusLine @@ -812,3 +813,177 @@ describe('getVoiceConfig', () => { }); }); }); + +describe('claude-settings target routing', () => { + let targetDir = ''; + let targetPath = ''; + + beforeEach(() => { + targetDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-target-')); + targetPath = path.join(targetDir, 'settings.local.json'); + }); + + afterEach(() => { + fs.rmSync(targetDir, { recursive: true, force: true }); + }); + + it('saveClaudeSettings writes to an explicit targetPath and leaves the default file alone', async () => { + await saveClaudeSettings({ statusLine: { type: 'command', command: 'x', padding: 0 } }, targetPath); + + expect(fs.existsSync(targetPath)).toBe(true); + const written = JSON.parse(fs.readFileSync(targetPath, 'utf-8')) as { statusLine?: { command?: string } }; + expect(written.statusLine?.command).toBe('x'); + expect(fs.existsSync(getClaudeSettingsPath())).toBe(false); + }); + + it('loadClaudeSettings reads from an explicit targetPath', async () => { + fs.writeFileSync(targetPath, JSON.stringify({ statusLine: { type: 'command', command: 'y', padding: 0 } }), 'utf-8'); + + const loaded = await loadClaudeSettings({ logErrors: false, targetPath }); + expect(loaded.statusLine?.command).toBe('y'); + }); + + it('loadClaudeSettingsSync reads from an explicit targetPath', () => { + fs.writeFileSync(targetPath, JSON.stringify({ effortLevel: 'high' }), 'utf-8'); + + const loaded = loadClaudeSettingsSync({ logErrors: false, targetPath }); + expect(loaded).toEqual({ effortLevel: 'high' }); + }); + + it('saveClaudeSettings backs up the target file, not the default file', async () => { + fs.writeFileSync(targetPath, JSON.stringify({ old: true }), 'utf-8'); + + await saveClaudeSettings({}, targetPath); + + expect(fs.existsSync(`${targetPath}.bak`)).toBe(true); + expect(fs.existsSync(`${getClaudeSettingsPath()}.bak`)).toBe(false); + }); + + it('isInstalled and getExistingStatusLine read the explicit target', async () => { + fs.writeFileSync(targetPath, JSON.stringify({ statusLine: { type: 'command', command: 'bunx -y ccstatusline@latest', padding: 0 } }), 'utf-8'); + + expect(await isInstalled({ targetPath })).toBe(true); + expect(await getExistingStatusLine({ targetPath })).toBe('bunx -y ccstatusline@latest'); + expect(await isInstalled()).toBe(false); + }); + + it('get/setRefreshInterval operate on the explicit target', async () => { + fs.writeFileSync(targetPath, JSON.stringify({ statusLine: { type: 'command', command: 'bunx -y ccstatusline@latest', padding: 0 } }), 'utf-8'); + + await setRefreshInterval(7, { targetPath }); + expect(await getRefreshInterval({ targetPath })).toBe(7); + expect(await getRefreshInterval()).toBeNull(); + }); + + it('installStatusLine writes statusLine + hooks to the target and preserves unrelated keys', async () => { + fs.writeFileSync(targetPath, JSON.stringify({ + sandbox: { enabled: true }, + permissions: { allow: ['Bash'] } + }), 'utf-8'); + + await installStatusLine({ + commandMode: 'auto-bunx', + supportsRefreshInterval: false, + targetPath + }); + + const written = JSON.parse(fs.readFileSync(targetPath, 'utf-8')) as { + statusLine?: { command?: string }; + sandbox?: unknown; + permissions?: unknown; + }; + expect(written.statusLine?.command).toContain('ccstatusline'); + expect(written.sandbox).toEqual({ enabled: true }); + expect(written.permissions).toEqual({ allow: ['Bash'] }); + expect(fs.existsSync(getClaudeSettingsPath())).toBe(false); + }); + + it('installStatusLine writes managed hooks to the explicit target, not the default file', async () => { + const configPath = config.getConfigPath(); + fs.writeFileSync(configPath, JSON.stringify({ + ...DEFAULT_SETTINGS, + lines: [[{ id: 'skills-1', type: 'skills' }], [], []] + }, null, 2), 'utf-8'); + + await installStatusLine({ + commandMode: 'auto-bunx', + supportsRefreshInterval: false, + targetPath + }); + + const installedCommand = `${CCSTATUSLINE_COMMANDS.BUNX} --config ${configPath}`; + const written = JSON.parse(fs.readFileSync(targetPath, 'utf-8')) as { + statusLine?: { command?: string }; + hooks?: Record; + }; + expect(written.statusLine?.command).toBe(installedCommand); + + const hooks = written.hooks ?? {}; + expect(hooks.PreToolUse).toEqual([ + { + _tag: 'ccstatusline-managed', + matcher: 'Skill', + hooks: [{ type: 'command', command: `${installedCommand} --hook` }] + } + ]); + expect(hooks.UserPromptSubmit).toEqual([ + { + _tag: 'ccstatusline-managed', + hooks: [{ type: 'command', command: `${installedCommand} --hook` }] + } + ]); + expect(fs.existsSync(getClaudeSettingsPath())).toBe(false); + }); + + it('uninstallStatusLine removes statusLine from the target and preserves unrelated keys', async () => { + fs.writeFileSync(targetPath, JSON.stringify({ + statusLine: { type: 'command', command: 'bunx -y ccstatusline@latest', padding: 0 }, + sandbox: { enabled: true } + }), 'utf-8'); + + await uninstallStatusLine({ targetPath }); + + const written = JSON.parse(fs.readFileSync(targetPath, 'utf-8')) as { + statusLine?: unknown; + sandbox?: unknown; + }; + expect(written.statusLine).toBeUndefined(); + expect(written.sandbox).toEqual({ enabled: true }); + }); + + it('uninstallStatusLine removes managed hooks from the explicit target only, leaving the default file untouched', async () => { + const seededShape = { + statusLine: { type: 'command', command: CCSTATUSLINE_COMMANDS.NPM, padding: 0 }, + hooks: { + PreToolUse: [ + { + _tag: 'ccstatusline-managed', + matcher: 'Skill', + hooks: [{ type: 'command', command: `${CCSTATUSLINE_COMMANDS.NPM} --hook` }] + } + ] + }, + sandbox: { enabled: true } + }; + fs.writeFileSync(targetPath, JSON.stringify(seededShape), 'utf-8'); + writeRawClaudeSettings(JSON.stringify(seededShape)); + + await uninstallStatusLine({ targetPath }); + + const updatedTarget = JSON.parse(fs.readFileSync(targetPath, 'utf-8')) as { + statusLine?: unknown; + hooks?: Record; + sandbox?: unknown; + }; + expect(updatedTarget.statusLine).toBeUndefined(); + expect(updatedTarget.hooks).toBeUndefined(); + expect(updatedTarget.sandbox).toEqual({ enabled: true }); + + const updatedDefault = JSON.parse(fs.readFileSync(getClaudeSettingsPath(), 'utf-8')) as { + statusLine?: { command?: string }; + hooks?: Record; + }; + expect(updatedDefault.statusLine?.command).toBe(CCSTATUSLINE_COMMANDS.NPM); + expect(updatedDefault.hooks).toEqual(seededShape.hooks); + }); +}); diff --git a/src/utils/__tests__/hooks.test.ts b/src/utils/__tests__/hooks.test.ts index 1b45978c..a3f9f014 100644 --- a/src/utils/__tests__/hooks.test.ts +++ b/src/utils/__tests__/hooks.test.ts @@ -14,6 +14,7 @@ import { DEFAULT_SETTINGS, SettingsSchema } from '../../types/Settings'; +import { getClaudeSettingsPath } from '../claude-settings'; import { removeManagedHooks, syncWidgetHooks @@ -22,7 +23,7 @@ import { const ORIGINAL_CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR; let testClaudeConfigDir = ''; -function getClaudeSettingsPath(): string { +function getLocalClaudeSettingsPath(): string { return path.join(testClaudeConfigDir, 'settings.json'); } @@ -47,7 +48,7 @@ describe('syncWidgetHooks', () => { }); it('removes managed hooks and persists cleanup when status line is unset', async () => { - const settingsPath = getClaudeSettingsPath(); + const settingsPath = getLocalClaudeSettingsPath(); fs.writeFileSync(settingsPath, JSON.stringify({ hooks: { PreToolUse: [ @@ -84,7 +85,7 @@ describe('syncWidgetHooks', () => { }); it('heals legacy untagged ccstatusline hooks instead of leaving duplicates', async () => { - const settingsPath = getClaudeSettingsPath(); + const settingsPath = getLocalClaudeSettingsPath(); const command = '/Users/test/.bun/bin/ccstatusline'; fs.writeFileSync(settingsPath, JSON.stringify({ statusLine: { type: 'command', command }, @@ -132,7 +133,7 @@ describe('syncWidgetHooks', () => { }); it('preserves other commands in mixed legacy hook entries while syncing', async () => { - const settingsPath = getClaudeSettingsPath(); + const settingsPath = getLocalClaudeSettingsPath(); const command = '/Users/test/.bun/bin/ccstatusline'; fs.writeFileSync(settingsPath, JSON.stringify({ statusLine: { type: 'command', command }, @@ -176,7 +177,7 @@ describe('syncWidgetHooks', () => { }); it('preserves other commands in mixed legacy hook entries while removing managed hooks', async () => { - const settingsPath = getClaudeSettingsPath(); + const settingsPath = getLocalClaudeSettingsPath(); fs.writeFileSync(settingsPath, JSON.stringify({ hooks: { PreToolUse: [ @@ -214,7 +215,7 @@ describe('syncWidgetHooks', () => { }); it('is idempotent — repeated syncs heal legacy hooks without accumulating', async () => { - const settingsPath = getClaudeSettingsPath(); + const settingsPath = getLocalClaudeSettingsPath(); const command = '/Users/test/.bun/bin/ccstatusline'; // Seed a legacy untagged hook so the first sync exercises the heal (regex) path, // not just the tagged-entry path. @@ -245,3 +246,74 @@ describe('syncWidgetHooks', () => { expect(saved.hooks?.UserPromptSubmit).toHaveLength(1); }); }); + +describe('hooks target routing', () => { + let previousConfigDir: string | undefined; + let claudeDir = ''; + let targetDir = ''; + let targetPath = ''; + + beforeEach(() => { + claudeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-hooks-claude-')); + previousConfigDir = process.env.CLAUDE_CONFIG_DIR; + process.env.CLAUDE_CONFIG_DIR = claudeDir; + targetDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-hooks-target-')); + targetPath = path.join(targetDir, 'settings.local.json'); + }); + + afterEach(() => { + if (previousConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = previousConfigDir; + } + fs.rmSync(claudeDir, { recursive: true, force: true }); + fs.rmSync(targetDir, { recursive: true, force: true }); + }); + + it('removeManagedHooks strips tagged entries from the explicit target only', async () => { + const tagged = { + hooks: { + PostToolUse: [{ + _tag: 'ccstatusline-managed', + hooks: [{ type: 'command', command: 'bunx -y ccstatusline@latest --hook' }] + }] + }, + sandbox: { enabled: true } + }; + fs.writeFileSync(targetPath, JSON.stringify(tagged), 'utf-8'); + fs.writeFileSync(getClaudeSettingsPath(), JSON.stringify(tagged), 'utf-8'); + + await removeManagedHooks({ targetPath }); + + const target = JSON.parse(fs.readFileSync(targetPath, 'utf-8')) as { hooks?: unknown; sandbox?: unknown }; + expect(target.hooks).toBeUndefined(); + expect(target.sandbox).toEqual({ enabled: true }); + + const globalFile = JSON.parse(fs.readFileSync(getClaudeSettingsPath(), 'utf-8')) as { hooks?: unknown }; + expect(globalFile.hooks).toBeDefined(); + }); + + it('syncWidgetHooks with no hook widgets strips managed hooks in the explicit target only', async () => { + const tagged = { + statusLine: { type: 'command', command: 'bunx -y ccstatusline@latest', padding: 0 }, + hooks: { + PostToolUse: [{ + _tag: 'ccstatusline-managed', + hooks: [{ type: 'command', command: 'bunx -y ccstatusline@latest --hook' }] + }] + } + }; + fs.writeFileSync(targetPath, JSON.stringify(tagged), 'utf-8'); + fs.writeFileSync(getClaudeSettingsPath(), JSON.stringify(tagged), 'utf-8'); + + await syncWidgetHooks(DEFAULT_SETTINGS, { targetPath }); + + const target = JSON.parse(fs.readFileSync(targetPath, 'utf-8')) as { hooks?: unknown; statusLine?: unknown }; + expect(target.hooks).toBeUndefined(); + expect(target.statusLine).toBeDefined(); + + const globalFile = JSON.parse(fs.readFileSync(getClaudeSettingsPath(), 'utf-8')) as { hooks?: unknown }; + expect(globalFile.hooks).toBeDefined(); + }); +}); diff --git a/src/utils/claude-settings.ts b/src/utils/claude-settings.ts index 238ea24e..75705dc7 100644 --- a/src/utils/claude-settings.ts +++ b/src/utils/claude-settings.ts @@ -46,6 +46,7 @@ export interface InstallStatusLineOptions { commandMode: StatusLineCommandMode; supportsRefreshInterval?: boolean; installationMetadata?: InstallationMetadata; + targetPath?: string; } export interface PackageCommandAvailability { @@ -139,11 +140,20 @@ export function getClaudeSettingsPath(): string { return path.join(getClaudeConfigDir(), 'settings.json'); } +/** + * Default Claude-settings file all reads/writes target when no explicit + * targetPath is given. Becomes scope-aware in the project-mode work + * (project scope routes to /.claude/settings.local.json). + */ +function defaultTargetPath(): string { + return getClaudeSettingsPath(); +} + /** * Creates a backup of the current Claude settings file. */ -async function backupClaudeSettings(suffix = '.bak'): Promise { - const settingsPath = getClaudeSettingsPath(); +async function backupClaudeSettings(suffix = '.bak', targetPath = defaultTargetPath()): Promise { + const settingsPath = targetPath; const backupPath = settingsPath + suffix; try { if (fs.existsSync(settingsPath)) { @@ -158,11 +168,16 @@ async function backupClaudeSettings(suffix = '.bak'): Promise { return null; } -interface LoadClaudeSettingsOptions { logErrors?: boolean } +interface LoadClaudeSettingsOptions { + logErrors?: boolean; + targetPath?: string; +} + +export interface ClaudeTargetOptions { targetPath?: string } export function loadClaudeSettingsSync(options: LoadClaudeSettingsOptions = {}): ClaudeSettings { - const { logErrors = true } = options; - const settingsPath = getClaudeSettingsPath(); + const { logErrors = true, targetPath } = options; + const settingsPath = targetPath ?? defaultTargetPath(); // File doesn't exist - return empty object if (!fs.existsSync(settingsPath)) { @@ -181,8 +196,8 @@ export function loadClaudeSettingsSync(options: LoadClaudeSettingsOptions = {}): } export async function loadClaudeSettings(options: LoadClaudeSettingsOptions = {}): Promise { - const { logErrors = true } = options; - const settingsPath = getClaudeSettingsPath(); + const { logErrors = true, targetPath } = options; + const settingsPath = targetPath ?? defaultTargetPath(); // File doesn't exist - return empty object if (!fs.existsSync(settingsPath)) { @@ -201,23 +216,24 @@ export async function loadClaudeSettings(options: LoadClaudeSettingsOptions = {} } export async function saveClaudeSettings( - settings: ClaudeSettings + settings: ClaudeSettings, + targetPath = defaultTargetPath() ): Promise { - const settingsPath = getClaudeSettingsPath(); + const settingsPath = targetPath; const dir = path.dirname(settingsPath); // Backup settings before overwriting - await backupClaudeSettings(); + await backupClaudeSettings('.bak', settingsPath); await mkdir(dir, { recursive: true }); await writeFile(settingsPath, JSON.stringify(settings, null, 2), 'utf-8'); } -export async function isInstalled(): Promise { +export async function isInstalled(options: ClaudeTargetOptions = {}): Promise { let settings: ClaudeSettings; try { - settings = await loadClaudeSettings({ logErrors: false }); + settings = await loadClaudeSettings({ logErrors: false, targetPath: options.targetPath }); } catch { return false; // Can't determine if installed, assume not } @@ -400,15 +416,17 @@ async function loadSavedSettingsForHookSync(): Promise { export async function installStatusLine({ commandMode, supportsRefreshInterval = false, - installationMetadata + installationMetadata, + targetPath }: InstallStatusLineOptions): Promise { + const resolvedTarget = targetPath ?? defaultTargetPath(); let settings: ClaudeSettings; - const backupPath = await backupClaudeSettings('.orig'); + const backupPath = await backupClaudeSettings('.orig', resolvedTarget); try { - settings = await loadClaudeSettings({ logErrors: false }); + settings = await loadClaudeSettings({ logErrors: false, targetPath: resolvedTarget }); } catch { - const fallbackBackupPath = `${getClaudeSettingsPath()}.orig`; + const fallbackBackupPath = `${resolvedTarget}.orig`; console.error(`Warning: Could not read existing Claude settings. A backup exists at ${backupPath ?? fallbackBackupPath}.`); settings = {}; } @@ -426,7 +444,7 @@ export async function installStatusLine({ settings.statusLine.refreshInterval = existingRefreshInterval ?? 10; } - await saveClaudeSettings(settings); + await saveClaudeSettings(settings, resolvedTarget); if (installationMetadata !== undefined) { await saveInstallationMetadata(installationMetadata); } @@ -434,15 +452,16 @@ export async function installStatusLine({ const savedSettings = await loadSavedSettingsForHookSync(); if (savedSettings) { const { syncWidgetHooks } = await import('./hooks'); - await syncWidgetHooks(savedSettings); + await syncWidgetHooks(savedSettings, { targetPath: resolvedTarget }); } } -export async function uninstallStatusLine(): Promise { +export async function uninstallStatusLine(options: ClaudeTargetOptions = {}): Promise { + const resolvedTarget = options.targetPath ?? defaultTargetPath(); let settings: ClaudeSettings; try { - settings = await loadClaudeSettings({ logErrors: false }); + settings = await loadClaudeSettings({ logErrors: false, targetPath: resolvedTarget }); } catch { console.error('Warning: Could not read existing Claude settings.'); return; // if we can't read, return... what are we uninstalling? @@ -450,42 +469,42 @@ export async function uninstallStatusLine(): Promise { if (settings.statusLine) { delete settings.statusLine; - await saveClaudeSettings(settings); + await saveClaudeSettings(settings, resolvedTarget); } await saveInstallationMetadata(undefined); try { const { removeManagedHooks } = await import('./hooks'); - await removeManagedHooks(); + await removeManagedHooks({ targetPath: resolvedTarget }); } catch { // Ignore hook cleanup failures during uninstall } } -export async function getExistingStatusLine(): Promise { +export async function getExistingStatusLine(options: ClaudeTargetOptions = {}): Promise { try { - const settings = await loadClaudeSettings({ logErrors: false }); + const settings = await loadClaudeSettings({ logErrors: false, targetPath: options.targetPath }); return settings.statusLine?.command ?? null; } catch { return null; // Can't read settings, return null } } -export async function getRefreshInterval(): Promise { +export async function getRefreshInterval(options: ClaudeTargetOptions = {}): Promise { try { - const settings = await loadClaudeSettings({ logErrors: false }); + const settings = await loadClaudeSettings({ logErrors: false, targetPath: options.targetPath }); return settings.statusLine?.refreshInterval ?? null; } catch { return null; } } -export async function setRefreshInterval(interval: number | null): Promise { +export async function setRefreshInterval(interval: number | null, options: ClaudeTargetOptions = {}): Promise { let settings: ClaudeSettings; try { - settings = await loadClaudeSettings({ logErrors: false }); + settings = await loadClaudeSettings({ logErrors: false, targetPath: options.targetPath }); } catch { return; } @@ -500,7 +519,7 @@ export async function setRefreshInterval(interval: number | null): Promise settings.statusLine.refreshInterval = interval; } - await saveClaudeSettings(settings); + await saveClaudeSettings(settings, options.targetPath); } const VoiceConfigSchema = z.object({ enabled: z.boolean().optional() }); diff --git a/src/utils/hooks.ts b/src/utils/hooks.ts index 74d1a419..dbb0a9b3 100644 --- a/src/utils/hooks.ts +++ b/src/utils/hooks.ts @@ -13,6 +13,8 @@ export interface WidgetHookDef { matcher?: string; } +export interface SyncWidgetHooksOptions { targetPath?: string } + const HOOK_TAG = 'ccstatusline-managed'; // Matches ccstatusline hook commands written by any install method @@ -98,18 +100,18 @@ function getActiveHookDefs(settings: Settings): WidgetHookDef[] { return defs; } -export async function syncWidgetHooks(settings: Settings): Promise { +export async function syncWidgetHooks(settings: Settings, options: SyncWidgetHooksOptions = {}): Promise { const needed = getActiveHookDefs(settings); - const claudeSettings = await loadClaudeSettings({ logErrors: false }); + const claudeSettings = await loadClaudeSettings({ logErrors: false, targetPath: options.targetPath }); const hooks = (claudeSettings.hooks ?? {}) as Record; // Remove tagged entries and legacy untagged ccstatusline hook commands stripManagedHooks(hooks); - const statusCommand = await getExistingStatusLine(); + const statusCommand = await getExistingStatusLine({ targetPath: options.targetPath }); if (!statusCommand) { claudeSettings.hooks = Object.keys(hooks).length > 0 ? hooks : undefined; - await saveClaudeSettings(claudeSettings); + await saveClaudeSettings(claudeSettings, options.targetPath); return; } const hookCommand = `${statusCommand} --hook`; @@ -128,15 +130,15 @@ export async function syncWidgetHooks(settings: Settings): Promise { } claudeSettings.hooks = Object.keys(hooks).length > 0 ? hooks : undefined; - await saveClaudeSettings(claudeSettings); + await saveClaudeSettings(claudeSettings, options.targetPath); } -export async function removeManagedHooks(): Promise { - const claudeSettings = await loadClaudeSettings({ logErrors: false }); +export async function removeManagedHooks(options: SyncWidgetHooksOptions = {}): Promise { + const claudeSettings = await loadClaudeSettings({ logErrors: false, targetPath: options.targetPath }); const hooks = (claudeSettings.hooks ?? {}) as Record; stripManagedHooks(hooks); claudeSettings.hooks = Object.keys(hooks).length > 0 ? hooks : undefined; - await saveClaudeSettings(claudeSettings); + await saveClaudeSettings(claudeSettings, options.targetPath); } From 1a929814e6864f332a90cf79fa5fcdf1b909df35 Mon Sep 17 00:00:00 2001 From: CorticalCode <212432458+CorticalCode@users.noreply.github.com> Date: Mon, 13 Jul 2026 09:52:33 -0500 Subject: [PATCH 2/9] feat(scope): add a global/project/custom scope and route install targets by it Implements the context-switcher model sirmalloc proposed in #351: a scope singleton mirrors the initConfigPath idiom; project scope points the config at /.claude/ccstatusline.json and installs into /.claude/settings.local.json; an explicit --config (custom scope) and the piped/--hook paths behave exactly as before. The TUI branch of main() auto-detects an existing project config. Load-error messages now name the actual config file, hook sync no longer materializes an empty settings file on plain saves, and the refresh-interval writer resolves its target once so an in-flight scope switch cannot write across scopes. --- src/ccstatusline.ts | 6 +- src/utils/__tests__/claude-settings.test.ts | 61 ++++++++++++++ src/utils/__tests__/hooks.test.ts | 8 ++ src/utils/__tests__/scope.test.ts | 91 +++++++++++++++++++++ src/utils/claude-settings.ts | 20 +++-- src/utils/config.ts | 14 ++-- src/utils/hooks.ts | 26 +++++- src/utils/scope.ts | 77 +++++++++++++++++ 8 files changed, 287 insertions(+), 16 deletions(-) create mode 100644 src/utils/__tests__/scope.test.ts create mode 100644 src/utils/scope.ts diff --git a/src/ccstatusline.ts b/src/ccstatusline.ts index f938ab42..43ad9afe 100644 --- a/src/ccstatusline.ts +++ b/src/ccstatusline.ts @@ -36,6 +36,7 @@ import { preRenderAllWidgets, renderStatusLine } from './utils/renderer'; +import { initScope } from './utils/scope'; import { advanceGlobalSeparatorIndex } from './utils/separator-index'; import { getSkillsMetrics } from './utils/skills'; import { @@ -284,7 +285,8 @@ async function main() { } // Parse --config before anything else - initConfigPath(parseConfigArg()); + const explicitConfigPath = parseConfigArg(); + initConfigPath(explicitConfigPath); // Handle --hook mode (cross-platform hook handler for widgets) if (process.argv.includes('--hook')) { @@ -318,6 +320,8 @@ async function main() { } } else { // Interactive mode - run TUI + initScope({ explicitConfigPath, detectProject: true }); + // Remove updatemessage before running TUI const settings = await loadSettings(); if (settings.updatemessage) { diff --git a/src/utils/__tests__/claude-settings.test.ts b/src/utils/__tests__/claude-settings.test.ts index f656dcda..85d47352 100644 --- a/src/utils/__tests__/claude-settings.test.ts +++ b/src/utils/__tests__/claude-settings.test.ts @@ -21,6 +21,7 @@ import { getClaudeJsonPath, getClaudeSettingsPath, getExistingStatusLine, + getInstallTargetPath, getRefreshInterval, getVoiceConfig, installStatusLine, @@ -34,6 +35,10 @@ import { uninstallStatusLine } from '../claude-settings'; import * as config from '../config'; +import { + initScope, + setScope +} from '../scope'; const ORIGINAL_CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR; let testClaudeConfigDir = ''; @@ -824,6 +829,7 @@ describe('claude-settings target routing', () => { }); afterEach(() => { + initScope({}); fs.rmSync(targetDir, { recursive: true, force: true }); }); @@ -986,4 +992,59 @@ describe('claude-settings target routing', () => { expect(updatedDefault.statusLine?.command).toBe(CCSTATUSLINE_COMMANDS.NPM); expect(updatedDefault.hooks).toEqual(seededShape.hooks); }); + + it('project scope routes default target to the project settings.local.json', async () => { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-scope-target-')); + try { + setScope({ type: 'project', root: projectDir }); + + expect(getInstallTargetPath()).toBe(path.join(projectDir, '.claude', 'settings.local.json')); + + await saveClaudeSettings({ effortLevel: 'high' }); + expect(fs.existsSync(path.join(projectDir, '.claude', 'settings.local.json'))).toBe(true); + expect(fs.existsSync(getClaudeSettingsPath())).toBe(false); + + const command = buildStatusLineCommand('auto-bunx'); + expect(command).toContain(`--config`); + expect(command).toContain(path.join(projectDir, '.claude', 'ccstatusline.json')); + expect(classifyInstallation(command).method).toBe('auto-update'); + } finally { + initScope({}); + fs.rmSync(projectDir, { recursive: true, force: true }); + } + }); + + it('global scope keeps the default target on the user settings.json', () => { + initScope({}); + expect(getInstallTargetPath()).toBe(getClaudeSettingsPath()); + expect(buildStatusLineCommand('auto-bunx')).not.toContain('--config'); + }); + + it('setRefreshInterval resolves its target once so a mid-flight scope change cannot write across scopes', async () => { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-refresh-race-')); + try { + setScope({ type: 'project', root: projectDir }); + const projectTarget = path.join(projectDir, '.claude', 'settings.local.json'); + fs.mkdirSync(path.dirname(projectTarget), { recursive: true }); + fs.writeFileSync(projectTarget, JSON.stringify({ + statusLine: { type: 'command', command: 'bunx -y ccstatusline@latest', padding: 0 }, + sandbox: { enabled: true } + }), 'utf-8'); + + const pending = setRefreshInterval(7); + initScope({}); + await pending; + + const project = JSON.parse(fs.readFileSync(projectTarget, 'utf-8')) as { + statusLine?: { refreshInterval?: number }; + sandbox?: unknown; + }; + expect(project.statusLine?.refreshInterval).toBe(7); + expect(project.sandbox).toEqual({ enabled: true }); + expect(fs.existsSync(getClaudeSettingsPath())).toBe(false); + } finally { + initScope({}); + fs.rmSync(projectDir, { recursive: true, force: true }); + } + }); }); diff --git a/src/utils/__tests__/hooks.test.ts b/src/utils/__tests__/hooks.test.ts index a3f9f014..a3fa87ac 100644 --- a/src/utils/__tests__/hooks.test.ts +++ b/src/utils/__tests__/hooks.test.ts @@ -316,4 +316,12 @@ describe('hooks target routing', () => { const globalFile = JSON.parse(fs.readFileSync(getClaudeSettingsPath(), 'utf-8')) as { hooks?: unknown }; expect(globalFile.hooks).toBeDefined(); }); + + it('syncWidgetHooks with no hook widgets and a nonexistent explicit target does not create the file', async () => { + expect(fs.existsSync(targetPath)).toBe(false); + + await syncWidgetHooks(DEFAULT_SETTINGS, { targetPath }); + + expect(fs.existsSync(targetPath)).toBe(false); + }); }); diff --git a/src/utils/__tests__/scope.test.ts b/src/utils/__tests__/scope.test.ts new file mode 100644 index 00000000..80e2352e --- /dev/null +++ b/src/utils/__tests__/scope.test.ts @@ -0,0 +1,91 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi +} from 'vitest'; + +import { getConfigPath } from '../config'; +import { + getProjectConfigPath, + getProjectInstallTargetPath, + getScope, + initScope, + isScopeSwitchingAvailable, + setScope +} from '../scope'; + +describe('scope', () => { + let projectDir = ''; + + beforeEach(() => { + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-scope-')); + vi.spyOn(process, 'cwd').mockReturnValue(projectDir); + }); + + afterEach(() => { + vi.restoreAllMocks(); + initScope({}); // reset singleton + config path to defaults + fs.rmSync(projectDir, { recursive: true, force: true }); + }); + + it('defaults to global', () => { + initScope({}); + expect(getScope()).toEqual({ type: 'global' }); + expect(isScopeSwitchingAvailable()).toBe(true); + expect(getProjectInstallTargetPath()).toBeNull(); + }); + + it('explicit --config wins and disables switching', () => { + fs.mkdirSync(path.join(projectDir, '.claude'), { recursive: true }); + fs.writeFileSync(getProjectConfigPath(projectDir), '{}', 'utf-8'); + + initScope({ explicitConfigPath: '/somewhere/custom.json', detectProject: true }); + + expect(getScope()).toEqual({ type: 'custom' }); + expect(isScopeSwitchingAvailable()).toBe(false); + expect(getProjectInstallTargetPath()).toBeNull(); + }); + + it('detects a project config in cwd when detectProject is set', () => { + fs.mkdirSync(path.join(projectDir, '.claude'), { recursive: true }); + fs.writeFileSync(getProjectConfigPath(projectDir), '{}', 'utf-8'); + + initScope({ detectProject: true }); + + expect(getScope()).toEqual({ type: 'project', root: projectDir }); + expect(getConfigPath()).toBe(getProjectConfigPath(projectDir)); + expect(getProjectInstallTargetPath()).toBe(path.join(projectDir, '.claude', 'settings.local.json')); + }); + + it('does not detect without the flag (piped/hook paths)', () => { + fs.mkdirSync(path.join(projectDir, '.claude'), { recursive: true }); + fs.writeFileSync(getProjectConfigPath(projectDir), '{}', 'utf-8'); + + initScope({}); + + expect(getScope()).toEqual({ type: 'global' }); + }); + + it('stays global when no project config exists even with detectProject', () => { + initScope({ detectProject: true }); + expect(getScope()).toEqual({ type: 'global' }); + }); + + it('setScope switches both the scope and the config path, both directions', () => { + initScope({}); + + setScope({ type: 'project', root: projectDir }); + expect(getScope()).toEqual({ type: 'project', root: projectDir }); + expect(getConfigPath()).toBe(getProjectConfigPath(projectDir)); + + setScope({ type: 'global' }); + expect(getScope()).toEqual({ type: 'global' }); + expect(getConfigPath()).not.toBe(getProjectConfigPath(projectDir)); + }); +}); diff --git a/src/utils/claude-settings.ts b/src/utils/claude-settings.ts index 75705dc7..a05dd288 100644 --- a/src/utils/claude-settings.ts +++ b/src/utils/claude-settings.ts @@ -16,6 +16,7 @@ import { isCustomConfigPath, saveInstallationMetadata } from './config'; +import { getProjectInstallTargetPath } from './scope'; // Re-export for backward compatibility export type { ClaudeSettings }; @@ -141,12 +142,16 @@ export function getClaudeSettingsPath(): string { } /** - * Default Claude-settings file all reads/writes target when no explicit - * targetPath is given. Becomes scope-aware in the project-mode work - * (project scope routes to /.claude/settings.local.json). + * The Claude-settings file all reads/writes target when no explicit targetPath + * is given: the project's settings.local.json in project scope, else the + * user-global settings.json (CLAUDE_CONFIG_DIR-aware). */ +export function getInstallTargetPath(): string { + return getProjectInstallTargetPath() ?? getClaudeSettingsPath(); +} + function defaultTargetPath(): string { - return getClaudeSettingsPath(); + return getInstallTargetPath(); } /** @@ -501,10 +506,13 @@ export async function getRefreshInterval(options: ClaudeTargetOptions = {}): Pro } export async function setRefreshInterval(interval: number | null, options: ClaudeTargetOptions = {}): Promise { + // Resolve once so an in-flight scope switch cannot split the read and the + // write across different settings files. + const targetPath = options.targetPath ?? defaultTargetPath(); let settings: ClaudeSettings; try { - settings = await loadClaudeSettings({ logErrors: false, targetPath: options.targetPath }); + settings = await loadClaudeSettings({ logErrors: false, targetPath }); } catch { return; } @@ -519,7 +527,7 @@ export async function setRefreshInterval(interval: number | null, options: Claud settings.statusLine.refreshInterval = interval; } - await saveClaudeSettings(settings, options.targetPath); + await saveClaudeSettings(settings, targetPath); } const VoiceConfigSchema = z.object({ enabled: z.boolean().optional() }); diff --git a/src/utils/config.ts b/src/utils/config.ts index 16979446..d81c953e 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -164,6 +164,10 @@ async function writeDefaultSettings(paths: SettingsPaths): Promise { export async function loadSettings(): Promise { lastLoadError = null; const paths = getSettingsPaths(); + // Project scope loads .claude/ccstatusline.json, and --config accepts any file + // name; derive the display name once so error text/console output always names + // the file actually in play instead of hard-coding "settings.json". + const fileName = path.basename(paths.settingsPath); try { // Check if settings file exists @@ -176,8 +180,8 @@ export async function loadSettings(): Promise { try { rawData = JSON.parse(content); } catch { - console.error('Failed to parse settings.json, using defaults (file left unchanged)'); - lastLoadError = 'settings.json is not valid JSON'; + console.error(`Failed to parse ${fileName}, using defaults (file left unchanged)`); + lastLoadError = `${fileName} is not valid JSON`; return inMemoryDefaults(); } @@ -189,7 +193,7 @@ export async function loadSettings(): Promise { const v1Result = SettingsSchema_v1.safeParse(rawData); if (!v1Result.success) { console.error('Invalid v1 settings format, using defaults (file left unchanged):', v1Result.error); - lastLoadError = 'settings.json is not in a valid format'; + lastLoadError = `${fileName} is not in a valid format`; return inMemoryDefaults(); } @@ -207,7 +211,7 @@ export async function loadSettings(): Promise { const result = SettingsSchema.safeParse(rawData); if (!result.success) { console.error('Failed to parse settings, using defaults (file left unchanged):', result.error); - lastLoadError = 'settings.json is not in a valid format'; + lastLoadError = `${fileName} is not in a valid format`; return inMemoryDefaults(); } @@ -223,7 +227,7 @@ export async function loadSettings(): Promise { }; } catch (error) { console.error('Error loading settings, using defaults:', error); - lastLoadError = 'settings.json could not be read'; + lastLoadError = `${fileName} could not be read`; return inMemoryDefaults(); } } diff --git a/src/utils/hooks.ts b/src/utils/hooks.ts index dbb0a9b3..48dc2fe6 100644 --- a/src/utils/hooks.ts +++ b/src/utils/hooks.ts @@ -1,8 +1,11 @@ +import * as fs from 'fs'; + import type { Settings } from '../types/Settings'; import type { Widget } from '../types/Widget'; import { getExistingStatusLine, + getInstallTargetPath, loadClaudeSettings, saveClaudeSettings } from './claude-settings'; @@ -101,17 +104,32 @@ function getActiveHookDefs(settings: Settings): WidgetHookDef[] { } export async function syncWidgetHooks(settings: Settings, options: SyncWidgetHooksOptions = {}): Promise { + // Resolve once: used for the no-op short-circuit below and every read/write in + // this call, so the check and the actual I/O always agree on the same file. + const targetPath = options.targetPath ?? getInstallTargetPath(); const needed = getActiveHookDefs(settings); - const claudeSettings = await loadClaudeSettings({ logErrors: false, targetPath: options.targetPath }); + const claudeSettings = await loadClaudeSettings({ logErrors: false, targetPath }); const hooks = (claudeSettings.hooks ?? {}) as Record; // Remove tagged entries and legacy untagged ccstatusline hook commands stripManagedHooks(hooks); - const statusCommand = await getExistingStatusLine({ targetPath: options.targetPath }); + // Nothing to add, nothing on disk to clean up, and no file to touch: skip + // materializing an empty Claude settings file (e.g. settings.local.json) just + // to persist `{}`. + if ( + !fs.existsSync(targetPath) + && needed.length === 0 + && !claudeSettings.statusLine + && Object.keys(hooks).length === 0 + ) { + return; + } + + const statusCommand = await getExistingStatusLine({ targetPath }); if (!statusCommand) { claudeSettings.hooks = Object.keys(hooks).length > 0 ? hooks : undefined; - await saveClaudeSettings(claudeSettings, options.targetPath); + await saveClaudeSettings(claudeSettings, targetPath); return; } const hookCommand = `${statusCommand} --hook`; @@ -130,7 +148,7 @@ export async function syncWidgetHooks(settings: Settings, options: SyncWidgetHoo } claudeSettings.hooks = Object.keys(hooks).length > 0 ? hooks : undefined; - await saveClaudeSettings(claudeSettings, options.targetPath); + await saveClaudeSettings(claudeSettings, targetPath); } export async function removeManagedHooks(options: SyncWidgetHooksOptions = {}): Promise { diff --git a/src/utils/scope.ts b/src/utils/scope.ts new file mode 100644 index 00000000..432f1b63 --- /dev/null +++ b/src/utils/scope.ts @@ -0,0 +1,77 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import { initConfigPath } from './config'; + +/** + * Which configuration context the process operates in. + * + * - global: today's behavior — config in ~/.config/ccstatusline/settings.json, + * installs into Claude's user settings.json. + * - project: config in /.claude/ccstatusline.json (committable), installs + * into /.claude/settings.local.json (personal, gitignored by Claude Code). + * - custom: an explicit --config path was given; behaves exactly like global for + * install targets and disables TUI mode switching. + */ +export type Scope + = | { type: 'global' } + | { type: 'project'; root: string } + | { type: 'custom' }; + +export type SwitchableScope = Exclude; + +let activeScope: Scope = { type: 'global' }; + +export function getProjectConfigPath(root: string): string { + return path.join(root, '.claude', 'ccstatusline.json'); +} + +/** + * Resolve the startup scope. Called once from main(): the TUI entry passes + * detectProject so an existing /.claude/ccstatusline.json selects project + * mode; piped render and --hook paths never auto-detect. + */ +export function initScope(options: { explicitConfigPath?: string; detectProject?: boolean } = {}): void { + if (options.explicitConfigPath) { + activeScope = { type: 'custom' }; + return; + } + + if (options.detectProject) { + const root = process.cwd(); + if (fs.existsSync(getProjectConfigPath(root))) { + setScope({ type: 'project', root }); + return; + } + } + + setScope({ type: 'global' }); +} + +export function getScope(): Scope { + return activeScope; +} + +/** + * The single mutation point: keeps the scope and the ccstatusline config path + * in lockstep so config.ts needs no scope awareness of its own. + */ +export function setScope(scope: SwitchableScope): void { + activeScope = scope; + initConfigPath(scope.type === 'project' ? getProjectConfigPath(scope.root) : undefined); +} + +export function isScopeSwitchingAvailable(): boolean { + return activeScope.type !== 'custom'; +} + +/** + * Claude-settings install target override: project scope installs into the + * project's personal settings.local.json; other scopes return null so callers + * fall back to the user-global settings.json. + */ +export function getProjectInstallTargetPath(): string | null { + return activeScope.type === 'project' + ? path.join(activeScope.root, '.claude', 'settings.local.json') + : null; +} From 2744b8367fdc64940d54ff5934e4f2b679ab358f Mon Sep 17 00:00:00 2001 From: CorticalCode <212432458+CorticalCode@users.noreply.github.com> Date: Mon, 13 Jul 2026 09:52:33 -0500 Subject: [PATCH 3/9] feat(tui): add the ctrl+p global/project mode switcher ctrl+p flips between global and project mode from the main menu, with a header indicator showing the resolved project config path. Entering a project with no config offers to copy the global config or start with defaults, seeding in memory only; the file is created on explicit save. Switching with unsaved edits prompts save/discard/cancel, routed through the invalid-config save guard. Install dialogs show the scope-aware target and save the project config before installing so the installed --config never dangles. The project-config detection, copy-global seeding, and active-config indicator build on jackall3n's #58. Implements the switcher sirmalloc designed in #351; closes the settings.local.json request from #276. --- src/tui/App.tsx | 324 ++++++++++++++++++++++--- src/tui/__tests__/App.test.ts | 91 ++++++- src/tui/components/InstallMenu.tsx | 4 +- src/tui/components/ScopeSwitchMenu.tsx | 51 ++++ 4 files changed, 429 insertions(+), 41 deletions(-) create mode 100644 src/tui/components/ScopeSwitchMenu.tsx diff --git a/src/tui/App.tsx b/src/tui/App.tsx index dbcd9c5d..a848bde9 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -1,4 +1,5 @@ import chalk from 'chalk'; +import * as fs from 'fs'; import { Box, Text, @@ -7,23 +8,26 @@ import { useInput } from 'ink'; import Gradient from 'ink-gradient'; +import * as os from 'os'; import React, { useCallback, useEffect, + useRef, useState } from 'react'; -import type { - InstallationMetadata, - ResolvedInstallationMetadata, - Settings +import { + DEFAULT_SETTINGS, + type InstallationMetadata, + type ResolvedInstallationMetadata, + type Settings } from '../types/Settings'; import type { WidgetItem } from '../types/Widget'; import { buildStatusLineCommand, classifyInstallation, - getClaudeSettingsPath, getExistingStatusLine, + getInstallTargetPath, getPackageCommandAvailability, installStatusLine, isClaudeCodeVersionAtLeast, @@ -60,6 +64,14 @@ import { installPowerlineFonts, type PowerlineFontStatus } from '../utils/powerline'; +import { + getProjectConfigPath, + getScope, + isScopeSwitchingAvailable, + setScope, + type Scope, + type SwitchableScope +} from '../utils/scope'; import { getPackageVersion } from '../utils/terminal'; import { checkForUpdates, @@ -96,6 +108,11 @@ import { List, type ListEntry } from './components/List'; +import { + ScopeSwitchMenu, + type ScopeSwitchChoice, + type ScopeSwitchStage +} from './components/ScopeSwitchMenu'; const GITHUB_REPO_URL = 'https://github.com/sirmalloc/ccstatusline'; @@ -119,7 +136,8 @@ type AppScreen = 'main' | 'manageInstallation' | 'uninstallOptions' | 'updates' - | 'refreshInterval'; + | 'refreshInterval' + | 'scopeSwitch'; type PinnedVersionMismatchAction = 'update' | 'exit'; @@ -279,14 +297,14 @@ function getGlobalUninstallCommand(packageManager: GlobalPackageManager): string function buildUninstallConfirmMessage(selection: UninstallSelection): string { if (selection.packageManagers.length === 0) { - return `This will remove ccstatusline from ${getClaudeSettingsPath()}. Continue?`; + return `This will remove ccstatusline from ${getInstallTargetPath()}. Continue?`; } const commands = selection.packageManagers .map(packageManager => getGlobalUninstallCommand(packageManager)) .join('\n'); - return `This will remove ccstatusline from ${getClaudeSettingsPath()} and run:\n\n${commands}\n\nContinue?`; + return `This will remove ccstatusline from ${getInstallTargetPath()} and run:\n\n${commands}\n\nContinue?`; } function clearInstallationMetadata(settings: Settings | null): Settings | null { @@ -422,6 +440,45 @@ export function buildConfigLoadWarning(configLoadError: string | null): string | return `⚠ ${configLoadError} — showing defaults; saving here overwrites the file.`; } +/** + * Replaces a leading os.homedir() prefix with ~. Only a leading prefix is + * abbreviated (startsWith + slice, not a global replace); paths outside the + * home dir are returned as-is. + */ +function abbreviateHomeDir(target: string): string { + const homeDir = os.homedir(); + return target.startsWith(homeDir) ? `~${target.slice(homeDir.length)}` : target; +} + +export function getScopeIndicator(scope: Scope): string | null { + switch (scope.type) { + case 'custom': + return null; + case 'global': + return 'Mode: Global · ctrl+p to switch'; + case 'project': + return `Mode: Project (${abbreviateHomeDir(getProjectConfigPath(scope.root))}) · ctrl+p to switch`; + } +} + +export function planScopeSwitch(input: { + switchingAvailable: boolean; + hasChanges: boolean; + enteringProject: boolean; + projectConfigExists: boolean; +}): 'blocked' | 'confirm-unsaved' | 'offer-seed' | 'switch' { + if (!input.switchingAvailable) { + return 'blocked'; + } + if (input.hasChanges) { + return 'confirm-unsaved'; + } + if (input.enteringProject && !input.projectConfigExists) { + return 'offer-seed'; + } + return 'switch'; +} + export function buildInvalidConfigSaveConfirm( configLoadError: string | null, onConfirm: () => void @@ -467,21 +524,26 @@ export const App: React.FC = () => { const [updatesReturnScreen, setUpdatesReturnScreen] = useState<'main' | 'manageInstallation'>('main'); const [hasLoadedClaudeStatus, setHasLoadedClaudeStatus] = useState(false); const [hasLoadedInstalledState, setHasLoadedInstalledState] = useState(false); - + const [scope, setScopeState] = useState(() => getScope()); + const settingsRef = useRef(settings); useEffect(() => { - void loadClaudeStatusLineState() - .then((statusLineState) => { - setExistingStatusLine(statusLineState.existingStatusLine); - setCurrentRefreshInterval(statusLineState.refreshInterval); - }) - .catch(() => { - setExistingStatusLine(null); - setCurrentRefreshInterval(null); - }) - .finally(() => { - setHasLoadedClaudeStatus(true); - }); - void loadSettings().then((loadedSettings) => { + settingsRef.current = settings; + }, [settings]); + + const reloadScopeData = useCallback(async (options: { skipSettings?: boolean } = {}) => { + try { + const statusLineState = await loadClaudeStatusLineState(); + setExistingStatusLine(statusLineState.existingStatusLine); + setCurrentRefreshInterval(statusLineState.refreshInterval); + } catch { + setExistingStatusLine(null); + setCurrentRefreshInterval(null); + } finally { + setHasLoadedClaudeStatus(true); + } + + if (!options.skipSettings) { + const loadedSettings = await loadSettings(); // Set global chalk level based on settings (default to 256 colors for compatibility) chalk.level = loadedSettings.colorLevel; setSettings(loadedSettings); @@ -490,13 +552,159 @@ export const App: React.FC = () => { // guard saves. Read it here, in the load callback: the module-scoped signal is // reset by any later loadSettings/saveInstallationMetadata call. setConfigLoadError(getConfigLoadError()); + } + + try { + setIsClaudeInstalled(await isInstalled()); + } catch { + setIsClaudeInstalled(false); + } finally { + setHasLoadedInstalledState(true); + } + }, []); + + const [scopeSwitchStage, setScopeSwitchStage] = useState(null); + const [pendingScopeTarget, setPendingScopeTarget] = useState(null); + + const executeSwitch = useCallback((target: SwitchableScope, seed: 'none' | 'copy' | 'defaults') => { + // Seed source must be the last on-disk global state, captured before the + // scope flips: after the unsaved-changes menu, originalSettings is it. + // A copy seed strips installation metadata: that block describes how the + // *global* scope's file was installed and must not leak into what becomes + // the committable project config. + const seedSource = seed === 'copy' && originalSettings + ? clearInstallationMetadata(cloneSettings(originalSettings)) ?? cloneSettings(DEFAULT_SETTINGS) + : cloneSettings(DEFAULT_SETTINGS); + + setScope(target); + setScopeState(getScope()); + setScopeSwitchStage(null); + setPendingScopeTarget(null); + setScreen('main'); + + if (seed === 'none') { + void reloadScopeData(); + return; + } + + // Seeded entry populates the editor in memory only; the project file is + // created by an explicit save, never by the switch itself. originalSettings + // stays null so the change detector leaves hasChanges alone. + setSettings(seedSource); + setOriginalSettings(null); + setHasChanges(true); + // A seeded scope has no on-disk file yet, so it cannot have a load error; + // clear any stale banner/guard carried over from the scope being left. + setConfigLoadError(null); + void reloadScopeData({ skipSettings: true }); + }, [originalSettings, reloadScopeData]); + + const continueScopeSwitch = useCallback((target: SwitchableScope) => { + if (target.type === 'project' && !fs.existsSync(getProjectConfigPath(target.root))) { + setScopeSwitchStage('seed'); + setScreen('scopeSwitch'); + return; + } + executeSwitch(target, 'none'); + }, [executeSwitch]); + + const requestScopeSwitch = useCallback(() => { + if (!isScopeSwitchingAvailable()) { + setFlashMessage({ + text: '⚠ Mode switching is disabled when --config is passed', + color: 'yellow' + }); + return; + } + const target: SwitchableScope = scope.type === 'project' + ? { type: 'global' } + : { type: 'project', root: process.cwd() }; + + const plan = planScopeSwitch({ + switchingAvailable: true, + hasChanges, + enteringProject: target.type === 'project', + projectConfigExists: target.type === 'project' && fs.existsSync(getProjectConfigPath(target.root)) }); - void isInstalled() - .then(setIsClaudeInstalled) - .catch(() => { setIsClaudeInstalled(false); }) - .finally(() => { - setHasLoadedInstalledState(true); + + setPendingScopeTarget(target); + if (plan === 'confirm-unsaved') { + setScopeSwitchStage('unsaved'); + setScreen('scopeSwitch'); + return; + } + if (plan === 'offer-seed') { + setScopeSwitchStage('seed'); + setScreen('scopeSwitch'); + return; + } + executeSwitch(target, 'none'); + }, [scope, hasChanges, executeSwitch]); + + const handleScopeSwitchChoice = useCallback((choice: ScopeSwitchChoice) => { + const target = pendingScopeTarget; + if (!target || choice === 'cancel') { + setScopeSwitchStage(null); + setPendingScopeTarget(null); + setScreen('main'); + return; + } + + if (choice === 'save' && settings) { + const performSave = () => { + void (async () => { + try { + await saveSettings(settings); + setOriginalSettings(cloneSettings(settings)); + setHasChanges(false); + setConfigLoadError(null); + // continueScopeSwitch may re-enter the 'seed' stage (project scope with + // no config file yet), which relies on pendingScopeTarget being set for + // the next handleScopeSwitchChoice call. Re-establish it here since the + // guard branch below clears it while the confirm dialog is shown. + setPendingScopeTarget(target); + continueScopeSwitch(target); + } catch { + setScopeSwitchStage(null); + setPendingScopeTarget(null); + setScreen('main'); + setFlashMessage({ text: '✗ Could not save configuration', color: 'red' }); + } + })(); + }; + + // Mirror the ctrl+s guard: an invalid on-disk config must not be silently + // overwritten by "Save and switch" either. Show the same confirm dialog, and + // clear the switcher state so a cancel (which lands on the dialog's default + // 'main' cancelScreen) leaves no stale scope-switch state behind. + const saveGuard = buildInvalidConfigSaveConfirm(configLoadError, () => { + setConfirmDialog(null); + setScreen('main'); + performSave(); }); + if (saveGuard) { + setScopeSwitchStage(null); + setPendingScopeTarget(null); + setConfirmDialog(saveGuard); + setScreen('confirm'); + } else { + performSave(); + } + return; + } + + if (choice === 'discard') { + continueScopeSwitch(target); + return; + } + + if (choice === 'copy' || choice === 'defaults') { + executeSwitch(target, choice); + } + }, [pendingScopeTarget, settings, configLoadError, continueScopeSwitch, executeSwitch]); + + useEffect(() => { + void reloadScopeData(); // Check for Powerline fonts on startup (use sync version that doesn't call execSync) const fontStatus = checkPowerlineFonts(); @@ -515,7 +723,7 @@ export const App: React.FC = () => { return () => { process.stdout.off('resize', handleResize); }; - }, []); + }, [reloadScopeData]); // Check for changes whenever settings update useEffect(() => { @@ -535,18 +743,26 @@ export const App: React.FC = () => { } }, [flashMessage]); + // Shared by the ctrl+s save shortcut and the ctrl+p scope-switch shortcut: both + // must refuse to act while the running TUI doesn't match the pinned global + // install, since either one can end up writing config the pinned runtime may + // not support. + const computeActivePinnedVersionMismatch = useCallback((currentSettings: Settings) => { + const installation = getCurrentInstallation(isClaudeInstalled, existingStatusLine, currentSettings); + const activeCommand = installation.method === 'pinned' || installation.method === 'self-managed' + ? inspectActiveGlobalCommand({ commandAvailability }) + : null; + const effectiveInstallation = getPathInferredInstallation(installation, activeCommand); + return getPinnedVersionMismatch(effectiveInstallation, getPackageVersion(), 'ccstatusline'); + }, [isClaudeInstalled, existingStatusLine, commandAvailability]); + useInput((input, key) => { if (key.ctrl && input === 'c') { exit(); } // Global save shortcut if (key.ctrl && input === 's' && settings && screen !== 'confirm') { - const installation = getCurrentInstallation(isClaudeInstalled, existingStatusLine, settings); - const activeCommand = installation.method === 'pinned' || installation.method === 'self-managed' - ? inspectActiveGlobalCommand({ commandAvailability }) - : null; - const effectiveInstallation = getPathInferredInstallation(installation, activeCommand); - const mismatch = getPinnedVersionMismatch(effectiveInstallation, getPackageVersion(), 'ccstatusline'); + const mismatch = computeActivePinnedVersionMismatch(settings); if (mismatch) { return; } @@ -587,6 +803,18 @@ export const App: React.FC = () => { performSave(); } } + + // Global scope switch shortcut (main screen only: submenus hold settings + // state that a scope flip would swap out from under them) + if (key.ctrl && input === 'p' && settings && screen === 'main') { + // Mirror the ctrl+s guard: the mismatch screen only early-returns from + // render without changing `screen`, so without this check ctrl+p would + // still fire (and switch scope) behind it. + if (computeActivePinnedVersionMismatch(settings)) { + return; + } + requestScopeSwitch(); + } }); const getGlobalResolutionWarning = useCallback((packageManager: 'npm' | 'bun') => ( @@ -599,7 +827,10 @@ export const App: React.FC = () => { const finalCommand = buildStatusLineCommand(selection.commandMode); const hookCommand = `${finalCommand} --hook`; const sideEffects = [ - `Claude settings path: ${getClaudeSettingsPath()}`, + `Claude settings path: ${getInstallTargetPath()}`, + ...(getScope().type === 'project' && !fs.existsSync(getConfigPath()) + ? [`Project config will be saved first: ${getConfigPath()}`] + : []), ...(selection.globalInstallCommand ? [`Global install command before settings write: ${selection.globalInstallCommand}`] : []), @@ -625,6 +856,15 @@ export const App: React.FC = () => { await runGlobalPackageInstall(selection.packageManager, getPackageVersion()); } + // In project mode the installed command carries --config + // pointing at the project file; make sure it exists before + // the install references it. + if (getScope().type === 'project' && !fs.existsSync(getConfigPath()) && settingsRef.current) { + await saveSettings(settingsRef.current); + setOriginalSettings(cloneSettings(settingsRef.current)); + setHasChanges(false); + } + await installStatusLine({ commandMode: selection.commandMode, supportsRefreshInterval, @@ -1062,9 +1302,11 @@ export const App: React.FC = () => { {configWarning && ( {configWarning} )} - {isCustomConfigPath() && ( - {`Config: ${getConfigPath()}`} - )} + {getScopeIndicator(scope) + ? {getScopeIndicator(scope)} + : isCustomConfigPath() && ( + {`Config: ${getConfigPath()}`} + )} { onClearMessage={() => { setFontInstallMessage(null); }} /> )} + {screen === 'scopeSwitch' && scopeSwitchStage && ( + + )} ); diff --git a/src/tui/__tests__/App.test.ts b/src/tui/__tests__/App.test.ts index 9b1b84b3..52ff4e2f 100644 --- a/src/tui/__tests__/App.test.ts +++ b/src/tui/__tests__/App.test.ts @@ -1,3 +1,5 @@ +import * as os from 'os'; +import * as path from 'path'; import { describe, expect, @@ -9,6 +11,7 @@ import { DEFAULT_SETTINGS, type InstallationMetadata } from '../../types/Settings'; +import { getProjectConfigPath } from '../../utils/scope'; import { buildConfigLoadWarning, buildInvalidConfigSaveConfirm, @@ -16,7 +19,9 @@ import { getConfirmCancelScreen, getCurrentInstallation, getPathInferredInstallation, - getPinnedVersionMismatch + getPinnedVersionMismatch, + getScopeIndicator, + planScopeSwitch } from '../App'; import { buildMainMenuItems, @@ -24,6 +29,7 @@ import { getMainMenuSelectionIndex } from '../components/MainMenu'; import { buildManageInstallationItems } from '../components/ManageInstallationMenu'; +import { getScopeSwitchMenuItems } from '../components/ScopeSwitchMenu'; function getMenuValues( isClaudeInstalled: boolean, @@ -288,3 +294,86 @@ describe('Invalid-config TUI guards', () => { .toContain('not in a valid format'); }); }); + +describe('Scope indicator', () => { + it('shows global mode with the switch hint', () => { + expect(getScopeIndicator({ type: 'global' })).toBe('Mode: Global · ctrl+p to switch'); + }); + + it('shows the ~-abbreviated resolved config path when the root is under the home dir', () => { + const root = path.join(os.homedir(), 'some', 'project'); + const expectedConfigPath = path.join('~', 'some', 'project', '.claude', 'ccstatusline.json'); + expect(getScopeIndicator({ type: 'project', root })) + .toBe(`Mode: Project (${expectedConfigPath}) · ctrl+p to switch`); + }); + + it('shows the raw resolved config path when the root is outside the home dir', () => { + const outsideRoot = path.join(path.parse(os.homedir()).root, 'outside-of-home', 'project'); + expect(outsideRoot.startsWith(os.homedir())).toBe(false); + expect(getScopeIndicator({ type: 'project', root: outsideRoot })) + .toBe(`Mode: Project (${getProjectConfigPath(outsideRoot)}) · ctrl+p to switch`); + }); + + it('returns null for custom scope so the Config path line renders instead', () => { + expect(getScopeIndicator({ type: 'custom' })).toBeNull(); + }); +}); + +describe('Scope switch planning', () => { + it('blocks when switching is unavailable (custom scope)', () => { + expect(planScopeSwitch({ + switchingAvailable: false, + hasChanges: false, + enteringProject: true, + projectConfigExists: false + })).toBe('blocked'); + }); + + it('asks about unsaved changes first', () => { + expect(planScopeSwitch({ + switchingAvailable: true, + hasChanges: true, + enteringProject: true, + projectConfigExists: false + })).toBe('confirm-unsaved'); + }); + + it('offers seeding when entering a project without a config', () => { + expect(planScopeSwitch({ + switchingAvailable: true, + hasChanges: false, + enteringProject: true, + projectConfigExists: false + })).toBe('offer-seed'); + }); + + it('switches directly into an existing project config', () => { + expect(planScopeSwitch({ + switchingAvailable: true, + hasChanges: false, + enteringProject: true, + projectConfigExists: true + })).toBe('switch'); + }); + + it('switches directly back to global', () => { + expect(planScopeSwitch({ + switchingAvailable: true, + hasChanges: false, + enteringProject: false, + projectConfigExists: false + })).toBe('switch'); + }); +}); + +describe('Scope switch menu items', () => { + it('offers save/discard/cancel for unsaved changes', () => { + expect(getScopeSwitchMenuItems('unsaved').map(item => item.value)) + .toEqual(['save', 'discard', 'cancel']); + }); + + it('offers copy/defaults/cancel for an empty project', () => { + expect(getScopeSwitchMenuItems('seed').map(item => item.value)) + .toEqual(['copy', 'defaults', 'cancel']); + }); +}); diff --git a/src/tui/components/InstallMenu.tsx b/src/tui/components/InstallMenu.tsx index 419bf5e5..5cb7ee1e 100644 --- a/src/tui/components/InstallMenu.tsx +++ b/src/tui/components/InstallMenu.tsx @@ -9,7 +9,7 @@ import type { InstallationMetadata } from '../../types/Settings'; import { CCSTATUSLINE_COMMANDS, PINNED_INSTALL_COMMANDS, - getClaudeSettingsPath, + getInstallTargetPath, type PackageCommandAvailability, type StatusLineCommandMode } from '../../utils/claude-settings'; @@ -227,7 +227,7 @@ export const InstallMenu: React.FC = ({ The selected command will be written to {' '} - {getClaudeSettingsPath()} + {getInstallTargetPath()} diff --git a/src/tui/components/ScopeSwitchMenu.tsx b/src/tui/components/ScopeSwitchMenu.tsx new file mode 100644 index 00000000..6b8ae757 --- /dev/null +++ b/src/tui/components/ScopeSwitchMenu.tsx @@ -0,0 +1,51 @@ +import { + Box, + Text +} from 'ink'; +import React from 'react'; + +import { List } from './List'; + +export type ScopeSwitchStage = 'unsaved' | 'seed'; +export type ScopeSwitchChoice = 'save' | 'discard' | 'copy' | 'defaults' | 'cancel'; + +export interface ScopeSwitchMenuProps { + stage: ScopeSwitchStage; + onSelect: (choice: ScopeSwitchChoice) => void; +} + +export function getScopeSwitchMenuItems(stage: ScopeSwitchStage): { label: string; value: ScopeSwitchChoice }[] { + if (stage === 'unsaved') { + return [ + { label: '💾 Save and switch', value: 'save' }, + { label: '🗑️ Discard and switch', value: 'discard' }, + { label: '❌ Cancel', value: 'cancel' } + ]; + } + + return [ + { label: '📋 Copy current global config', value: 'copy' }, + { label: '✨ Start with defaults', value: 'defaults' }, + { label: '❌ Cancel', value: 'cancel' } + ]; +} + +export const ScopeSwitchMenu: React.FC = ({ stage, onSelect }) => { + const title = stage === 'unsaved' + ? 'You have unsaved changes' + : 'No project config found in this directory'; + + return ( + + {title} + + { + onSelect(value === 'back' ? 'cancel' : value); + }} + /> + + + ); +}; From 0b651f07e4cf98f5aaa5f2b395d399730d4a3132 Mon Sep 17 00:00:00 2001 From: Matthew Breedlove Date: Sat, 25 Jul 2026 22:29:49 -0400 Subject: [PATCH 4/9] fix(tui): block interaction while switching config scopes Invalidate the source settings and enter an explicit loading state before changing the module-level config path for an existing scope. Gate global shortcuts with a synchronous ref until the target reload completes so Ctrl+S cannot write stale source settings into the newly selected scope. Add an Ink regression test that holds the target load open, verifies the loading screen is shown, and confirms save remains disabled until the target settings are available. --- src/tui/App.tsx | 33 +++- src/tui/__tests__/App.scope-loading.test.tsx | 176 +++++++++++++++++++ 2 files changed, 205 insertions(+), 4 deletions(-) create mode 100644 src/tui/__tests__/App.scope-loading.test.tsx diff --git a/src/tui/App.tsx b/src/tui/App.tsx index afc1679b..e3ba562d 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -547,6 +547,8 @@ export const App: React.FC = () => { const [hasLoadedInstalledState, setHasLoadedInstalledState] = useState(false); const [scope, setScopeState] = useState(() => getScope()); const settingsRef = useRef(settings); + const scopeReloadingRef = useRef(false); + const [isScopeReloading, setIsScopeReloading] = useState(false); const [importValidation, setImportValidation] = useState(null); useEffect(() => { @@ -599,20 +601,40 @@ export const App: React.FC = () => { ? clearInstallationMetadata(cloneSettings(originalSettings)) ?? cloneSettings(DEFAULT_SETTINGS) : cloneSettings(DEFAULT_SETTINGS); - setScope(target); - setScopeState(getScope()); setScopeSwitchStage(null); setPendingScopeTarget(null); setScreen('main'); if (seed === 'none') { - void reloadScopeData(); + // Invalidate the source settings before changing the module-level + // config path. The synchronous ref closes the gap before React + // commits the loading render, so Ctrl+S cannot write stale source + // settings through the target scope's path. + scopeReloadingRef.current = true; + settingsRef.current = null; + setIsScopeReloading(true); + setSettings(null); + setOriginalSettings(null); + setHasChanges(false); + setHasLoadedClaudeStatus(false); + setHasLoadedInstalledState(false); + + setScope(target); + setScopeState(getScope()); + void reloadScopeData().finally(() => { + scopeReloadingRef.current = false; + setIsScopeReloading(false); + }); return; } + setScope(target); + setScopeState(getScope()); + // Seeded entry populates the editor in memory only; the project file is // created by an explicit save, never by the switch itself. originalSettings // stays null so the change detector leaves hasChanges alone. + settingsRef.current = seedSource; setSettings(seedSource); setOriginalSettings(null); setHasChanges(true); @@ -783,6 +805,9 @@ export const App: React.FC = () => { if (key.ctrl && input === 'c') { exit(); } + if (scopeReloadingRef.current) { + return; + } // Global save shortcut if (key.ctrl && input === 's' && settings && screen !== 'confirm') { const mismatch = computeActivePinnedVersionMismatch(settings); @@ -1077,7 +1102,7 @@ export const App: React.FC = () => { setScreen('main'); }, [importValidation, settings]); - if (!settings || !hasLoadedClaudeStatus || !hasLoadedInstalledState) { + if (isScopeReloading || !settings || !hasLoadedClaudeStatus || !hasLoadedInstalledState) { return Loading settings...; } diff --git a/src/tui/__tests__/App.scope-loading.test.tsx b/src/tui/__tests__/App.scope-loading.test.tsx new file mode 100644 index 00000000..6cefad4e --- /dev/null +++ b/src/tui/__tests__/App.scope-loading.test.tsx @@ -0,0 +1,176 @@ +import * as fs from 'fs'; +import { render } from 'ink'; +import * as os from 'os'; +import * as path from 'path'; +import React from 'react'; +import { PassThrough } from 'stream'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi +} from 'vitest'; + +import { + DEFAULT_SETTINGS, + type Settings +} from '../../types/Settings'; +import * as claudeSettings from '../../utils/claude-settings'; +import * as config from '../../utils/config'; +import * as powerline from '../../utils/powerline'; +import { + getScope, + initScope, + setScope +} from '../../utils/scope'; +import { App } from '../App'; +import * as claudeStatus from '../claude-status'; + +class MockTtyStream extends PassThrough { + isTTY = true; + columns = 120; + rows = 40; + + setRawMode() { + return this; + } + + ref() { + return this; + } + + unref() { + return this; + } +} + +interface CapturedWriteStream extends NodeJS.WriteStream { + clearOutput: () => void; + getOutput: () => string; +} + +function createMockStdin(): NodeJS.ReadStream { + return new MockTtyStream() as unknown as NodeJS.ReadStream; +} + +function createMockStdout(): CapturedWriteStream { + const stream = new MockTtyStream(); + const chunks: string[] = []; + + stream.on('data', (chunk: Buffer | string) => { + chunks.push(chunk.toString()); + }); + + return Object.assign(stream as unknown as NodeJS.WriteStream, { + clearOutput() { + chunks.length = 0; + }, + getOutput() { + return chunks.join(''); + } + }); +} + +async function waitFor(assertion: () => void): Promise { + const deadline = Date.now() + 1000; + let lastError: unknown; + + while (Date.now() < deadline) { + try { + assertion(); + return; + } catch (error) { + lastError = error; + await new Promise(resolve => setTimeout(resolve, 10)); + } + } + + throw lastError; +} + +describe('App scope reload guard', () => { + let projectDir = ''; + + beforeEach(() => { + vi.clearAllMocks(); + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-app-scope-')); + setScope({ type: 'project', root: projectDir }); + vi.spyOn(claudeStatus, 'loadClaudeStatusLineState').mockResolvedValue({ + existingStatusLine: null, + refreshInterval: null + }); + vi.spyOn(claudeSettings, 'isInstalled').mockResolvedValue(false); + vi.spyOn(config, 'saveSettings').mockResolvedValue(undefined); + const fontStatus = { installed: true, checkedSymbol: '\uE0B0' }; + vi.spyOn(powerline, 'checkPowerlineFonts').mockReturnValue(fontStatus); + vi.spyOn(powerline, 'checkPowerlineFontsAsync').mockResolvedValue(fontStatus); + }); + + afterEach(() => { + vi.restoreAllMocks(); + initScope({}); + fs.rmSync(projectDir, { recursive: true, force: true }); + }); + + it('blocks saving stale source settings until the target scope finishes loading', async () => { + const sourceSettings: Settings = { + ...DEFAULT_SETTINGS, + lines: [[{ id: 'source-text', type: 'custom-text', customText: 'source' }], [], []] + }; + let resolveTargetSettings: ((settings: Settings) => void) | undefined; + const targetSettings = new Promise((resolve) => { + resolveTargetSettings = resolve; + }); + const loadSettings = vi.spyOn(config, 'loadSettings'); + loadSettings + .mockResolvedValueOnce(sourceSettings) + .mockReturnValueOnce(targetSettings); + + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + const instance = render(, { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + }); + + try { + await waitFor(() => { + expect(stdout.getOutput()).toContain('Mode: Project'); + }); + await new Promise(resolve => setTimeout(resolve, 25)); + + stdout.clearOutput(); + stdin.write('\x10'); + + await waitFor(() => { + expect(loadSettings).toHaveBeenCalledTimes(2); + expect(getScope()).toEqual({ type: 'global' }); + expect(stdout.getOutput()).toContain('Loading settings...'); + }); + + stdin.write('\x13'); + await new Promise(resolve => setTimeout(resolve, 25)); + expect(config.saveSettings).not.toHaveBeenCalled(); + + stdout.clearOutput(); + resolveTargetSettings?.(DEFAULT_SETTINGS); + + await waitFor(() => { + expect(stdout.getOutput()).toContain('Mode: Global'); + }); + } finally { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }); +}); From e131afcd7da95b4c81cbb30e88b0d3f62b467562 Mon Sep 17 00:00:00 2001 From: Matthew Breedlove Date: Sat, 25 Jul 2026 22:32:04 -0400 Subject: [PATCH 5/9] fix(render): restore project scope before piped persistence Recognize the absolute .claude/ccstatusline.json path emitted by project installations and initialize its project scope before processing piped status input. Preserve custom scope semantics for arbitrary --config paths and continue avoiding cwd-based project detection outside the TUI. This keeps migration and update-message saveSettings calls from synchronizing managed hooks against the global Claude settings file. Add regression coverage that verifies render-time hook synchronization writes to the inferred project's settings.local.json. --- src/ccstatusline.ts | 7 ++++ src/utils/__tests__/scope.test.ts | 56 ++++++++++++++++++++++++++++++- src/utils/scope.ts | 26 +++++++++++--- 3 files changed, 84 insertions(+), 5 deletions(-) diff --git a/src/ccstatusline.ts b/src/ccstatusline.ts index 9a782572..a5a75546 100644 --- a/src/ccstatusline.ts +++ b/src/ccstatusline.ts @@ -324,6 +324,13 @@ async function main() { // Check if we're in a piped/non-TTY environment first if (!process.stdin.isTTY) { + // Project installs use an absolute .claude/ccstatusline.json --config. + // Restore that scope before rendering so migration/update-message writes + // synchronize hooks with the project's settings.local.json. + initScope({ + explicitConfigPath, + inferProjectFromExplicitConfig: true + }); await ensureWindowsUtf8CodePage(); // We're receiving piped input diff --git a/src/utils/__tests__/scope.test.ts b/src/utils/__tests__/scope.test.ts index 80e2352e..5ab8db23 100644 --- a/src/utils/__tests__/scope.test.ts +++ b/src/utils/__tests__/scope.test.ts @@ -10,7 +10,11 @@ import { vi } from 'vitest'; -import { getConfigPath } from '../config'; +import { DEFAULT_SETTINGS } from '../../types/Settings'; +import { + getConfigPath, + saveSettings +} from '../config'; import { getProjectConfigPath, getProjectInstallTargetPath, @@ -52,6 +56,56 @@ describe('scope', () => { expect(getProjectInstallTargetPath()).toBeNull(); }); + it('restores project scope from a project install config path for piped rendering', () => { + const projectConfigPath = getProjectConfigPath(projectDir); + + initScope({ + explicitConfigPath: projectConfigPath, + inferProjectFromExplicitConfig: true + }); + + expect(getScope()).toEqual({ type: 'project', root: projectDir }); + expect(getConfigPath()).toBe(projectConfigPath); + expect(getProjectInstallTargetPath()).toBe(path.join(projectDir, '.claude', 'settings.local.json')); + }); + + it('routes render-time hook synchronization to the inferred project target', async () => { + const projectConfigPath = getProjectConfigPath(projectDir); + const projectSettingsPath = path.join(projectDir, '.claude', 'settings.local.json'); + fs.mkdirSync(path.dirname(projectSettingsPath), { recursive: true }); + fs.writeFileSync(projectSettingsPath, JSON.stringify({ + statusLine: { + type: 'command', + command: `bunx -y ccstatusline@latest --config ${projectConfigPath}`, + padding: 0 + } + }), 'utf-8'); + + initScope({ + explicitConfigPath: projectConfigPath, + inferProjectFromExplicitConfig: true + }); + await saveSettings({ + ...DEFAULT_SETTINGS, + lines: [[{ id: 'skills-1', type: 'skills' }], [], []] + }); + + const projectSettings = JSON.parse(fs.readFileSync(projectSettingsPath, 'utf-8')) as { hooks?: Record }; + expect(projectSettings.hooks?.PreToolUse?.[0]?.hooks?.[0]?.command) + .toBe(`bunx -y ccstatusline@latest --config ${projectConfigPath} --hook`); + }); + + it('keeps arbitrary explicit config paths custom during piped rendering', () => { + initScope({ + explicitConfigPath: path.join(projectDir, 'custom.json'), + inferProjectFromExplicitConfig: true + }); + + expect(getScope()).toEqual({ type: 'custom' }); + expect(isScopeSwitchingAvailable()).toBe(false); + expect(getProjectInstallTargetPath()).toBeNull(); + }); + it('detects a project config in cwd when detectProject is set', () => { fs.mkdirSync(path.join(projectDir, '.claude'), { recursive: true }); fs.writeFileSync(getProjectConfigPath(projectDir), '{}', 'utf-8'); diff --git a/src/utils/scope.ts b/src/utils/scope.ts index 432f1b63..b8e6235a 100644 --- a/src/utils/scope.ts +++ b/src/utils/scope.ts @@ -27,12 +27,30 @@ export function getProjectConfigPath(root: string): string { } /** - * Resolve the startup scope. Called once from main(): the TUI entry passes - * detectProject so an existing /.claude/ccstatusline.json selects project - * mode; piped render and --hook paths never auto-detect. + * Resolve the startup scope. The TUI entry passes detectProject so an existing + * /.claude/ccstatusline.json selects project mode. Piped rendering passes + * inferProjectFromExplicitConfig so the absolute --config written by a project + * install restores its project scope without cwd auto-detection. Hook mode does + * neither. */ -export function initScope(options: { explicitConfigPath?: string; detectProject?: boolean } = {}): void { +export function initScope(options: { + explicitConfigPath?: string; + detectProject?: boolean; + inferProjectFromExplicitConfig?: boolean; +} = {}): void { if (options.explicitConfigPath) { + if (options.inferProjectFromExplicitConfig) { + const resolvedConfigPath = path.resolve(options.explicitConfigPath); + const claudeDir = path.dirname(resolvedConfigPath); + if ( + path.basename(resolvedConfigPath) === 'ccstatusline.json' + && path.basename(claudeDir) === '.claude' + ) { + setScope({ type: 'project', root: path.dirname(claudeDir) }); + return; + } + } + activeScope = { type: 'custom' }; return; } From d0b6f1384a186b827cf25acd94a2a1c3c58531f0 Mon Sep 17 00:00:00 2001 From: Matthew Breedlove Date: Sat, 25 Jul 2026 23:16:42 -0400 Subject: [PATCH 6/9] fix(tui): serialize scope switches with settings saves Track every TUI saveSettings operation with a counted synchronous guard and refuse both shortcut-initiated and already-planned scope transitions until all active saves settle. Route Ctrl+S, save-and-switch, project pre-install saves, and Save & Exit through the same tracker so a scope change cannot redirect late hook synchronization or stale save completion state. Add an Ink regression test that holds Ctrl+S open, verifies Ctrl+P leaves the source scope and load state untouched, then confirms switching becomes available once the save completes. --- src/tui/App.tsx | 37 +++++++++++-- src/tui/__tests__/App.scope-loading.test.tsx | 58 ++++++++++++++++++++ 2 files changed, 89 insertions(+), 6 deletions(-) diff --git a/src/tui/App.tsx b/src/tui/App.tsx index e3ba562d..d5249236 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -548,6 +548,7 @@ export const App: React.FC = () => { const [scope, setScopeState] = useState(() => getScope()); const settingsRef = useRef(settings); const scopeReloadingRef = useRef(false); + const settingsSavesInFlightRef = useRef(0); const [isScopeReloading, setIsScopeReloading] = useState(false); const [importValidation, setImportValidation] = useState(null); @@ -591,7 +592,24 @@ export const App: React.FC = () => { const [scopeSwitchStage, setScopeSwitchStage] = useState(null); const [pendingScopeTarget, setPendingScopeTarget] = useState(null); + const saveSettingsTracked = useCallback(async (nextSettings: Settings) => { + settingsSavesInFlightRef.current += 1; + try { + await saveSettings(nextSettings); + } finally { + settingsSavesInFlightRef.current -= 1; + } + }, []); + const executeSwitch = useCallback((target: SwitchableScope, seed: 'none' | 'copy' | 'defaults') => { + if (settingsSavesInFlightRef.current > 0) { + setFlashMessage({ + text: '⚠ Wait for the current save before switching modes', + color: 'yellow' + }); + return; + } + // Seed source must be the last on-disk global state, captured before the // scope flips: after the unsaved-changes menu, originalSettings is it. // A copy seed strips installation metadata: that block describes how the @@ -654,6 +672,13 @@ export const App: React.FC = () => { }, [executeSwitch]); const requestScopeSwitch = useCallback(() => { + if (settingsSavesInFlightRef.current > 0) { + setFlashMessage({ + text: '⚠ Wait for the current save before switching modes', + color: 'yellow' + }); + return; + } if (!isScopeSwitchingAvailable()) { setFlashMessage({ text: '⚠ Mode switching is disabled when --config is passed', @@ -699,7 +724,7 @@ export const App: React.FC = () => { const performSave = () => { void (async () => { try { - await saveSettings(settings); + await saveSettingsTracked(settings); setOriginalSettings(cloneSettings(settings)); setHasChanges(false); setConfigLoadError(null); @@ -746,7 +771,7 @@ export const App: React.FC = () => { if (choice === 'copy' || choice === 'defaults') { executeSwitch(target, choice); } - }, [pendingScopeTarget, settings, configLoadError, continueScopeSwitch, executeSwitch]); + }, [pendingScopeTarget, settings, configLoadError, continueScopeSwitch, executeSwitch, saveSettingsTracked]); useEffect(() => { void reloadScopeData(); @@ -818,7 +843,7 @@ export const App: React.FC = () => { const performSave = () => { void (async () => { try { - await saveSettings(settings); + await saveSettingsTracked(settings); setOriginalSettings(cloneSettings(settings)); setHasChanges(false); // File is valid again after an explicit save → clear the banner + guard. @@ -908,7 +933,7 @@ export const App: React.FC = () => { // pointing at the project file; make sure it exists before // the install references it. if (getScope().type === 'project' && !fs.existsSync(getConfigPath()) && settingsRef.current) { - await saveSettings(settingsRef.current); + await saveSettingsTracked(settingsRef.current); setOriginalSettings(cloneSettings(settingsRef.current)); setHasChanges(false); } @@ -969,7 +994,7 @@ export const App: React.FC = () => { }); setScreen('confirm'); }); - }, [getGlobalResolutionWarning, supportsRefreshInterval]); + }, [getGlobalResolutionWarning, saveSettingsTracked, supportsRefreshInterval]); const handleInstallMenuCancel = useCallback(() => { setMenuSelections(clearInstallMenuSelection); @@ -1309,7 +1334,7 @@ export const App: React.FC = () => { case 'save': { const saveAndExit = async () => { try { - await saveSettings(settings); + await saveSettingsTracked(settings); setOriginalSettings(cloneSettings(settings)); setHasChanges(false); exit(); diff --git a/src/tui/__tests__/App.scope-loading.test.tsx b/src/tui/__tests__/App.scope-loading.test.tsx index 6cefad4e..8c26dd30 100644 --- a/src/tui/__tests__/App.scope-loading.test.tsx +++ b/src/tui/__tests__/App.scope-loading.test.tsx @@ -173,4 +173,62 @@ describe('App scope reload guard', () => { stderr.destroy(); } }); + + it('does not switch scopes while a settings save is in flight', async () => { + const loadSettings = vi.spyOn(config, 'loadSettings').mockResolvedValue(DEFAULT_SETTINGS); + let resolveSave: (() => void) | undefined; + const pendingSave = new Promise((resolve) => { + resolveSave = resolve; + }); + const saveSettings = vi.spyOn(config, 'saveSettings').mockReturnValueOnce(pendingSave); + + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + const instance = render(, { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + }); + + try { + await waitFor(() => { + expect(stdout.getOutput()).toContain('Mode: Project'); + }); + await new Promise(resolve => setTimeout(resolve, 25)); + + stdin.write('\x13'); + await waitFor(() => { + expect(saveSettings).toHaveBeenCalledOnce(); + }); + + stdout.clearOutput(); + stdin.write('\x10'); + await new Promise(resolve => setTimeout(resolve, 25)); + + expect(getScope()).toEqual({ type: 'project', root: projectDir }); + expect(loadSettings).toHaveBeenCalledOnce(); + expect(stdout.getOutput()).toContain('Wait for the current save before switching modes'); + + resolveSave?.(); + await waitFor(() => { + expect(stdout.getOutput()).toContain('Configuration saved'); + }); + + stdin.write('\x10'); + await waitFor(() => { + expect(getScope()).toEqual({ type: 'global' }); + expect(loadSettings).toHaveBeenCalledTimes(2); + }); + } finally { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }); }); From 95efadaccf0543a0e5682bc5c5623b79409f4227 Mon Sep 17 00:00:00 2001 From: Matthew Breedlove Date: Sat, 25 Jul 2026 23:18:39 -0400 Subject: [PATCH 7/9] fix(tui): guard seeded scope status reloads Run both existing-config and seeded scope transitions under the same synchronous loading guard. Seeded settings remain in memory, but the TUI stays non-interactive until the new scope's status line, refresh interval, and installation state have all been refreshed, preventing actions from using values inherited from the previous scope. Assign a generation to each scope reload and apply state or release the loading guard only for the newest generation, so an obsolete async completion cannot overwrite a later transition. Add an Ink regression test that delays project status loading and proves a second scope switch and config reload are blocked until completion. --- src/tui/App.tsx | 92 ++++++++++++++------ src/tui/__tests__/App.scope-loading.test.tsx | 74 ++++++++++++++++ 2 files changed, 139 insertions(+), 27 deletions(-) diff --git a/src/tui/App.tsx b/src/tui/App.tsx index d5249236..7a524412 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -548,6 +548,7 @@ export const App: React.FC = () => { const [scope, setScopeState] = useState(() => getScope()); const settingsRef = useRef(settings); const scopeReloadingRef = useRef(false); + const scopeReloadGenerationRef = useRef(0); const settingsSavesInFlightRef = useRef(0); const [isScopeReloading, setIsScopeReloading] = useState(false); const [importValidation, setImportValidation] = useState(null); @@ -556,36 +557,57 @@ export const App: React.FC = () => { settingsRef.current = settings; }, [settings]); - const reloadScopeData = useCallback(async (options: { skipSettings?: boolean } = {}) => { + const reloadScopeData = useCallback(async (options: { + skipSettings?: boolean; + generation?: number; + } = {}) => { + const generation = options.generation ?? scopeReloadGenerationRef.current; + const isCurrentReload = () => scopeReloadGenerationRef.current === generation; + try { const statusLineState = await loadClaudeStatusLineState(); - setExistingStatusLine(statusLineState.existingStatusLine); - setCurrentRefreshInterval(statusLineState.refreshInterval); + if (isCurrentReload()) { + setExistingStatusLine(statusLineState.existingStatusLine); + setCurrentRefreshInterval(statusLineState.refreshInterval); + } } catch { - setExistingStatusLine(null); - setCurrentRefreshInterval(null); + if (isCurrentReload()) { + setExistingStatusLine(null); + setCurrentRefreshInterval(null); + } } finally { - setHasLoadedClaudeStatus(true); + if (isCurrentReload()) { + setHasLoadedClaudeStatus(true); + } } if (!options.skipSettings) { const loadedSettings = await loadSettings(); - // Set global chalk level based on settings (default to 256 colors for compatibility) - chalk.level = loadedSettings.colorLevel; - setSettings(loadedSettings); - setOriginalSettings(cloneSettings(loadedSettings)); - // Capture why settings.json was rejected (if at all) so the TUI can warn and - // guard saves. Read it here, in the load callback: the module-scoped signal is - // reset by any later loadSettings/saveInstallationMetadata call. - setConfigLoadError(getConfigLoadError()); + if (isCurrentReload()) { + // Set global chalk level based on settings (default to 256 colors for compatibility) + chalk.level = loadedSettings.colorLevel; + setSettings(loadedSettings); + setOriginalSettings(cloneSettings(loadedSettings)); + // Capture why settings.json was rejected (if at all) so the TUI can warn and + // guard saves. Read it here, in the load callback: the module-scoped signal is + // reset by any later loadSettings/saveInstallationMetadata call. + setConfigLoadError(getConfigLoadError()); + } } try { - setIsClaudeInstalled(await isInstalled()); + const installed = await isInstalled(); + if (isCurrentReload()) { + setIsClaudeInstalled(installed); + } } catch { - setIsClaudeInstalled(false); + if (isCurrentReload()) { + setIsClaudeInstalled(false); + } } finally { - setHasLoadedInstalledState(true); + if (isCurrentReload()) { + setHasLoadedInstalledState(true); + } } }, []); @@ -601,6 +623,23 @@ export const App: React.FC = () => { } }, []); + const beginScopeReload = useCallback(() => { + const generation = scopeReloadGenerationRef.current + 1; + scopeReloadGenerationRef.current = generation; + scopeReloadingRef.current = true; + setIsScopeReloading(true); + setHasLoadedClaudeStatus(false); + setHasLoadedInstalledState(false); + return generation; + }, []); + + const finishScopeReload = useCallback((generation: number) => { + if (scopeReloadGenerationRef.current === generation) { + scopeReloadingRef.current = false; + setIsScopeReloading(false); + } + }, []); + const executeSwitch = useCallback((target: SwitchableScope, seed: 'none' | 'copy' | 'defaults') => { if (settingsSavesInFlightRef.current > 0) { setFlashMessage({ @@ -623,26 +662,22 @@ export const App: React.FC = () => { setPendingScopeTarget(null); setScreen('main'); + const reloadGeneration = beginScopeReload(); + if (seed === 'none') { // Invalidate the source settings before changing the module-level // config path. The synchronous ref closes the gap before React // commits the loading render, so Ctrl+S cannot write stale source // settings through the target scope's path. - scopeReloadingRef.current = true; settingsRef.current = null; - setIsScopeReloading(true); setSettings(null); setOriginalSettings(null); setHasChanges(false); - setHasLoadedClaudeStatus(false); - setHasLoadedInstalledState(false); setScope(target); setScopeState(getScope()); - void reloadScopeData().finally(() => { - scopeReloadingRef.current = false; - setIsScopeReloading(false); - }); + void reloadScopeData({ generation: reloadGeneration }) + .finally(() => { finishScopeReload(reloadGeneration); }); return; } @@ -659,8 +694,11 @@ export const App: React.FC = () => { // A seeded scope has no on-disk file yet, so it cannot have a load error; // clear any stale banner/guard carried over from the scope being left. setConfigLoadError(null); - void reloadScopeData({ skipSettings: true }); - }, [originalSettings, reloadScopeData]); + void reloadScopeData({ + skipSettings: true, + generation: reloadGeneration + }).finally(() => { finishScopeReload(reloadGeneration); }); + }, [beginScopeReload, finishScopeReload, originalSettings, reloadScopeData]); const continueScopeSwitch = useCallback((target: SwitchableScope) => { if (target.type === 'project' && !fs.existsSync(getProjectConfigPath(target.root))) { diff --git a/src/tui/__tests__/App.scope-loading.test.tsx b/src/tui/__tests__/App.scope-loading.test.tsx index 8c26dd30..ded72c64 100644 --- a/src/tui/__tests__/App.scope-loading.test.tsx +++ b/src/tui/__tests__/App.scope-loading.test.tsx @@ -231,4 +231,78 @@ describe('App scope reload guard', () => { stderr.destroy(); } }); + + it('keeps a seeded project non-interactive until its status data reloads', async () => { + setScope({ type: 'global' }); + vi.spyOn(process, 'cwd').mockReturnValue(projectDir); + const loadSettings = vi.spyOn(config, 'loadSettings').mockResolvedValue(DEFAULT_SETTINGS); + let resolveProjectStatus: ((status: { + existingStatusLine: string | null; + refreshInterval: number | null; + }) => void) | undefined; + const projectStatus = new Promise<{ + existingStatusLine: string | null; + refreshInterval: number | null; + }>((resolve) => { + resolveProjectStatus = resolve; + }); + vi.spyOn(claudeStatus, 'loadClaudeStatusLineState') + .mockResolvedValueOnce({ + existingStatusLine: 'bunx -y ccstatusline@latest', + refreshInterval: 5 + }) + .mockReturnValueOnce(projectStatus); + + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + const instance = render(, { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + }); + + try { + await waitFor(() => { + expect(stdout.getOutput()).toContain('Mode: Global'); + }); + await new Promise(resolve => setTimeout(resolve, 25)); + + stdin.write('\x10'); + await waitFor(() => { + expect(stdout.getOutput()).toContain('No project config found'); + }); + + stdin.write('\u001B[B'); + await new Promise(resolve => setTimeout(resolve, 25)); + stdin.write('\r'); + await waitFor(() => { + expect(getScope()).toEqual({ type: 'project', root: projectDir }); + expect(stdout.getOutput()).toContain('Loading settings...'); + }); + + stdin.write('\x10'); + await new Promise(resolve => setTimeout(resolve, 25)); + expect(getScope()).toEqual({ type: 'project', root: projectDir }); + expect(loadSettings).toHaveBeenCalledOnce(); + + stdout.clearOutput(); + resolveProjectStatus?.({ + existingStatusLine: null, + refreshInterval: null + }); + await waitFor(() => { + expect(stdout.getOutput()).toContain('Mode: Project'); + }); + } finally { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }); }); From a01c1c0ffe5059c547c8a7063578a06031702a58 Mon Sep 17 00:00:00 2001 From: Matthew Breedlove Date: Sat, 25 Jul 2026 23:19:24 -0400 Subject: [PATCH 8/9] fix(tui): apply color capability when seeding scopes Synchronize the process-wide Chalk level with seedSource before exposing copied or default project settings. This keeps preview rendering aligned with the seeded colorLevel immediately instead of inheriting the source scope's terminal color mode until restart or manual reconfiguration. Add an Ink regression test that starts in a no-color global scope, seeds a missing project with defaults, and verifies Chalk switches to the default seed's color level after the transition. --- src/tui/App.tsx | 1 + src/tui/__tests__/App.scope-loading.test.tsx | 53 ++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 7a524412..11207050 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -688,6 +688,7 @@ export const App: React.FC = () => { // created by an explicit save, never by the switch itself. originalSettings // stays null so the change detector leaves hasChanges alone. settingsRef.current = seedSource; + chalk.level = seedSource.colorLevel; setSettings(seedSource); setOriginalSettings(null); setHasChanges(true); diff --git a/src/tui/__tests__/App.scope-loading.test.tsx b/src/tui/__tests__/App.scope-loading.test.tsx index ded72c64..f23b840b 100644 --- a/src/tui/__tests__/App.scope-loading.test.tsx +++ b/src/tui/__tests__/App.scope-loading.test.tsx @@ -1,3 +1,4 @@ +import chalk from 'chalk'; import * as fs from 'fs'; import { render } from 'ink'; import * as os from 'os'; @@ -305,4 +306,56 @@ describe('App scope reload guard', () => { stderr.destroy(); } }); + + it('applies the seeded settings color level when starting with defaults', async () => { + const previousChalkLevel = chalk.level; + setScope({ type: 'global' }); + vi.spyOn(process, 'cwd').mockReturnValue(projectDir); + const globalSettings: Settings = { + ...DEFAULT_SETTINGS, + colorLevel: 0 + }; + vi.spyOn(config, 'loadSettings').mockResolvedValue(globalSettings); + + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + const instance = render(, { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + }); + + try { + await waitFor(() => { + expect(stdout.getOutput()).toContain('Mode: Global'); + expect(chalk.level).toBe(0); + }); + await new Promise(resolve => setTimeout(resolve, 25)); + + stdin.write('\x10'); + await waitFor(() => { + expect(stdout.getOutput()).toContain('No project config found'); + }); + + stdin.write('\u001B[B'); + await new Promise(resolve => setTimeout(resolve, 25)); + stdin.write('\r'); + await waitFor(() => { + expect(stdout.getOutput()).toContain('Mode: Project'); + }); + + expect(chalk.level).toBe(DEFAULT_SETTINGS.colorLevel); + } finally { + chalk.level = previousChalkLevel; + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }); }); From 387127097a99d631107f94d66b546b7a0fc61f58 Mon Sep 17 00:00:00 2001 From: Matthew Breedlove Date: Sat, 25 Jul 2026 23:33:02 -0400 Subject: [PATCH 9/9] fix(hooks): normalize managed commands across scopes Build managed hook commands from the installed status-line command while dropping the config-only argument. Hook mode does not load status-line configuration, so this preserves runtime behavior and gives merged global and project hooks identical command strings when they use the same installation method, allowing Claude Code to deduplicate execution. Keep scoped --config arguments on statusLine commands, update the install confirmation preview, and add regression coverage for quoted and unquoted paths, project render synchronization, installation flows, and matching global/project hook entries. --- src/tui/App.tsx | 3 +- src/utils/__tests__/claude-settings.test.ts | 28 ++++++++++++++---- src/utils/__tests__/hooks.test.ts | 32 +++++++++++++++++++++ src/utils/__tests__/scope.test.ts | 2 +- src/utils/claude-settings.ts | 14 +++++++++ src/utils/hooks.ts | 3 +- 6 files changed, 74 insertions(+), 8 deletions(-) diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 11207050..b18ba34f 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -24,6 +24,7 @@ import { } from '../types/Settings'; import type { WidgetItem } from '../types/Widget'; import { + buildHookCommand, buildStatusLineCommand, classifyInstallation, getExistingStatusLine, @@ -937,7 +938,7 @@ export const App: React.FC = () => { void getExistingStatusLine().then((existing) => { const isAlreadyInstalled = isKnownCommand(existing ?? ''); const finalCommand = buildStatusLineCommand(selection.commandMode); - const hookCommand = `${finalCommand} --hook`; + const hookCommand = buildHookCommand(finalCommand); const sideEffects = [ `Claude settings path: ${getInstallTargetPath()}`, ...(getScope().type === 'project' && !fs.existsSync(getConfigPath()) diff --git a/src/utils/__tests__/claude-settings.test.ts b/src/utils/__tests__/claude-settings.test.ts index 9e391d57..04674d6c 100644 --- a/src/utils/__tests__/claude-settings.test.ts +++ b/src/utils/__tests__/claude-settings.test.ts @@ -15,6 +15,7 @@ import { import { DEFAULT_SETTINGS } from '../../types/Settings'; import { CCSTATUSLINE_COMMANDS, + buildHookCommand, buildStatusLineCommand, classifyInstallation, getClaudeCodeVersion, @@ -291,13 +292,13 @@ describe('buildCommand via installStatusLine', () => { { _tag: 'ccstatusline-managed', matcher: 'Skill', - hooks: [{ type: 'command', command: `${installedCommand} --hook` }] + hooks: [{ type: 'command', command: `${CCSTATUSLINE_COMMANDS.NPM} --hook` }] } ]); expect(hooks.UserPromptSubmit).toEqual([ { _tag: 'ccstatusline-managed', - hooks: [{ type: 'command', command: `${installedCommand} --hook` }] + hooks: [{ type: 'command', command: `${CCSTATUSLINE_COMMANDS.NPM} --hook` }] } ]); }); @@ -326,12 +327,29 @@ describe('buildCommand via installStatusLine', () => { { _tag: 'ccstatusline-managed', matcher: 'Skill', - hooks: [{ type: 'command', command: `${installedCommand} --hook` }] + hooks: [{ type: 'command', command: `${CCSTATUSLINE_COMMANDS.GLOBAL} --hook` }] } ]); }); }); +describe('buildHookCommand', () => { + it('appends --hook to an unscoped status-line command', () => { + expect(buildHookCommand(CCSTATUSLINE_COMMANDS.NPM)) + .toBe(`${CCSTATUSLINE_COMMANDS.NPM} --hook`); + }); + + it('removes an unquoted config argument from the hook command', () => { + expect(buildHookCommand(`${CCSTATUSLINE_COMMANDS.BUNX} --config /tmp/settings.json`)) + .toBe(`${CCSTATUSLINE_COMMANDS.BUNX} --hook`); + }); + + it('removes a quoted config argument containing spaces from the hook command', () => { + expect(buildHookCommand(`${CCSTATUSLINE_COMMANDS.GLOBAL} --config '/my path/settings.json'`)) + .toBe(`${CCSTATUSLINE_COMMANDS.GLOBAL} --hook`); + }); +}); + describe('installStatusLine refreshInterval', () => { it('should set refreshInterval to 10 when version is supported', async () => { config.initConfigPath(); @@ -930,13 +948,13 @@ describe('claude-settings target routing', () => { { _tag: 'ccstatusline-managed', matcher: 'Skill', - hooks: [{ type: 'command', command: `${installedCommand} --hook` }] + hooks: [{ type: 'command', command: `${CCSTATUSLINE_COMMANDS.BUNX} --hook` }] } ]); expect(hooks.UserPromptSubmit).toEqual([ { _tag: 'ccstatusline-managed', - hooks: [{ type: 'command', command: `${installedCommand} --hook` }] + hooks: [{ type: 'command', command: `${CCSTATUSLINE_COMMANDS.BUNX} --hook` }] } ]); expect(fs.existsSync(getClaudeSettingsPath())).toBe(false); diff --git a/src/utils/__tests__/hooks.test.ts b/src/utils/__tests__/hooks.test.ts index a3fa87ac..a426c9a0 100644 --- a/src/utils/__tests__/hooks.test.ts +++ b/src/utils/__tests__/hooks.test.ts @@ -324,4 +324,36 @@ describe('hooks target routing', () => { expect(fs.existsSync(targetPath)).toBe(false); }); + + it('keeps global and project hooks while normalizing their commands for Claude deduplication', async () => { + const globalPath = getClaudeSettingsPath(); + const baseCommand = 'bunx -y ccstatusline@latest'; + const projectConfigPath = path.join(targetDir, 'project config.json'); + const globalStatusLine = { + type: 'command', + command: baseCommand, + padding: 0 + }; + const projectStatusLine = { + type: 'command', + command: `${baseCommand} --config '${projectConfigPath}'`, + padding: 0 + }; + fs.writeFileSync(globalPath, JSON.stringify({ statusLine: globalStatusLine }), 'utf-8'); + fs.writeFileSync(targetPath, JSON.stringify({ statusLine: projectStatusLine }), 'utf-8'); + + const settings = SettingsSchema.parse({ lines: [[{ id: 'skills-1', type: 'skills' }]] }); + await syncWidgetHooks(settings, { targetPath: globalPath }); + await syncWidgetHooks(settings, { targetPath }); + + const readHookCommand = (settingsPath: string): string | undefined => { + const saved = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { hooks?: Record }; + return saved.hooks?.PreToolUse?.[0]?.hooks?.[0]?.command; + }; + const globalHookCommand = readHookCommand(globalPath); + const projectHookCommand = readHookCommand(targetPath); + + expect(globalHookCommand).toBe(`${baseCommand} --hook`); + expect(projectHookCommand).toBe(globalHookCommand); + }); }); diff --git a/src/utils/__tests__/scope.test.ts b/src/utils/__tests__/scope.test.ts index 5ab8db23..1a4aca14 100644 --- a/src/utils/__tests__/scope.test.ts +++ b/src/utils/__tests__/scope.test.ts @@ -92,7 +92,7 @@ describe('scope', () => { const projectSettings = JSON.parse(fs.readFileSync(projectSettingsPath, 'utf-8')) as { hooks?: Record }; expect(projectSettings.hooks?.PreToolUse?.[0]?.hooks?.[0]?.command) - .toBe(`bunx -y ccstatusline@latest --config ${projectConfigPath} --hook`); + .toBe('bunx -y ccstatusline@latest --hook'); }); it('keeps arbitrary explicit config paths custom during piped rendering', () => { diff --git a/src/utils/claude-settings.ts b/src/utils/claude-settings.ts index 250bed0d..cb150b20 100644 --- a/src/utils/claude-settings.ts +++ b/src/utils/claude-settings.ts @@ -347,6 +347,20 @@ export function buildStatusLineCommand(commandMode: StatusLineCommandMode): stri return buildCommand(getBaseCommandForMode(commandMode)); } +/** + * Hook mode never loads the status-line config, so keep its command independent + * of --config. This also gives global and project settings the same command + * string when they use the same installation method, allowing Claude Code to + * deduplicate the merged hook. + */ +export function buildHookCommand(statusLineCommand: string): string { + const configArgumentIndex = statusLineCommand.indexOf(' --config '); + const baseCommand = configArgumentIndex === -1 + ? statusLineCommand + : statusLineCommand.slice(0, configArgumentIndex); + return `${baseCommand.trimEnd()} --hook`; +} + function matchesCommandBase(command: string, baseCommand: string): boolean { return command === baseCommand || command.startsWith(`${baseCommand} --config `); } diff --git a/src/utils/hooks.ts b/src/utils/hooks.ts index 48dc2fe6..0f33697a 100644 --- a/src/utils/hooks.ts +++ b/src/utils/hooks.ts @@ -4,6 +4,7 @@ import type { Settings } from '../types/Settings'; import type { Widget } from '../types/Widget'; import { + buildHookCommand, getExistingStatusLine, getInstallTargetPath, loadClaudeSettings, @@ -132,7 +133,7 @@ export async function syncWidgetHooks(settings: Settings, options: SyncWidgetHoo await saveClaudeSettings(claudeSettings, targetPath); return; } - const hookCommand = `${statusCommand} --hook`; + const hookCommand = buildHookCommand(statusCommand); // Add needed hooks for (const def of needed) {