diff --git a/README.md b/README.md index c089177..7f838bd 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ ## 🆕 Recent Updates -### v2.1.0 - v2.1.7 - Usage widgets, links, new git insertions / deletions widgets, and reliability fixes +### v2.1.0 - v2.1.8 - Usage widgets, links, new git insertions / deletions widgets, and reliability fixes - **🧩 New Usage widgets (v2.1.0)** - Added **Session Usage**, **Weekly Usage**, **Block Reset Timer**, and **Context Bar** widgets. - **📊 More accurate counts (v2.1.0)** - Usage/context widgets now use new statusline JSON metrics when available for more accurate token and context counts. @@ -56,6 +56,7 @@ - **➖ New Git Deletions widget (v2.1.4)** - Added a dedicated Git widget that shows only uncommitted deletions (e.g., `-10`). - **🧠 Context format fallback fix (v2.1.6)** - When `context_window_size` is missing, context widgets now infer 1M models from long-context labels such as `[1m]` and `1M context` in model identifiers. - **⏳ Weekly reset timer split (v2.1.7)** - Added a separate `Weekly Reset Timer` widget. +- **⚙️ Custom config file flag (v2.1.8)** - Added `--config ` support so ccstatusline can load/save settings from a custom file location. ### v2.0.26 - v2.0.29 - Performance, git internals, and workflow improvements diff --git a/package.json b/package.json index a5f53dc..041427d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ccstatusline", - "version": "2.1.7", + "version": "2.1.8", "description": "A customizable status line formatter for Claude Code CLI", "module": "src/ccstatusline.ts", "type": "module", diff --git a/src/ccstatusline.ts b/src/ccstatusline.ts index fee23e7..1312634 100644 --- a/src/ccstatusline.ts +++ b/src/ccstatusline.ts @@ -9,6 +9,7 @@ import { StatusJSONSchema } from './types/StatusJSON'; import { getVisibleText } from './utils/ansi'; import { updateColorMap } from './utils/colors'; import { + initConfigPath, loadSettings, saveSettings } from './utils/config'; @@ -166,7 +167,23 @@ async function renderMultipleLines(data: StatusJSON) { } } +function parseConfigArg(): string | undefined { + const idx = process.argv.indexOf('--config'); + if (idx === -1) + return undefined; + const configPath = process.argv[idx + 1]; + if (!configPath || configPath.startsWith('--')) { + console.error('--config requires a file path argument'); + process.exit(1); + } + process.argv.splice(idx, 2); + return configPath; +} + async function main() { + // Parse --config before anything else + initConfigPath(parseConfigArg()); + // Check if we're in a piped/non-TTY environment first if (!process.stdin.isTTY) { await ensureWindowsUtf8CodePage(); diff --git a/src/tui/App.tsx b/src/tui/App.tsx index ed350d7..8af0f7b 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -22,10 +22,13 @@ import { installStatusLine, isBunxAvailable, isInstalled, + isKnownCommand, uninstallStatusLine } from '../utils/claude-settings'; import { cloneSettings } from '../utils/clone-settings'; import { + getConfigPath, + isCustomConfigPath, loadSettings, saveSettings } from '../utils/config'; @@ -146,7 +149,7 @@ export const App: React.FC = () => { const handleInstallSelection = useCallback((command: string, displayName: string, useBunx: boolean) => { void getExistingStatusLine().then((existing) => { - const isAlreadyInstalled = [CCSTATUSLINE_COMMANDS.NPM, CCSTATUSLINE_COMMANDS.BUNX, CCSTATUSLINE_COMMANDS.SELF_MANAGED].includes(existing ?? ''); + const isAlreadyInstalled = isKnownCommand(existing ?? ''); let message: string; if (existing && !isAlreadyInstalled) { @@ -290,6 +293,9 @@ export const App: React.FC = () => { )} + {isCustomConfigPath() && ( + {`Config: ${getConfigPath()}`} + )} ({ + getConfigPath: vi.fn(() => '/default/settings.json'), + isCustomConfigPath: vi.fn(() => false) +})); + +const mockIsCustomConfigPath = isCustomConfigPath as Mock; +const mockGetConfigPath = getConfigPath as Mock; +const ORIGINAL_PLATFORM = process.platform; + +function setProcessPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { + value: platform, + configurable: true + }); +} + +// Safety net: point CLAUDE_CONFIG_DIR at a temp path so even if the fs mock +// leaks, writes will never land in the user's real ~/.claude directory. +const ORIGINAL_CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR; + +beforeAll(() => { + process.env.CLAUDE_CONFIG_DIR = '/tmp/ccstatusline-test-fake-claude-config'; +}); + +afterAll(() => { + if (ORIGINAL_CLAUDE_CONFIG_DIR === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = ORIGINAL_CLAUDE_CONFIG_DIR; + } +}); + +// Capture settings written by installStatusLine via the mock writeFile callback. +let savedSettings: Record = {}; + +vi.mock('fs', () => ({ + existsSync: vi.fn(() => false), + statSync: vi.fn(), + promises: { + readFile: vi.fn(() => Promise.resolve('{}')), + writeFile: vi.fn((_path: string, content: string) => { + savedSettings = JSON.parse(content) as Record; + return Promise.resolve(undefined); + }), + mkdir: vi.fn(() => Promise.resolve(undefined)) + } +})); + +// Dynamic import so the module picks up the mocked fs references. +const { CCSTATUSLINE_COMMANDS, isKnownCommand, installStatusLine } = await import('../claude-settings'); + +describe('isKnownCommand', () => { + it('should match exact NPM command', () => { + expect(isKnownCommand(CCSTATUSLINE_COMMANDS.NPM)).toBe(true); + }); + + it('should match exact BUNX command', () => { + expect(isKnownCommand(CCSTATUSLINE_COMMANDS.BUNX)).toBe(true); + }); + + it('should match exact SELF_MANAGED command', () => { + expect(isKnownCommand(CCSTATUSLINE_COMMANDS.SELF_MANAGED)).toBe(true); + }); + + it('should match NPM command with --config and simple path', () => { + expect(isKnownCommand(`${CCSTATUSLINE_COMMANDS.NPM} --config /tmp/settings.json`)).toBe(true); + }); + + it('should match BUNX command with --config and quoted path with spaces', () => { + expect(isKnownCommand(`${CCSTATUSLINE_COMMANDS.BUNX} --config '/my path/settings.json'`)).toBe(true); + }); + + it('should match command with --config and quoted path with parens', () => { + expect(isKnownCommand(`${CCSTATUSLINE_COMMANDS.NPM} --config '/my(path)/settings.json'`)).toBe(true); + }); + + it('should match command with --config and double-quoted Windows path', () => { + expect(isKnownCommand(`${CCSTATUSLINE_COMMANDS.NPM} --config "C:\\Users\\Alice\\My Settings\\settings.json"`)).toBe(true); + }); + + it('should not match unknown commands', () => { + expect(isKnownCommand('some-other-command')).toBe(false); + }); + + it('should not match empty string', () => { + expect(isKnownCommand('')).toBe(false); + }); + + it('should not match partial prefix', () => { + expect(isKnownCommand('npx -y ccstatusline')).toBe(false); + }); + + it('should not match prefix that is a substring', () => { + expect(isKnownCommand('npx -y ccstatusline@latestFOO')).toBe(false); + }); +}); + +describe('buildCommand via installStatusLine', () => { + beforeEach(() => { + savedSettings = {}; + setProcessPlatform(ORIGINAL_PLATFORM); + mockIsCustomConfigPath.mockReturnValue(false); + mockGetConfigPath.mockReturnValue('/default/settings.json'); + }); + + afterEach(() => { + setProcessPlatform(ORIGINAL_PLATFORM); + }); + + it('should use base command when no custom config path', async () => { + mockIsCustomConfigPath.mockReturnValue(false); + await installStatusLine(false); + + const statusLine = savedSettings.statusLine as { command: string }; + expect(statusLine.command).toBe(CCSTATUSLINE_COMMANDS.NPM); + }); + + it('should append --config with simple path (no quoting needed)', async () => { + mockIsCustomConfigPath.mockReturnValue(true); + mockGetConfigPath.mockReturnValue('/tmp/settings.json'); + await installStatusLine(false); + + const statusLine = savedSettings.statusLine as { command: string }; + expect(statusLine.command).toBe(`${CCSTATUSLINE_COMMANDS.NPM} --config /tmp/settings.json`); + }); + + it('should quote path with spaces', async () => { + mockIsCustomConfigPath.mockReturnValue(true); + mockGetConfigPath.mockReturnValue('/my path/settings.json'); + await installStatusLine(false); + + const statusLine = savedSettings.statusLine as { command: string }; + expect(statusLine.command).toBe(`${CCSTATUSLINE_COMMANDS.NPM} --config '/my path/settings.json'`); + }); + + it('should quote path with parentheses', async () => { + mockIsCustomConfigPath.mockReturnValue(true); + mockGetConfigPath.mockReturnValue('/my(path)/settings.json'); + await installStatusLine(false); + + const statusLine = savedSettings.statusLine as { command: string }; + expect(statusLine.command).toBe(`${CCSTATUSLINE_COMMANDS.NPM} --config '/my(path)/settings.json'`); + }); + + it('should escape embedded single quotes in path', async () => { + mockIsCustomConfigPath.mockReturnValue(true); + mockGetConfigPath.mockReturnValue('/my\'path/settings.json'); + await installStatusLine(false); + + const statusLine = savedSettings.statusLine as { command: string }; + expect(statusLine.command).toBe(`${CCSTATUSLINE_COMMANDS.NPM} --config '/my'\\''path/settings.json'`); + }); + + it('should use bunx command when useBunx is true', async () => { + mockIsCustomConfigPath.mockReturnValue(true); + mockGetConfigPath.mockReturnValue('/my path/settings.json'); + await installStatusLine(true); + + const statusLine = savedSettings.statusLine as { command: string }; + expect(statusLine.command).toBe(`${CCSTATUSLINE_COMMANDS.BUNX} --config '/my path/settings.json'`); + }); + + it('should use Windows-safe double quoting for custom config paths', async () => { + setProcessPlatform('win32'); + mockIsCustomConfigPath.mockReturnValue(true); + mockGetConfigPath.mockReturnValue('C:\\Users\\Alice\\My Settings\\settings.json'); + await installStatusLine(false); + + const statusLine = savedSettings.statusLine as { command: string }; + expect(statusLine.command).toBe(`${CCSTATUSLINE_COMMANDS.NPM} --config \"C:\\Users\\Alice\\My Settings\\settings.json\"`); + }); +}); diff --git a/src/utils/__tests__/config-dir.test.ts b/src/utils/__tests__/config-dir.test.ts new file mode 100644 index 0000000..edd7db8 --- /dev/null +++ b/src/utils/__tests__/config-dir.test.ts @@ -0,0 +1,48 @@ +import * as os from 'os'; +import * as path from 'path'; +import { + beforeEach, + describe, + expect, + it +} from 'vitest'; + +import { + getConfigPath, + initConfigPath, + isCustomConfigPath +} from '../config'; + +const DEFAULT_PATH = path.join(os.homedir(), '.config', 'ccstatusline', 'settings.json'); + +describe('initConfigPath / getConfigPath', () => { + beforeEach(() => { + initConfigPath(); + }); + + it('should return the default settings path when no arg is provided', () => { + initConfigPath(); + expect(getConfigPath()).toBe(DEFAULT_PATH); + expect(isCustomConfigPath()).toBe(false); + }); + + it('should return a custom settings path when a file path is provided', () => { + initConfigPath('/tmp/my-ccsl/settings.json'); + expect(getConfigPath()).toBe('/tmp/my-ccsl/settings.json'); + expect(isCustomConfigPath()).toBe(true); + }); + + it('should resolve relative paths', () => { + initConfigPath('relative/settings.json'); + expect(path.isAbsolute(getConfigPath())).toBe(true); + expect(getConfigPath()).toBe(path.resolve('relative/settings.json')); + }); + + it('should reset to default when called with undefined', () => { + initConfigPath('/tmp/custom.json'); + expect(isCustomConfigPath()).toBe(true); + initConfigPath(undefined); + expect(getConfigPath()).toBe(DEFAULT_PATH); + expect(isCustomConfigPath()).toBe(false); + }); +}); \ No newline at end of file diff --git a/src/utils/claude-settings.ts b/src/utils/claude-settings.ts index 43c4620..8969913 100644 --- a/src/utils/claude-settings.ts +++ b/src/utils/claude-settings.ts @@ -5,6 +5,11 @@ import * as path from 'path'; import type { ClaudeSettings } from '../types/ClaudeSettings'; +import { + getConfigPath, + isCustomConfigPath +} from './config'; + // Re-export for backward compatibility export type { ClaudeSettings }; @@ -19,6 +24,32 @@ export const CCSTATUSLINE_COMMANDS = { SELF_MANAGED: 'ccstatusline' }; +export function isKnownCommand(command: string): boolean { + const prefixes = [CCSTATUSLINE_COMMANDS.NPM, CCSTATUSLINE_COMMANDS.BUNX, CCSTATUSLINE_COMMANDS.SELF_MANAGED]; + return prefixes.some(prefix => command === prefix || command.startsWith(`${prefix} --config `)); +} + +function needsQuoting(filePath: string): boolean { + if (process.platform === 'win32') { + // cmd.exe-safe set of characters that require quoting. + return /[\s&()<>|^"]/.test(filePath); + } + + return /[\s()[\];&#|'"\\$`]/.test(filePath); +} + +function quotePathIfNeeded(filePath: string): string { + if (!needsQuoting(filePath)) { + return filePath; + } + + if (process.platform === 'win32') { + return `"${filePath.replace(/"/g, '""')}"`; + } + + return `'${filePath.replace(/'/g, '\'\\\'\'')}'`; +} + /** * Determines the Claude config directory, checking CLAUDE_CONFIG_DIR environment variable first, * then falling back to the default ~/.claude directory. @@ -82,17 +113,9 @@ export async function saveClaudeSettings( export async function isInstalled(): Promise { const settings = await loadClaudeSettings(); - // Check if command is either npx or bunx version AND padding is 0 (or undefined for new installs) - const validCommands = [ - // Default autoinstalled npm command - CCSTATUSLINE_COMMANDS.NPM, - // Default autoinstalled bunx command - CCSTATUSLINE_COMMANDS.BUNX, - // Self managed installation command - CCSTATUSLINE_COMMANDS.SELF_MANAGED - ]; + const command = settings.statusLine?.command ?? ''; return ( - validCommands.includes(settings.statusLine?.command ?? '') + isKnownCommand(command) && (settings.statusLine?.padding === 0 || settings.statusLine?.padding === undefined) ); @@ -109,15 +132,24 @@ export function isBunxAvailable(): boolean { } } +function buildCommand(baseCommand: string): string { + if (isCustomConfigPath()) { + return `${baseCommand} --config ${quotePathIfNeeded(getConfigPath())}`; + } + return baseCommand; +} + export async function installStatusLine(useBunx = false): Promise { const settings = await loadClaudeSettings(); + const baseCommand = useBunx + ? CCSTATUSLINE_COMMANDS.BUNX + : CCSTATUSLINE_COMMANDS.NPM; + // Update settings with our status line (confirmation already handled in TUI) settings.statusLine = { type: 'command', - command: useBunx - ? CCSTATUSLINE_COMMANDS.BUNX - : CCSTATUSLINE_COMMANDS.NPM, + command: buildCommand(baseCommand), padding: 0 }; @@ -136,4 +168,4 @@ export async function uninstallStatusLine(): Promise { export async function getExistingStatusLine(): Promise { const settings = await loadClaudeSettings(); return settings.statusLine?.command ?? null; -} \ No newline at end of file +} diff --git a/src/utils/config.ts b/src/utils/config.ts index 1c450af..6605545 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -19,6 +19,22 @@ const readFile = fs.promises.readFile; const writeFile = fs.promises.writeFile; const mkdir = fs.promises.mkdir; +const DEFAULT_SETTINGS_PATH = path.join(os.homedir(), '.config', 'ccstatusline', 'settings.json'); + +let settingsPath = DEFAULT_SETTINGS_PATH; + +export function initConfigPath(filePath?: string): void { + settingsPath = filePath ? path.resolve(filePath) : DEFAULT_SETTINGS_PATH; +} + +export function getConfigPath(): string { + return settingsPath; +} + +export function isCustomConfigPath(): boolean { + return settingsPath !== DEFAULT_SETTINGS_PATH; +} + interface SettingsPaths { configDir: string; settingsPath: string; @@ -26,11 +42,16 @@ interface SettingsPaths { } function getSettingsPaths(): SettingsPaths { - const configDir = path.join(os.homedir(), '.config', 'ccstatusline'); + const configDir = path.dirname(settingsPath); + const parsedPath = path.parse(settingsPath); + const backupBaseName = parsedPath.ext + ? `${parsedPath.name}.bak` + : `${parsedPath.base}.bak`; + return { configDir, - settingsPath: path.join(configDir, 'settings.json'), - settingsBackupPath: path.join(configDir, 'settings.bak') + settingsPath, + settingsBackupPath: path.join(configDir, backupBaseName) }; } @@ -137,4 +158,4 @@ export async function saveSettings(settings: Settings): Promise { }; await writeSettingsJson(settingsWithVersion, paths); -} \ No newline at end of file +}