diff --git a/src/__tests__/unit/cli-config.test.ts b/src/__tests__/unit/cli-config.test.ts new file mode 100644 index 00000000..0805b202 --- /dev/null +++ b/src/__tests__/unit/cli-config.test.ts @@ -0,0 +1,281 @@ +/** + * Unit tests for cli-config.ts + * + * Tests the centralized Claude CLI configuration module. + * + * Pure functions (expandTilde) are tested directly via import. + * Functions that depend on getSetting (getClaudeConfigDir, etc.) are tested + * by re-implementing the logic here — same pattern as mcp-config.test.ts. + * This avoids needing to mock the database module. + * + * Uses Node's built-in test runner (zero dependencies). + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'path'; +import os from 'os'; + +const HOME = os.homedir(); + +// ── Import the pure function directly ────────────────────────── +import { expandTilde } from '../../lib/cli-config'; + +// ── Re-implement config resolution logic for testing ─────────── +// This mirrors the logic in cli-config.ts but accepts settings as +// parameters instead of reading from the database. + +const DEFAULT_CONFIG_DIR_NAME = '.claude'; + +function getClaudeConfigDir(configDirSetting?: string): string { + if (configDirSetting) return expandTilde(configDirSetting); + return path.join(os.homedir(), DEFAULT_CONFIG_DIR_NAME); +} + +function getClaudeBinaryName(cliPathSetting?: string): string { + if (cliPathSetting) { + const base = path.basename(expandTilde(cliPathSetting)); + return base.replace(/\.(cmd|exe|bat)$/i, '') || 'claude'; + } + return 'claude'; +} + +function getCustomCliPath(cliPathSetting?: string): string | undefined { + if (cliPathSetting) return expandTilde(cliPathSetting); + return undefined; +} + +function getClaudeUserConfigPath(configDirSetting?: string): string { + const configDir = getClaudeConfigDir(configDirSetting); + const dirName = path.basename(configDir); + return path.join(os.homedir(), `${dirName}.json`); +} + +// ── Tests ────────────────────────────────────────────────────── + +describe('cli-config', () => { + // ── expandTilde (pure function, tested directly) ───────────── + describe('expandTilde', () => { + it('should expand bare ~ to home directory', () => { + assert.equal(expandTilde('~'), HOME); + }); + + it('should expand ~/ prefix to home directory', () => { + assert.equal(expandTilde('~/foo/bar'), path.join(HOME, 'foo/bar')); + }); + + it('should expand ~\\ prefix (Windows style)', () => { + assert.equal(expandTilde('~\\foo\\bar'), path.join(HOME, 'foo\\bar')); + }); + + it('should return absolute paths unchanged', () => { + assert.equal( + expandTilde('/usr/local/bin/claude'), + '/usr/local/bin/claude', + ); + }); + + it('should return relative paths unchanged', () => { + assert.equal(expandTilde('foo/bar'), 'foo/bar'); + }); + + it('should handle ~/.claude-internal', () => { + assert.equal( + expandTilde('~/.claude-internal'), + path.join(HOME, '.claude-internal'), + ); + }); + + it('should not expand ~ in the middle of a path', () => { + assert.equal(expandTilde('/home/~user/bin'), '/home/~user/bin'); + }); + + it('should handle empty string', () => { + assert.equal(expandTilde(''), ''); + }); + }); + + // ── getClaudeConfigDir ─────────────────────────────────────── + describe('getClaudeConfigDir', () => { + it('should return ~/.claude by default', () => { + assert.equal(getClaudeConfigDir(), path.join(HOME, '.claude')); + }); + + it('should return custom dir when setting is provided', () => { + assert.equal( + getClaudeConfigDir('~/.claude-internal'), + path.join(HOME, '.claude-internal'), + ); + }); + + it('should expand tilde in custom config dir', () => { + assert.equal( + getClaudeConfigDir('~/custom-claude'), + path.join(HOME, 'custom-claude'), + ); + }); + + it('should handle absolute path without tilde', () => { + assert.equal( + getClaudeConfigDir('/opt/claude-config'), + '/opt/claude-config', + ); + }); + + it('should ignore empty string setting (fall back to default)', () => { + assert.equal(getClaudeConfigDir(''), path.join(HOME, '.claude')); + }); + }); + + // ── getClaudeBinaryName ────────────────────────────────────── + describe('getClaudeBinaryName', () => { + it('should return "claude" by default', () => { + assert.equal(getClaudeBinaryName(), 'claude'); + }); + + it('should derive name from custom CLI path', () => { + assert.equal( + getClaudeBinaryName('/usr/local/bin/claude-internal'), + 'claude-internal', + ); + }); + + it('should strip .cmd extension (Windows)', () => { + // On macOS/Linux, path.basename doesn't split on backslash, + // so the full path becomes the basename. The .cmd is still stripped. + const result = getClaudeBinaryName('C:\\Program Files\\claude.cmd'); + assert.ok( + result.endsWith('claude'), + `expected to end with "claude", got "${result}"`, + ); + assert.ok(!result.endsWith('.cmd'), 'should not end with .cmd'); + }); + + it('should strip .exe extension (Windows)', () => { + assert.equal( + getClaudeBinaryName('~/bin/claude-internal.exe'), + 'claude-internal', + ); + }); + + it('should strip .bat extension (Windows, case insensitive)', () => { + assert.equal(getClaudeBinaryName('~/bin/claude.BAT'), 'claude'); + }); + + it('should handle tilde in CLI path', () => { + assert.equal(getClaudeBinaryName('~/bin/my-claude'), 'my-claude'); + }); + + it('should not strip non-Windows extensions', () => { + assert.equal(getClaudeBinaryName('/usr/bin/claude.sh'), 'claude.sh'); + }); + }); + + // ── getCustomCliPath ───────────────────────────────────────── + describe('getCustomCliPath', () => { + it('should return undefined when not configured', () => { + assert.equal(getCustomCliPath(), undefined); + assert.equal(getCustomCliPath(undefined), undefined); + }); + + it('should return expanded path when configured with tilde', () => { + assert.equal( + getCustomCliPath('~/bin/claude-internal'), + path.join(HOME, 'bin/claude-internal'), + ); + }); + + it('should return absolute path as-is', () => { + assert.equal( + getCustomCliPath('/usr/local/bin/claude'), + '/usr/local/bin/claude', + ); + }); + }); + + // ── Convenience helpers ────────────────────────────────────── + describe('convenience helpers (default config)', () => { + const base = path.join(HOME, '.claude'); + + it('commands dir', () => { + assert.equal( + path.join(getClaudeConfigDir(), 'commands'), + path.join(base, 'commands'), + ); + }); + + it('skills dir', () => { + assert.equal( + path.join(getClaudeConfigDir(), 'skills'), + path.join(base, 'skills'), + ); + }); + + it('projects dir', () => { + assert.equal( + path.join(getClaudeConfigDir(), 'projects'), + path.join(base, 'projects'), + ); + }); + + it('settings path', () => { + assert.equal( + path.join(getClaudeConfigDir(), 'settings.json'), + path.join(base, 'settings.json'), + ); + }); + + it('bin dir', () => { + assert.equal( + path.join(getClaudeConfigDir(), 'bin'), + path.join(base, 'bin'), + ); + }); + + it('plugins dir', () => { + assert.equal( + path.join(getClaudeConfigDir(), 'plugins'), + path.join(base, 'plugins'), + ); + }); + }); + + describe('convenience helpers (custom config dir)', () => { + const customDir = '~/.claude-internal'; + const base = path.join(HOME, '.claude-internal'); + + it('all subdirs should use custom base', () => { + const dir = getClaudeConfigDir(customDir); + assert.equal(path.join(dir, 'commands'), path.join(base, 'commands')); + assert.equal(path.join(dir, 'skills'), path.join(base, 'skills')); + assert.equal(path.join(dir, 'projects'), path.join(base, 'projects')); + assert.equal( + path.join(dir, 'settings.json'), + path.join(base, 'settings.json'), + ); + assert.equal(path.join(dir, 'bin'), path.join(base, 'bin')); + assert.equal(path.join(dir, 'plugins'), path.join(base, 'plugins')); + }); + }); + + // ── getClaudeUserConfigPath ────────────────────────────────── + describe('getClaudeUserConfigPath', () => { + it('should return ~/.claude.json by default', () => { + assert.equal(getClaudeUserConfigPath(), path.join(HOME, '.claude.json')); + }); + + it('should derive .json filename from custom config dir name', () => { + assert.equal( + getClaudeUserConfigPath('~/.claude-internal'), + path.join(HOME, '.claude-internal.json'), + ); + }); + + it('should use basename of absolute path config dir', () => { + assert.equal( + getClaudeUserConfigPath('/opt/my-claude'), + path.join(HOME, 'my-claude.json'), + ); + }); + }); +}); diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 42ab31ee..1fe15e51 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -10,6 +10,7 @@ import fs from 'fs'; import path from 'path'; import os from 'os'; import type { MCPServerConfig } from '@/types'; +import { getClaudeUserConfigPath, getClaudeSettingsPath } from '@/lib/cli-config'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -21,8 +22,8 @@ function loadMcpServers(): Record | undefined { if (!fs.existsSync(p)) return {}; try { return JSON.parse(fs.readFileSync(p, 'utf-8')); } catch { return {}; } }; - const userConfig = readJson(path.join(os.homedir(), '.claude.json')); - const settings = readJson(path.join(os.homedir(), '.claude', 'settings.json')); + const userConfig = readJson(getClaudeUserConfigPath()); + const settings = readJson(getClaudeSettingsPath()); // Also read project-level .mcp.json const projectMcp = readJson(path.join(process.cwd(), '.mcp.json')); const merged = { diff --git a/src/app/api/plugins/mcp/[name]/route.ts b/src/app/api/plugins/mcp/[name]/route.ts index 4cb53cd9..d4b72628 100644 --- a/src/app/api/plugins/mcp/[name]/route.ts +++ b/src/app/api/plugins/mcp/[name]/route.ts @@ -1,18 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; import fs from 'fs'; import path from 'path'; -import os from 'os'; +import { getClaudeSettingsPath, getClaudeUserConfigPath } from '@/lib/cli-config'; import type { MCPServerConfig, ErrorResponse, SuccessResponse } from '@/types'; -function getSettingsPath(): string { - return path.join(os.homedir(), '.claude', 'settings.json'); -} - -// ~/.claude.json — Claude CLI stores user-scoped MCP servers here -function getUserConfigPath(): string { - return path.join(os.homedir(), '.claude.json'); -} - function readJsonFile(filePath: string): Record { if (!fs.existsSync(filePath)) return {}; try { @@ -41,22 +32,24 @@ export async function DELETE( let deleted = false; // Try deleting from ~/.claude/settings.json - const settings = readJsonFile(getSettingsPath()); + const settingsPath = getClaudeSettingsPath(); + const settings = readJsonFile(settingsPath); const settingsServers = (settings.mcpServers || {}) as Record; if (settingsServers[serverName]) { delete settingsServers[serverName]; settings.mcpServers = settingsServers; - writeJsonFile(getSettingsPath(), settings); + writeJsonFile(settingsPath, settings); deleted = true; } // Also try deleting from ~/.claude.json - const userConfig = readJsonFile(getUserConfigPath()); + const userConfigPath = getClaudeUserConfigPath(); + const userConfig = readJsonFile(userConfigPath); const userServers = (userConfig.mcpServers || {}) as Record; if (userServers[serverName]) { delete userServers[serverName]; userConfig.mcpServers = userServers; - writeJsonFile(getUserConfigPath(), userConfig); + writeJsonFile(userConfigPath, userConfig); deleted = true; } diff --git a/src/app/api/plugins/mcp/route.ts b/src/app/api/plugins/mcp/route.ts index acdd48aa..7d61b936 100644 --- a/src/app/api/plugins/mcp/route.ts +++ b/src/app/api/plugins/mcp/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import fs from 'fs'; import path from 'path'; -import os from 'os'; +import { getClaudeSettingsPath, getClaudeUserConfigPath } from '@/lib/cli-config'; import type { MCPServerConfig, MCPConfigResponse, @@ -9,15 +9,6 @@ import type { SuccessResponse, } from '@/types'; -function getSettingsPath(): string { - return path.join(os.homedir(), '.claude', 'settings.json'); -} - -// ~/.claude.json — Claude CLI stores user-scoped MCP servers here -function getUserConfigPath(): string { - return path.join(os.homedir(), '.claude.json'); -} - function readJsonFile(filePath: string): Record { if (!fs.existsSync(filePath)) return {}; try { @@ -28,11 +19,11 @@ function readJsonFile(filePath: string): Record { } function readSettings(): Record { - return readJsonFile(getSettingsPath()); + return readJsonFile(getClaudeSettingsPath()); } function writeSettings(settings: Record): void { - const settingsPath = getSettingsPath(); + const settingsPath = getClaudeSettingsPath(); const dir = path.dirname(settingsPath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); @@ -43,7 +34,7 @@ function writeSettings(settings: Record): void { export async function GET(): Promise> { try { const settings = readSettings(); - const userConfig = readJsonFile(getUserConfigPath()); + const userConfig = readJsonFile(getClaudeUserConfigPath()); const settingsServers = (settings.mcpServers || {}) as Record; const userConfigServers = (userConfig.mcpServers || {}) as Record; @@ -93,9 +84,9 @@ export async function PUT( writeSettings(settings); // Write ~/.claude.json (only the mcpServers key, preserve other fields) - const userConfig = readJsonFile(getUserConfigPath()); + const userConfigPath = getClaudeUserConfigPath(); + const userConfig = readJsonFile(userConfigPath); userConfig.mcpServers = forUserConfig; - const userConfigPath = getUserConfigPath(); const dir = path.dirname(userConfigPath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); @@ -129,7 +120,7 @@ export async function POST( // Check both config files for name collision (merged namespace) const settings = readSettings(); - const userConfig = readJsonFile(getUserConfigPath()); + const userConfig = readJsonFile(getClaudeUserConfigPath()); if (!settings.mcpServers) { settings.mcpServers = {}; } diff --git a/src/app/api/settings/app/route.ts b/src/app/api/settings/app/route.ts index e87cab19..34a00d83 100644 --- a/src/app/api/settings/app/route.ts +++ b/src/app/api/settings/app/route.ts @@ -1,5 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; import { getSetting, setSetting } from '@/lib/db'; +import { expandTilde } from '@/lib/cli-config'; +import { clearClaudePathCache } from '@/lib/claude-client'; +import fs from 'fs'; /** * CodePilot app-level settings (stored in SQLite, separate from ~/.claude/settings.json). @@ -9,6 +12,8 @@ import { getSetting, setSetting } from '@/lib/db'; const ALLOWED_KEYS = [ 'anthropic_auth_token', 'anthropic_base_url', + 'claude_cli_path', + 'claude_config_dir', 'dangerously_skip_permissions', 'generative_ui_enabled', 'locale', @@ -53,6 +58,25 @@ export async function PUT(request: NextRequest) { if (key === 'anthropic_auth_token' && strValue.startsWith('***')) { continue; } + // Validate path settings + if (key === 'claude_cli_path') { + const expanded = expandTilde(strValue); + if (!fs.existsSync(expanded)) { + return NextResponse.json( + { error: `CLI path not found: ${expanded}` }, + { status: 400 } + ); + } + } + if (key === 'claude_config_dir') { + const expanded = expandTilde(strValue); + if (!fs.existsSync(expanded) || !fs.statSync(expanded).isDirectory()) { + return NextResponse.json( + { error: `Config directory not found: ${expanded}` }, + { status: 400 } + ); + } + } setSetting(key, strValue); } else { // Empty value = remove the setting @@ -60,6 +84,11 @@ export async function PUT(request: NextRequest) { } } + // Clear cached CLI path when path-related settings change + if ('claude_cli_path' in settings || 'claude_config_dir' in settings) { + clearClaudePathCache(); + } + return NextResponse.json({ success: true }); } catch (error) { const message = error instanceof Error ? error.message : 'Failed to save app settings'; diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts index 751e04f1..65cc8911 100644 --- a/src/app/api/settings/route.ts +++ b/src/app/api/settings/route.ts @@ -1,9 +1,9 @@ import { NextResponse } from "next/server"; import fs from "fs"; import path from "path"; -import os from "os"; +import { getClaudeSettingsPath } from "@/lib/cli-config"; -const SETTINGS_PATH = path.join(os.homedir(), ".claude", "settings.json"); +const SETTINGS_PATH = getClaudeSettingsPath(); function readSettingsFile(): Record { try { diff --git a/src/app/api/skills/[name]/route.ts b/src/app/api/skills/[name]/route.ts index 74cbc2a0..f7648777 100644 --- a/src/app/api/skills/[name]/route.ts +++ b/src/app/api/skills/[name]/route.ts @@ -1,12 +1,12 @@ import { NextResponse } from "next/server"; import fs from "fs"; import path from "path"; -import os from "os"; +import { getClaudeConfigDir, getClaudeSkillsDir as getClaudeGlobalSkillsDir } from "@/lib/cli-config"; import crypto from "crypto"; import type { SkillKind } from "@/types"; function getGlobalCommandsDir(): string { - return path.join(os.homedir(), ".claude", "commands"); + return path.join(getClaudeConfigDir(), "commands"); } function getProjectCommandsDir(cwd?: string): string { @@ -18,11 +18,11 @@ function getProjectSkillsDir(cwd?: string): string { } function getInstalledSkillsDir(): string { - return path.join(os.homedir(), ".agents", "skills"); + return path.join(getClaudeConfigDir(), "..", ".agents", "skills"); } function getClaudeSkillsDir(): string { - return path.join(os.homedir(), ".claude", "skills"); + return getClaudeGlobalSkillsDir(); } type InstalledSource = "agents" | "claude"; diff --git a/src/app/api/skills/route.ts b/src/app/api/skills/route.ts index 14c59d67..214f8ebe 100644 --- a/src/app/api/skills/route.ts +++ b/src/app/api/skills/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import fs from "fs"; import path from "path"; import os from "os"; +import { getClaudeConfigDir, getClaudePluginsDir, getClaudeSkillsDir as getClaudeGlobalSkillsDir } from "@/lib/cli-config"; import crypto from "crypto"; import type { SkillKind } from "@/types"; @@ -19,7 +20,7 @@ type InstalledSource = "agents" | "claude"; type InstalledSkill = SkillFile & { installedSource: InstalledSource; contentHash: string }; function getGlobalCommandsDir(): string { - return path.join(os.homedir(), ".claude", "commands"); + return path.join(getClaudeConfigDir(), "commands"); } function getProjectCommandsDir(cwd?: string): string { @@ -32,7 +33,7 @@ function getProjectSkillsDir(cwd?: string): string { function getPluginCommandsDirs(): string[] { const dirs: string[] = []; - const pluginsRoot = path.join(os.homedir(), ".claude", "plugins"); + const pluginsRoot = getClaudePluginsDir(); // Scan marketplaces: ~/.claude/plugins/marketplaces/{mkt}/plugins/*/commands const marketplacesDir = path.join(pluginsRoot, "marketplaces"); @@ -79,7 +80,7 @@ function getInstalledSkillsDir(): string { } function getClaudeSkillsDir(): string { - return path.join(os.homedir(), ".claude", "skills"); + return getClaudeGlobalSkillsDir(); } /** @@ -310,7 +311,7 @@ export async function GET(request: NextRequest) { console.log(`[skills] Scanning global: ${globalDir} (exists: ${fs.existsSync(globalDir)})`); console.log(`[skills] Scanning project: ${projectDir} (exists: ${fs.existsSync(projectDir)})`); - console.log(`[skills] HOME=${process.env.HOME}, homedir=${os.homedir()}`); + console.log(`[skills] HOME=${process.env.HOME}, configDir=${getClaudeConfigDir()}`); const globalSkills = scanDirectory(globalDir, "global"); const projectSkills = scanDirectory(projectDir, "project"); diff --git a/src/components/settings/GeneralSection.tsx b/src/components/settings/GeneralSection.tsx index c6791045..68921e58 100644 --- a/src/components/settings/GeneralSection.tsx +++ b/src/components/settings/GeneralSection.tsx @@ -1,7 +1,8 @@ "use client"; -import { useState, useCallback, useEffect } from "react"; +import { useState, useCallback, useEffect, useRef } from "react"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import { AlertDialog, @@ -128,6 +129,19 @@ export function GeneralSection() { const [skipPermSaving, setSkipPermSaving] = useState(false); const [generativeUI, setGenerativeUI] = useState(true); const [generativeUISaving, setGenerativeUISaving] = useState(false); + const [cliPath, setCliPath] = useState(""); + const [configDir, setConfigDir] = useState(""); + const [cliPathValidation, setCliPathValidation] = useState<{ + valid: boolean; + resolved?: string; + } | null>(null); + const [configDirValidation, setConfigDirValidation] = useState<{ + valid: boolean; + resolved?: string; + } | null>(null); + const [pathSaving, setPathSaving] = useState(false); + const cliPathTimer = useRef | null>(null); + const configDirTimer = useRef | null>(null); const { accountInfo } = useAccountInfo(); const { t, locale, setLocale } = useTranslation(); @@ -140,6 +154,8 @@ export function GeneralSection() { setSkipPermissions(appSettings.dangerously_skip_permissions === "true"); // generative_ui_enabled defaults to true when not set setGenerativeUI(appSettings.generative_ui_enabled !== "false"); + if (appSettings.claude_cli_path) setCliPath(appSettings.claude_cli_path); + if (appSettings.claude_config_dir) setConfigDir(appSettings.claude_config_dir); } } catch { // ignore @@ -150,6 +166,49 @@ export function GeneralSection() { fetchAppSettings(); }, [fetchAppSettings]); + const savePathSetting = async (key: string, value: string) => { + setPathSaving(true); + try { + const res = await fetch("/api/settings/app", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ settings: { [key]: value } }), + }); + const data = await res.json(); + if (res.ok) { + if (key === 'claude_cli_path') { + setCliPathValidation(value ? { valid: true } : null); + } else { + setConfigDirValidation(value ? { valid: true } : null); + } + } else { + if (key === 'claude_cli_path') { + setCliPathValidation({ valid: false, resolved: data.error }); + } else { + setConfigDirValidation({ valid: false, resolved: data.error }); + } + } + } catch { + // ignore + } finally { + setPathSaving(false); + } + }; + + const handleCliPathChange = (value: string) => { + setCliPath(value); + setCliPathValidation(null); + if (cliPathTimer.current) clearTimeout(cliPathTimer.current); + cliPathTimer.current = setTimeout(() => savePathSetting('claude_cli_path', value), 800); + }; + + const handleConfigDirChange = (value: string) => { + setConfigDir(value); + setConfigDirValidation(null); + if (configDirTimer.current) clearTimeout(configDirTimer.current); + configDirTimer.current = setTimeout(() => savePathSetting('claude_config_dir', value), 800); + }; + const handleSkipPermToggle = (checked: boolean) => { if (checked) { setShowSkipPermWarning(true); @@ -270,6 +329,49 @@ export function GeneralSection() { + {/* Custom CLI path */} + +
+ handleCliPathChange(e.target.value)} + placeholder="e.g. ~/.claude-internal/local/claude" + className="text-sm" + disabled={pathSaving} + /> + {cliPathValidation && ( +

+ {cliPathValidation.valid ? t('settings.pathValid' as TranslationKey) : cliPathValidation.resolved} +

+ )} +
+
+ + {/* Custom config directory */} + +
+ handleConfigDirChange(e.target.value)} + placeholder="e.g. ~/.claude-internal" + className="text-sm" + disabled={pathSaving} + /> + {configDirValidation && ( +

+ {configDirValidation.valid ? t('settings.pathValid' as TranslationKey) : configDirValidation.resolved} +

+ )} +
+
{/* Appearance */} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 479d50f5..1089fff8 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -925,6 +925,15 @@ const en = { 'settings.thinkingEnabled': 'Enabled', 'settings.thinkingDisabled': 'Disabled', + // CLI path settings + 'settings.cliPathTitle': 'Claude CLI Path', + 'settings.cliPathDesc': 'Custom path to the Claude CLI binary. Leave empty to use the default.', + 'settings.configDirTitle': 'Claude Config Directory', + 'settings.configDirDesc': 'Custom path to the Claude config directory (default: ~/.claude).', + 'settings.pathValid': 'Path is valid', + 'settings.pathInvalid': 'Path not found', + 'settings.pathChecking': 'Checking...', + // ── SDK Capabilities: Account ───────────────────────────── 'settings.accountInfo': 'Account Information', 'settings.email': 'Email', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index c239873d..5581fdd2 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -922,6 +922,15 @@ const zh: Record = { 'settings.thinkingEnabled': '启用', 'settings.thinkingDisabled': '禁用', + // CLI path settings + 'settings.cliPathTitle': 'Claude CLI 路径', + 'settings.cliPathDesc': '自定义 Claude CLI 二进制文件路径,留空使用默认值。', + 'settings.configDirTitle': 'Claude 配置目录', + 'settings.configDirDesc': '自定义 Claude 配置目录路径(默认:~/.claude)。', + 'settings.pathValid': '路径有效', + 'settings.pathInvalid': '路径未找到', + 'settings.pathChecking': '检查中...', + // ── SDK Capabilities: Account ───────────────────────────── 'settings.accountInfo': '账户信息', 'settings.email': '邮箱', diff --git a/src/lib/claude-client.ts b/src/lib/claude-client.ts index 9979e91e..a040dc97 100644 --- a/src/lib/claude-client.ts +++ b/src/lib/claude-client.ts @@ -21,6 +21,7 @@ import { captureCapabilities, setCachedPlugins } from './agent-sdk-capabilities' import { getSetting, updateSdkSessionId, createPermissionRequest } from './db'; import { resolveForClaudeCode, toClaudeCodeEnv } from './provider-resolver'; import { findClaudeBinary, findGitBash, getExpandedPath, invalidateClaudePathCache } from './platform'; +import { getCustomCliPath } from './cli-config'; import { notifyPermissionRequest, notifyGeneric } from './telegram-bot'; import { classifyError, formatClassifiedError } from './error-classifier'; import os from 'os'; @@ -91,11 +92,23 @@ let cachedClaudePath: string | null | undefined; function findClaudePath(): string | undefined { if (cachedClaudePath !== undefined) return cachedClaudePath || undefined; + // Prefer custom CLI path from app settings + const customPath = getCustomCliPath(); + if (customPath) { + cachedClaudePath = customPath; + return customPath; + } const found = findClaudeBinary(); cachedClaudePath = found ?? null; return found; } +/** Clear the cached CLI path so the next call to findClaudePath() re-evaluates. */ +export function clearClaudePathCache(): void { + cachedClaudePath = undefined; + invalidateClaudePathCache(); +} + /** * Invalidate the cached Claude binary path in this module AND in platform.ts. * Must be called after installation so the next SDK call picks up the new binary. diff --git a/src/lib/claude-session-parser.ts b/src/lib/claude-session-parser.ts index 07380be8..53296e8d 100644 --- a/src/lib/claude-session-parser.ts +++ b/src/lib/claude-session-parser.ts @@ -14,7 +14,7 @@ import fs from 'fs'; import path from 'path'; -import os from 'os'; +import * as cliConfig from './cli-config'; import type { MessageContentBlock } from '@/types'; // ========================================== @@ -136,7 +136,7 @@ interface ContentBlock { * Get the Claude Code projects directory. */ export function getClaudeProjectsDir(): string { - return path.join(os.homedir(), '.claude', 'projects'); + return cliConfig.getClaudeProjectsDir(); } /** diff --git a/src/lib/cli-config.ts b/src/lib/cli-config.ts new file mode 100644 index 00000000..e0a8b3fa --- /dev/null +++ b/src/lib/cli-config.ts @@ -0,0 +1,94 @@ +/** + * Centralized Claude CLI configuration. + * + * All references to the Claude config directory (~/.claude or ~/.claude-internal) + * and CLI binary name should go through this module. When `claude_config_dir` + * is set in CodePilot app settings, that value is used; otherwise defaults to + * `~/.claude`. + */ +import path from 'path'; +import os from 'os'; +import { getSetting } from './db'; + +const DEFAULT_CONFIG_DIR_NAME = '.claude'; + +/** + * Expand leading `~` or `~user` to the actual home directory. + * Handles `~/...`, `~\...` (Windows), and bare `~`. + */ +export function expandTilde(p: string): string { + if (p === '~') return os.homedir(); + if (p.startsWith('~/') || p.startsWith('~\\')) { + return path.join(os.homedir(), p.slice(2)); + } + return p; +} + +/** + * Get the Claude configuration directory (e.g. ~/.claude or ~/.claude-internal). + * Reads the `claude_config_dir` app setting; falls back to ~/.claude. + */ +export function getClaudeConfigDir(): string { + const custom = getSetting('claude_config_dir'); + if (custom) return expandTilde(custom); + return path.join(os.homedir(), DEFAULT_CONFIG_DIR_NAME); +} + +/** + * Get the Claude CLI binary name to search for in PATH. + * Derives from `claude_cli_path` setting (basename) or defaults to "claude". + */ +export function getClaudeBinaryName(): string { + const customPath = getSetting('claude_cli_path'); + if (customPath) { + const base = path.basename(expandTilde(customPath)); + return base.replace(/\.(cmd|exe|bat)$/i, '') || 'claude'; + } + return 'claude'; +} + +/** + * Get the full resolved CLI path from settings (with ~ expanded). + * Returns undefined if not configured. + */ +export function getCustomCliPath(): string | undefined { + const customPath = getSetting('claude_cli_path'); + if (customPath) return expandTilde(customPath); + return undefined; +} + +// Convenience helpers for common subdirectories + +export function getClaudeCommandsDir(): string { + return path.join(getClaudeConfigDir(), 'commands'); +} + +export function getClaudeSkillsDir(): string { + return path.join(getClaudeConfigDir(), 'skills'); +} + +export function getClaudeProjectsDir(): string { + return path.join(getClaudeConfigDir(), 'projects'); +} + +export function getClaudeSettingsPath(): string { + return path.join(getClaudeConfigDir(), 'settings.json'); +} + +export function getClaudeBinDir(): string { + return path.join(getClaudeConfigDir(), 'bin'); +} + +export function getClaudePluginsDir(): string { + return path.join(getClaudeConfigDir(), 'plugins'); +} + +/** + * Get the user-level config file path (~/.claude.json or ~/.claude-internal.json). + * This is separate from the config directory — it's the CLI's root config. + */ +export function getClaudeUserConfigPath(): string { + const configDir = getClaudeConfigDir(); + const dirName = path.basename(configDir); + return path.join(os.homedir(), `${dirName}.json`); +}