diff --git a/src/ccstatusline.ts b/src/ccstatusline.ts index be492891..a5a75546 100644 --- a/src/ccstatusline.ts +++ b/src/ccstatusline.ts @@ -40,6 +40,7 @@ import { preRenderAllWidgets, renderStatusLine } from './utils/renderer'; +import { initScope } from './utils/scope'; import { advanceGlobalSeparatorIndex } from './utils/separator-index'; import { getSkillsMetrics } from './utils/skills'; import { @@ -312,7 +313,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')) { @@ -322,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 @@ -346,6 +355,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/tui/App.tsx b/src/tui/App.tsx index 53973ee4..b18ba34f 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,27 @@ 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 { + buildHookCommand, buildStatusLineCommand, classifyInstallation, - getClaudeSettingsPath, getExistingStatusLine, + getInstallTargetPath, getPackageCommandAvailability, installStatusLine, isClaudeCodeVersionAtLeast, @@ -64,6 +69,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, @@ -103,6 +116,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'; @@ -127,6 +145,7 @@ type AppScreen = 'main' | 'uninstallOptions' | 'updates' | 'refreshInterval' + | 'scopeSwitch' | 'exportConfig' | 'importConfig' | 'importPreview'; @@ -289,14 +308,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 { @@ -443,6 +462,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 @@ -488,37 +546,275 @@ 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); + const scopeReloadingRef = useRef(false); + const scopeReloadGenerationRef = useRef(0); + const settingsSavesInFlightRef = useRef(0); + const [isScopeReloading, setIsScopeReloading] = useState(false); const [importValidation, setImportValidation] = useState(null); useEffect(() => { - void loadClaudeStatusLineState() - .then((statusLineState) => { + settingsRef.current = settings; + }, [settings]); + + 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(); + if (isCurrentReload()) { setExistingStatusLine(statusLineState.existingStatusLine); setCurrentRefreshInterval(statusLineState.refreshInterval); - }) - .catch(() => { + } + } catch { + if (isCurrentReload()) { setExistingStatusLine(null); setCurrentRefreshInterval(null); - }) - .finally(() => { + } + } finally { + if (isCurrentReload()) { setHasLoadedClaudeStatus(true); + } + } + + if (!options.skipSettings) { + const loadedSettings = await loadSettings(); + 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 { + const installed = await isInstalled(); + if (isCurrentReload()) { + setIsClaudeInstalled(installed); + } + } catch { + if (isCurrentReload()) { + setIsClaudeInstalled(false); + } + } finally { + if (isCurrentReload()) { + setHasLoadedInstalledState(true); + } + } + }, []); + + 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 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({ + text: '⚠ Wait for the current save before switching modes', + color: 'yellow' }); - void loadSettings().then((loadedSettings) => { - // 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()); + 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 + // *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); + + setScopeSwitchStage(null); + 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. + settingsRef.current = null; + setSettings(null); + setOriginalSettings(null); + setHasChanges(false); + + setScope(target); + setScopeState(getScope()); + void reloadScopeData({ generation: reloadGeneration }) + .finally(() => { finishScopeReload(reloadGeneration); }); + 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; + chalk.level = seedSource.colorLevel; + 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, + generation: reloadGeneration + }).finally(() => { finishScopeReload(reloadGeneration); }); + }, [beginScopeReload, finishScopeReload, 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 (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', + 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 saveSettingsTracked(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, saveSettingsTracked]); + + useEffect(() => { + void reloadScopeData(); // Check for Powerline fonts on startup (use sync version that doesn't call execSync) const fontStatus = checkPowerlineFonts(); @@ -537,7 +833,7 @@ export const App: React.FC = () => { return () => { process.stdout.off('resize', handleResize); }; - }, []); + }, [reloadScopeData]); // Check for changes whenever settings update useEffect(() => { @@ -557,18 +853,29 @@ 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(); } + if (scopeReloadingRef.current) { + return; + } // 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; } @@ -576,7 +883,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. @@ -609,6 +916,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') => ( @@ -619,9 +938,12 @@ 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: ${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}`] : []), @@ -647,6 +969,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 saveSettingsTracked(settingsRef.current); + setOriginalSettings(cloneSettings(settingsRef.current)); + setHasChanges(false); + } + await installStatusLine({ commandMode: selection.commandMode, supportsRefreshInterval, @@ -703,7 +1034,7 @@ export const App: React.FC = () => { }); setScreen('confirm'); }); - }, [getGlobalResolutionWarning, supportsRefreshInterval]); + }, [getGlobalResolutionWarning, saveSettingsTracked, supportsRefreshInterval]); const handleInstallMenuCancel = useCallback(() => { setMenuSelections(clearInstallMenuSelection); @@ -836,7 +1167,7 @@ export const App: React.FC = () => { setScreen('main'); }, [importValidation, settings]); - if (!settings || !hasLoadedClaudeStatus || !hasLoadedInstalledState) { + if (isScopeReloading || !settings || !hasLoadedClaudeStatus || !hasLoadedInstalledState) { return Loading settings...; } @@ -1043,7 +1374,7 @@ export const App: React.FC = () => { case 'save': { const saveAndExit = async () => { try { - await saveSettings(settings); + await saveSettingsTracked(settings); setOriginalSettings(cloneSettings(settings)); setHasChanges(false); exit(); @@ -1143,9 +1474,11 @@ export const App: React.FC = () => { {configWarning && ( {configWarning} )} - {isCustomConfigPath() && ( - {`Config: ${getConfigPath()}`} - )} + {getScopeIndicator(scope) + ? {getScopeIndicator(scope)} + : isCustomConfigPath() && ( + {`Config: ${getConfigPath()}`} + )} { onClearMessage={() => { setFontInstallMessage(null); }} /> )} + {screen === 'scopeSwitch' && scopeSwitchStage && ( + + )} {screen === 'exportConfig' && ( 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(); + } + }); + + 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(); + } + }); + + 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(); + } + }); + + 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(); + } + }); +}); diff --git a/src/tui/__tests__/App.test.ts b/src/tui/__tests__/App.test.ts index eb2f6411..2c335ab9 100644 --- a/src/tui/__tests__/App.test.ts +++ b/src/tui/__tests__/App.test.ts @@ -1,4 +1,6 @@ import chalk from 'chalk'; +import * as os from 'os'; +import * as path from 'path'; import { describe, expect, @@ -10,6 +12,7 @@ import { DEFAULT_SETTINGS, type InstallationMetadata } from '../../types/Settings'; +import { getProjectConfigPath } from '../../utils/scope'; import { applyTuiImport, buildConfigLoadWarning, @@ -18,7 +21,9 @@ import { getConfirmCancelScreen, getCurrentInstallation, getPathInferredInstallation, - getPinnedVersionMismatch + getPinnedVersionMismatch, + getScopeIndicator, + planScopeSwitch } from '../App'; import { buildMainMenuItems, @@ -26,6 +31,7 @@ import { getMainMenuSelectionIndex } from '../components/MainMenu'; import { buildManageInstallationItems } from '../components/ManageInstallationMenu'; +import { getScopeSwitchMenuItems } from '../components/ScopeSwitchMenu'; function getMenuValues( isClaudeInstalled: boolean, @@ -319,3 +325,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); + }} + /> + + + ); +}; diff --git a/src/utils/__tests__/claude-settings.test.ts b/src/utils/__tests__/claude-settings.test.ts index 0630ba2a..04674d6c 100644 --- a/src/utils/__tests__/claude-settings.test.ts +++ b/src/utils/__tests__/claude-settings.test.ts @@ -15,12 +15,14 @@ import { import { DEFAULT_SETTINGS } from '../../types/Settings'; import { CCSTATUSLINE_COMMANDS, + buildHookCommand, buildStatusLineCommand, classifyInstallation, getClaudeCodeVersion, getClaudeJsonPath, getClaudeSettingsPath, getExistingStatusLine, + getInstallTargetPath, getRefreshInterval, getSandboxConfig, getVoiceConfig, @@ -29,11 +31,16 @@ import { isInstalled, isKnownCommand, loadClaudeSettings, + loadClaudeSettingsSync, saveClaudeSettings, setRefreshInterval, 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 = ''; @@ -285,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` }] } ]); }); @@ -320,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(); @@ -814,6 +838,236 @@ 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(() => { + initScope({}); + 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: `${CCSTATUSLINE_COMMANDS.BUNX} --hook` }] + } + ]); + expect(hooks.UserPromptSubmit).toEqual([ + { + _tag: 'ccstatusline-managed', + hooks: [{ type: 'command', command: `${CCSTATUSLINE_COMMANDS.BUNX} --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); + }); + + 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 }); + } + }); +}); + describe('getSandboxConfig', () => { let testSandboxProjectDir = ''; diff --git a/src/utils/__tests__/hooks.test.ts b/src/utils/__tests__/hooks.test.ts index 1b45978c..a426c9a0 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,114 @@ 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(); + }); + + 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); + }); + + 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 new file mode 100644 index 00000000..1a4aca14 --- /dev/null +++ b/src/utils/__tests__/scope.test.ts @@ -0,0 +1,145 @@ +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 { DEFAULT_SETTINGS } from '../../types/Settings'; +import { + getConfigPath, + saveSettings +} 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('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 --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'); + + 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 8aaecfce..cb150b20 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 }; @@ -46,6 +47,7 @@ export interface InstallStatusLineOptions { commandMode: StatusLineCommandMode; supportsRefreshInterval?: boolean; installationMetadata?: InstallationMetadata; + targetPath?: string; } export interface PackageCommandAvailability { @@ -139,11 +141,24 @@ export function getClaudeSettingsPath(): string { return path.join(getClaudeConfigDir(), 'settings.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 getInstallTargetPath(); +} + /** * 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 +173,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 +201,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 +221,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 } @@ -326,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 `); } @@ -400,15 +435,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 +463,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 +471,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 +488,45 @@ 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 { + // 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 }); + settings = await loadClaudeSettings({ logErrors: false, targetPath }); } catch { return; } @@ -500,7 +541,7 @@ export async function setRefreshInterval(interval: number | null): Promise settings.statusLine.refreshInterval = interval; } - await saveClaudeSettings(settings); + 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 829ed52c..ab7d0427 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -165,6 +165,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 @@ -177,8 +181,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(); } @@ -190,7 +194,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(); } @@ -208,7 +212,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(); } @@ -224,7 +228,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 74d1a419..0f33697a 100644 --- a/src/utils/hooks.ts +++ b/src/utils/hooks.ts @@ -1,8 +1,12 @@ +import * as fs from 'fs'; + import type { Settings } from '../types/Settings'; import type { Widget } from '../types/Widget'; import { + buildHookCommand, getExistingStatusLine, + getInstallTargetPath, loadClaudeSettings, saveClaudeSettings } from './claude-settings'; @@ -13,6 +17,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,21 +104,36 @@ function getActiveHookDefs(settings: Settings): WidgetHookDef[] { return defs; } -export async function syncWidgetHooks(settings: Settings): Promise { +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 }); + 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(); + // 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); + await saveClaudeSettings(claudeSettings, targetPath); return; } - const hookCommand = `${statusCommand} --hook`; + const hookCommand = buildHookCommand(statusCommand); // Add needed hooks for (const def of needed) { @@ -128,15 +149,15 @@ export async function syncWidgetHooks(settings: Settings): Promise { } claudeSettings.hooks = Object.keys(hooks).length > 0 ? hooks : undefined; - await saveClaudeSettings(claudeSettings); + await saveClaudeSettings(claudeSettings, 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); } diff --git a/src/utils/scope.ts b/src/utils/scope.ts new file mode 100644 index 00000000..b8e6235a --- /dev/null +++ b/src/utils/scope.ts @@ -0,0 +1,95 @@ +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. 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; + 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; + } + + 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; +}