Skip to content

Commit a3764c8

Browse files
JingkaiTangclaude
andcommitted
feat: support custom Claude CLI path and config directory
Add centralized CLI configuration module (cli-config.ts) that replaces 15+ hardcoded ~/.claude references across the codebase. New features: - Custom CLI binary path (claude_cli_path app setting) - Custom config directory (claude_config_dir app setting) - expandTilde() for ~ path expansion - Path validation on save (file exists / directory exists) - Cache invalidation when settings change Backend changes: - cli-config.ts: getClaudeConfigDir(), getCustomCliPath(), etc. - claude-client.ts: findClaudePath() prioritizes custom CLI path - settings/app/route.ts: validates and saves new path settings - All route files (plugins, skills, settings, chat) now use cli-config helpers instead of hardcoded os.homedir()/.claude paths Frontend changes: - GeneralSection.tsx: CLI path and config dir input fields with debounced auto-save and validation feedback - i18n: 7 new translation keys (en + zh) Tests: - 33 new unit tests for cli-config module (all passing) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a187f32 commit a3764c8

14 files changed

Lines changed: 568 additions & 45 deletions

File tree

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
/**
2+
* Unit tests for cli-config.ts
3+
*
4+
* Tests the centralized Claude CLI configuration module.
5+
*
6+
* Pure functions (expandTilde) are tested directly via import.
7+
* Functions that depend on getSetting (getClaudeConfigDir, etc.) are tested
8+
* by re-implementing the logic here — same pattern as mcp-config.test.ts.
9+
* This avoids needing to mock the database module.
10+
*
11+
* Uses Node's built-in test runner (zero dependencies).
12+
*/
13+
14+
import { describe, it } from 'node:test';
15+
import assert from 'node:assert/strict';
16+
import path from 'path';
17+
import os from 'os';
18+
19+
const HOME = os.homedir();
20+
21+
// ── Import the pure function directly ──────────────────────────
22+
import { expandTilde } from '../../lib/cli-config';
23+
24+
// ── Re-implement config resolution logic for testing ───────────
25+
// This mirrors the logic in cli-config.ts but accepts settings as
26+
// parameters instead of reading from the database.
27+
28+
const DEFAULT_CONFIG_DIR_NAME = '.claude';
29+
30+
function getClaudeConfigDir(configDirSetting?: string): string {
31+
if (configDirSetting) return expandTilde(configDirSetting);
32+
return path.join(os.homedir(), DEFAULT_CONFIG_DIR_NAME);
33+
}
34+
35+
function getClaudeBinaryName(cliPathSetting?: string): string {
36+
if (cliPathSetting) {
37+
const base = path.basename(expandTilde(cliPathSetting));
38+
return base.replace(/\.(cmd|exe|bat)$/i, '') || 'claude';
39+
}
40+
return 'claude';
41+
}
42+
43+
function getCustomCliPath(cliPathSetting?: string): string | undefined {
44+
if (cliPathSetting) return expandTilde(cliPathSetting);
45+
return undefined;
46+
}
47+
48+
function getClaudeUserConfigPath(configDirSetting?: string): string {
49+
const configDir = getClaudeConfigDir(configDirSetting);
50+
const dirName = path.basename(configDir);
51+
return path.join(os.homedir(), `${dirName}.json`);
52+
}
53+
54+
// ── Tests ──────────────────────────────────────────────────────
55+
56+
describe('cli-config', () => {
57+
// ── expandTilde (pure function, tested directly) ─────────────
58+
describe('expandTilde', () => {
59+
it('should expand bare ~ to home directory', () => {
60+
assert.equal(expandTilde('~'), HOME);
61+
});
62+
63+
it('should expand ~/ prefix to home directory', () => {
64+
assert.equal(expandTilde('~/foo/bar'), path.join(HOME, 'foo/bar'));
65+
});
66+
67+
it('should expand ~\\ prefix (Windows style)', () => {
68+
assert.equal(expandTilde('~\\foo\\bar'), path.join(HOME, 'foo\\bar'));
69+
});
70+
71+
it('should return absolute paths unchanged', () => {
72+
assert.equal(
73+
expandTilde('/usr/local/bin/claude'),
74+
'/usr/local/bin/claude',
75+
);
76+
});
77+
78+
it('should return relative paths unchanged', () => {
79+
assert.equal(expandTilde('foo/bar'), 'foo/bar');
80+
});
81+
82+
it('should handle ~/.claude-internal', () => {
83+
assert.equal(
84+
expandTilde('~/.claude-internal'),
85+
path.join(HOME, '.claude-internal'),
86+
);
87+
});
88+
89+
it('should not expand ~ in the middle of a path', () => {
90+
assert.equal(expandTilde('/home/~user/bin'), '/home/~user/bin');
91+
});
92+
93+
it('should handle empty string', () => {
94+
assert.equal(expandTilde(''), '');
95+
});
96+
});
97+
98+
// ── getClaudeConfigDir ───────────────────────────────────────
99+
describe('getClaudeConfigDir', () => {
100+
it('should return ~/.claude by default', () => {
101+
assert.equal(getClaudeConfigDir(), path.join(HOME, '.claude'));
102+
});
103+
104+
it('should return custom dir when setting is provided', () => {
105+
assert.equal(
106+
getClaudeConfigDir('~/.claude-internal'),
107+
path.join(HOME, '.claude-internal'),
108+
);
109+
});
110+
111+
it('should expand tilde in custom config dir', () => {
112+
assert.equal(
113+
getClaudeConfigDir('~/custom-claude'),
114+
path.join(HOME, 'custom-claude'),
115+
);
116+
});
117+
118+
it('should handle absolute path without tilde', () => {
119+
assert.equal(
120+
getClaudeConfigDir('/opt/claude-config'),
121+
'/opt/claude-config',
122+
);
123+
});
124+
125+
it('should ignore empty string setting (fall back to default)', () => {
126+
assert.equal(getClaudeConfigDir(''), path.join(HOME, '.claude'));
127+
});
128+
});
129+
130+
// ── getClaudeBinaryName ──────────────────────────────────────
131+
describe('getClaudeBinaryName', () => {
132+
it('should return "claude" by default', () => {
133+
assert.equal(getClaudeBinaryName(), 'claude');
134+
});
135+
136+
it('should derive name from custom CLI path', () => {
137+
assert.equal(
138+
getClaudeBinaryName('/usr/local/bin/claude-internal'),
139+
'claude-internal',
140+
);
141+
});
142+
143+
it('should strip .cmd extension (Windows)', () => {
144+
// On macOS/Linux, path.basename doesn't split on backslash,
145+
// so the full path becomes the basename. The .cmd is still stripped.
146+
const result = getClaudeBinaryName('C:\\Program Files\\claude.cmd');
147+
assert.ok(
148+
result.endsWith('claude'),
149+
`expected to end with "claude", got "${result}"`,
150+
);
151+
assert.ok(!result.endsWith('.cmd'), 'should not end with .cmd');
152+
});
153+
154+
it('should strip .exe extension (Windows)', () => {
155+
assert.equal(
156+
getClaudeBinaryName('~/bin/claude-internal.exe'),
157+
'claude-internal',
158+
);
159+
});
160+
161+
it('should strip .bat extension (Windows, case insensitive)', () => {
162+
assert.equal(getClaudeBinaryName('~/bin/claude.BAT'), 'claude');
163+
});
164+
165+
it('should handle tilde in CLI path', () => {
166+
assert.equal(getClaudeBinaryName('~/bin/my-claude'), 'my-claude');
167+
});
168+
169+
it('should not strip non-Windows extensions', () => {
170+
assert.equal(getClaudeBinaryName('/usr/bin/claude.sh'), 'claude.sh');
171+
});
172+
});
173+
174+
// ── getCustomCliPath ─────────────────────────────────────────
175+
describe('getCustomCliPath', () => {
176+
it('should return undefined when not configured', () => {
177+
assert.equal(getCustomCliPath(), undefined);
178+
assert.equal(getCustomCliPath(undefined), undefined);
179+
});
180+
181+
it('should return expanded path when configured with tilde', () => {
182+
assert.equal(
183+
getCustomCliPath('~/bin/claude-internal'),
184+
path.join(HOME, 'bin/claude-internal'),
185+
);
186+
});
187+
188+
it('should return absolute path as-is', () => {
189+
assert.equal(
190+
getCustomCliPath('/usr/local/bin/claude'),
191+
'/usr/local/bin/claude',
192+
);
193+
});
194+
});
195+
196+
// ── Convenience helpers ──────────────────────────────────────
197+
describe('convenience helpers (default config)', () => {
198+
const base = path.join(HOME, '.claude');
199+
200+
it('commands dir', () => {
201+
assert.equal(
202+
path.join(getClaudeConfigDir(), 'commands'),
203+
path.join(base, 'commands'),
204+
);
205+
});
206+
207+
it('skills dir', () => {
208+
assert.equal(
209+
path.join(getClaudeConfigDir(), 'skills'),
210+
path.join(base, 'skills'),
211+
);
212+
});
213+
214+
it('projects dir', () => {
215+
assert.equal(
216+
path.join(getClaudeConfigDir(), 'projects'),
217+
path.join(base, 'projects'),
218+
);
219+
});
220+
221+
it('settings path', () => {
222+
assert.equal(
223+
path.join(getClaudeConfigDir(), 'settings.json'),
224+
path.join(base, 'settings.json'),
225+
);
226+
});
227+
228+
it('bin dir', () => {
229+
assert.equal(
230+
path.join(getClaudeConfigDir(), 'bin'),
231+
path.join(base, 'bin'),
232+
);
233+
});
234+
235+
it('plugins dir', () => {
236+
assert.equal(
237+
path.join(getClaudeConfigDir(), 'plugins'),
238+
path.join(base, 'plugins'),
239+
);
240+
});
241+
});
242+
243+
describe('convenience helpers (custom config dir)', () => {
244+
const customDir = '~/.claude-internal';
245+
const base = path.join(HOME, '.claude-internal');
246+
247+
it('all subdirs should use custom base', () => {
248+
const dir = getClaudeConfigDir(customDir);
249+
assert.equal(path.join(dir, 'commands'), path.join(base, 'commands'));
250+
assert.equal(path.join(dir, 'skills'), path.join(base, 'skills'));
251+
assert.equal(path.join(dir, 'projects'), path.join(base, 'projects'));
252+
assert.equal(
253+
path.join(dir, 'settings.json'),
254+
path.join(base, 'settings.json'),
255+
);
256+
assert.equal(path.join(dir, 'bin'), path.join(base, 'bin'));
257+
assert.equal(path.join(dir, 'plugins'), path.join(base, 'plugins'));
258+
});
259+
});
260+
261+
// ── getClaudeUserConfigPath ──────────────────────────────────
262+
describe('getClaudeUserConfigPath', () => {
263+
it('should return ~/.claude.json by default', () => {
264+
assert.equal(getClaudeUserConfigPath(), path.join(HOME, '.claude.json'));
265+
});
266+
267+
it('should derive .json filename from custom config dir name', () => {
268+
assert.equal(
269+
getClaudeUserConfigPath('~/.claude-internal'),
270+
path.join(HOME, '.claude-internal.json'),
271+
);
272+
});
273+
274+
it('should use basename of absolute path config dir', () => {
275+
assert.equal(
276+
getClaudeUserConfigPath('/opt/my-claude'),
277+
path.join(HOME, 'my-claude.json'),
278+
);
279+
});
280+
});
281+
});

