feat: Add --config flag for custom settings file path (#166)

* Add --config flag for custom settings file path

Allow users to specify an alternative settings file via `--config <path>`,
enabling distinct status line configs for different Claude Code instances.

- Add initConfigPath/getConfigPath/isCustomConfigPath to config.ts
- Fix backup path to always append .bak instead of replacing .json suffix
- Use path.dirname(SETTINGS_PATH) instead of CONFIG_DIR for mkdir
- Parse --config arg in ccstatusline.ts main() entry point
- Add tests for config path management

* Integrate --config flag with Claude Code install commands

When a custom config path is active, the install-to-Claude-Code feature
now appends --config <path> to the status line command. Also updates
isInstalled detection to recognize commands with the --config suffix.

- Add isKnownCommand() to deduplicate command-matching logic
- Add shell-safe path quoting for --config values with spaces/special chars
- Add buildCommand() to append --config when custom path is active
- Refactor isInstalled() to use isKnownCommand()
- Use isKnownCommand() in App.tsx install flow
- Add tests for isKnownCommand() and buildCommand() with CLAUDE_CONFIG_DIR safety net

* Show custom config path in TUI header

When a custom --config path is active, display it below the title bar
so users can see which settings file is being edited.

* fix: use platform-appropriate quoting for --config path

* chore: bump version to 2.1.8 and update recent updates

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
This commit is contained in:
Stuart Corbishley
2026-03-04 09:10:37 -05:00
committed by GitHub
co-authored by Matthew Breedlove
parent 79b8e4e2f5
commit d79eefb043
8 changed files with 335 additions and 21 deletions
+2 -1
View File
@@ -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 <path>` support so ccstatusline can load/save settings from a custom file location.
### v2.0.26 - v2.0.29 - Performance, git internals, and workflow improvements
+1 -1
View File
@@ -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",
+17
View File
@@ -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();
+7 -1
View File
@@ -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 = () => {
</Text>
)}
</Box>
{isCustomConfigPath() && (
<Text dimColor>{`Config: ${getConfigPath()}`}</Text>
)}
<StatusLinePreview
lines={settings.lines}
+189
View File
@@ -0,0 +1,189 @@
import {
afterEach,
afterAll,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
type Mock
} from 'vitest';
import {
getConfigPath,
isCustomConfigPath
} from '../config';
vi.mock('../config', () => ({
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<string, unknown> = {};
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<string, unknown>;
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\"`);
});
});
+48
View File
@@ -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);
});
});
+46 -14
View File
@@ -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<boolean> {
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<void> {
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<void> {
export async function getExistingStatusLine(): Promise<string | null> {
const settings = await loadClaudeSettings();
return settings.statusLine?.command ?? null;
}
}
+25 -4
View File
@@ -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<void> {
};
await writeSettingsJson(settingsWithVersion, paths);
}
}