mirror of
https://github.com/tiennm99/ccstatusline.git
synced 2026-07-19 22:18:27 +00:00
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:
co-authored by
Matthew Breedlove
parent
79b8e4e2f5
commit
d79eefb043
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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\"`);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user