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/3] 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/3] 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/3] 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); + }} + /> + + + ); +};