src/app/api/chat/route.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import fs from 'fs';
1010
import path from 'path';
1111
import os from 'os';
1212
import type { MCPServerConfig } from '@/types';
13+
import { getClaudeUserConfigPath, getClaudeSettingsPath } from '@/lib/cli-config';
1314

1415
export const runtime = 'nodejs';
1516
export const dynamic = 'force-dynamic';
@@ -21,8 +22,8 @@ function loadMcpServers(): Record<string, MCPServerConfig> | undefined {
2122
if (!fs.existsSync(p)) return {};
2223
try { return JSON.parse(fs.readFileSync(p, 'utf-8')); } catch { return {}; }
2324
};
24-
const userConfig = readJson(path.join(os.homedir(), '.claude.json'));
25-
const settings = readJson(path.join(os.homedir(), '.claude', 'settings.json'));
25+
const userConfig = readJson(getClaudeUserConfigPath());
26+
const settings = readJson(getClaudeSettingsPath());
2627
const merged = {
2728
...((userConfig.mcpServers || {}) as Record<string, MCPServerConfig>),
2829
...((settings.mcpServers || {}) as Record<string, MCPServerConfig>),

src/app/api/plugins/mcp/[name]/route.ts

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,9 @@
11
import { NextRequest, NextResponse } from 'next/server';
22
import fs from 'fs';
33
import path from 'path';
4-
import os from 'os';
4+
import { getClaudeSettingsPath, getClaudeUserConfigPath } from '@/lib/cli-config';
55
import type { MCPServerConfig, ErrorResponse, SuccessResponse } from '@/types';
66

7-
function getSettingsPath(): string {
8-
return path.join(os.homedir(), '.claude', 'settings.json');
9-
}
10-
11-
// ~/.claude.json — Claude CLI stores user-scoped MCP servers here
12-
function getUserConfigPath(): string {
13-
return path.join(os.homedir(), '.claude.json');
14-
}
15-
167
function readJsonFile(filePath: string): Record<string, unknown> {
178
if (!fs.existsSync(filePath)) return {};
189
try {
@@ -41,22 +32,24 @@ export async function DELETE(
4132
let deleted = false;
4233

4334
// Try deleting from ~/.claude/settings.json
44-
const settings = readJsonFile(getSettingsPath());
35+
const settingsPath = getClaudeSettingsPath();
36+
const settings = readJsonFile(settingsPath);
4537
const settingsServers = (settings.mcpServers || {}) as Record<string, MCPServerConfig>;
4638
if (settingsServers[serverName]) {
4739
delete settingsServers[serverName];
4840
settings.mcpServers = settingsServers;
49-
writeJsonFile(getSettingsPath(), settings);
41+
writeJsonFile(settingsPath, settings);
5042
deleted = true;
5143
}
5244

5345
// Also try deleting from ~/.claude.json
54-
const userConfig = readJsonFile(getUserConfigPath());
46+
const userConfigPath = getClaudeUserConfigPath();
47+
const userConfig = readJsonFile(userConfigPath);
5548
const userServers = (userConfig.mcpServers || {}) as Record<string, MCPServerConfig>;
5649
if (userServers[serverName]) {
5750
delete userServers[serverName];
5851
userConfig.mcpServers = userServers;
59-
writeJsonFile(getUserConfigPath(), userConfig);
52+
writeJsonFile(userConfigPath, userConfig);
6053
deleted = true;
6154
}
6255

0 commit comments

Comments
 (0)