diff --git a/src/__tests__/unit/cli-config.test.ts b/src/__tests__/unit/cli-config.test.ts new file mode 100644 index 00000000..3aca9363 --- /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/plugins/[id]/route.ts b/src/app/api/plugins/[id]/route.ts index e0e8f331..00824a5b 100644 --- a/src/app/api/plugins/[id]/route.ts +++ b/src/app/api/plugins/[id]/route.ts @@ -1,22 +1,22 @@ -import { NextRequest, NextResponse } from 'next/server'; -import fs from 'fs'; -import path from 'path'; -import os from 'os'; -import type { PluginInfo, ErrorResponse, SuccessResponse } from '@/types'; +import { NextRequest, NextResponse } from "next/server"; +import fs from "fs"; +import path from "path"; +import type { PluginInfo, ErrorResponse, SuccessResponse } from "@/types"; +import { getClaudeConfigDir } from "@/lib/cli-config"; function getClaudeDir(): string { - return path.join(os.homedir(), '.claude'); + return getClaudeConfigDir(); } function getSettingsPath(): string { - return path.join(getClaudeDir(), 'settings.json'); + return path.join(getClaudeDir(), "settings.json"); } function readSettings(): Record { const settingsPath = getSettingsPath(); if (!fs.existsSync(settingsPath)) return {}; try { - return JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); + return JSON.parse(fs.readFileSync(settingsPath, "utf-8")); } catch { return {}; } @@ -28,28 +28,28 @@ function writeSettings(settings: Record): void { if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } - fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8'); + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), "utf-8"); } export async function GET( _request: NextRequest, - { params }: { params: Promise<{ id: string }> } + { params }: { params: Promise<{ id: string }> }, ): Promise> { const { id } = await params; const pluginName = decodeURIComponent(id); // Check in commands directory - const commandsDir = path.join(getClaudeDir(), 'commands'); + const commandsDir = path.join(getClaudeDir(), "commands"); const filePath = path.join(commandsDir, `${pluginName}.md`); if (fs.existsSync(filePath)) { - const content = fs.readFileSync(filePath, 'utf-8'); - const firstLine = content.split('\n')[0]?.trim() || ''; + const content = fs.readFileSync(filePath, "utf-8"); + const firstLine = content.split("\n")[0]?.trim() || ""; return NextResponse.json({ plugin: { name: pluginName, - description: firstLine.startsWith('#') - ? firstLine.replace(/^#+\s*/, '') + description: firstLine.startsWith("#") + ? firstLine.replace(/^#+\s*/, "") : `Skill: /${pluginName}`, enabled: true, }, @@ -73,12 +73,12 @@ export async function GET( }); } - return NextResponse.json({ error: 'Plugin not found' }, { status: 404 }); + return NextResponse.json({ error: "Plugin not found" }, { status: 404 }); } export async function PUT( request: NextRequest, - { params }: { params: Promise<{ id: string }> } + { params }: { params: Promise<{ id: string }> }, ): Promise> { try { const { id } = await params; @@ -106,8 +106,11 @@ export async function PUT( return NextResponse.json({ success: true }); } catch (error) { return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to update plugin' }, - { status: 500 } + { + error: + error instanceof Error ? error.message : "Failed to update plugin", + }, + { status: 500 }, ); } } diff --git a/src/app/api/plugins/mcp/[name]/route.ts b/src/app/api/plugins/mcp/[name]/route.ts index 659a6bf2..c44474a8 100644 --- a/src/app/api/plugins/mcp/[name]/route.ts +++ b/src/app/api/plugins/mcp/[name]/route.ts @@ -1,18 +1,18 @@ -import { NextRequest, NextResponse } from 'next/server'; -import fs from 'fs'; -import path from 'path'; -import os from 'os'; -import type { MCPServerConfig, ErrorResponse, SuccessResponse } from '@/types'; +import { NextRequest, NextResponse } from "next/server"; +import fs from "fs"; +import path from "path"; +import type { MCPServerConfig, ErrorResponse, SuccessResponse } from "@/types"; +import { getClaudeSettingsPath } from "@/lib/cli-config"; function getSettingsPath(): string { - return path.join(os.homedir(), '.claude', 'settings.json'); + return getClaudeSettingsPath(); } function readSettings(): Record { const settingsPath = getSettingsPath(); if (!fs.existsSync(settingsPath)) return {}; try { - return JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); + return JSON.parse(fs.readFileSync(settingsPath, "utf-8")); } catch { return {}; } @@ -24,24 +24,27 @@ function writeSettings(settings: Record): void { if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } - fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8'); + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), "utf-8"); } export async function DELETE( _request: NextRequest, - { params }: { params: Promise<{ name: string }> } + { params }: { params: Promise<{ name: string }> }, ): Promise> { try { const { name } = await params; const serverName = decodeURIComponent(name); const settings = readSettings(); - const mcpServers = (settings.mcpServers || {}) as Record; + const mcpServers = (settings.mcpServers || {}) as Record< + string, + MCPServerConfig + >; if (!mcpServers[serverName]) { return NextResponse.json( { error: `MCP server "${serverName}" not found` }, - { status: 404 } + { status: 404 }, ); } @@ -52,8 +55,13 @@ export async function DELETE( return NextResponse.json({ success: true }); } catch (error) { return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to delete MCP server' }, - { status: 500 } + { + error: + error instanceof Error + ? error.message + : "Failed to delete MCP server", + }, + { status: 500 }, ); } } diff --git a/src/app/api/plugins/mcp/route.ts b/src/app/api/plugins/mcp/route.ts index 18f968b9..68724c98 100644 --- a/src/app/api/plugins/mcp/route.ts +++ b/src/app/api/plugins/mcp/route.ts @@ -1,27 +1,30 @@ -import { NextRequest, NextResponse } from 'next/server'; -import fs from 'fs'; -import path from 'path'; -import os from 'os'; +import { NextRequest, NextResponse } from "next/server"; +import fs from "fs"; +import path from "path"; import type { MCPServerConfig, MCPConfigResponse, ErrorResponse, SuccessResponse, -} from '@/types'; +} from "@/types"; +import { + getClaudeSettingsPath, + getClaudeUserConfigPath, +} from "@/lib/cli-config"; function getSettingsPath(): string { - return path.join(os.homedir(), '.claude', 'settings.json'); + return getClaudeSettingsPath(); } -// ~/.claude.json — Claude CLI stores user-scoped MCP servers here +// User-level config file (e.g. ~/.claude.json or ~/.claude-internal.json) function getUserConfigPath(): string { - return path.join(os.homedir(), '.claude.json'); + return getClaudeUserConfigPath(); } function readJsonFile(filePath: string): Record { if (!fs.existsSync(filePath)) return {}; try { - return JSON.parse(fs.readFileSync(filePath, 'utf-8')); + return JSON.parse(fs.readFileSync(filePath, "utf-8")); } catch { return {}; } @@ -37,10 +40,12 @@ function writeSettings(settings: Record): void { if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } - fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8'); + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), "utf-8"); } -export async function GET(): Promise> { +export async function GET(): Promise< + NextResponse +> { try { const settings = readSettings(); const userConfig = readJsonFile(getUserConfigPath()); @@ -52,18 +57,23 @@ export async function GET(): Promise> { try { const body = await request.json(); - const { mcpServers } = body as { mcpServers: Record }; + const { mcpServers } = body as { + mcpServers: Record; + }; const settings = readSettings(); settings.mcpServers = mcpServers; @@ -72,14 +82,19 @@ export async function PUT( return NextResponse.json({ success: true }); } catch (error) { return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to update MCP config' }, - { status: 500 } + { + error: + error instanceof Error + ? error.message + : "Failed to update MCP config", + }, + { status: 500 }, ); } } export async function POST( - request: NextRequest + request: NextRequest, ): Promise> { try { const body = await request.json(); @@ -87,8 +102,8 @@ export async function POST( if (!name || !server || !server.command) { return NextResponse.json( - { error: 'Name and server command are required' }, - { status: 400 } + { error: "Name and server command are required" }, + { status: 400 }, ); } @@ -101,7 +116,7 @@ export async function POST( if (mcpServers[name]) { return NextResponse.json( { error: `MCP server "${name}" already exists` }, - { status: 409 } + { status: 409 }, ); } @@ -111,8 +126,11 @@ export async function POST( return NextResponse.json({ success: true }); } catch (error) { return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to add MCP server' }, - { status: 500 } + { + error: + error instanceof Error ? error.message : "Failed to add MCP server", + }, + { status: 500 }, ); } } diff --git a/src/app/api/plugins/route.ts b/src/app/api/plugins/route.ts index c4e910e5..1a0e74c2 100644 --- a/src/app/api/plugins/route.ts +++ b/src/app/api/plugins/route.ts @@ -1,13 +1,13 @@ -import { NextResponse } from 'next/server'; -import fs from 'fs'; -import path from 'path'; -import os from 'os'; -import type { ErrorResponse } from '@/types'; +import { NextResponse } from "next/server"; +import fs from "fs"; +import path from "path"; +import type { ErrorResponse } from "@/types"; +import { getClaudeConfigDir } from "@/lib/cli-config"; export interface SkillInfo { name: string; description: string; - source: 'global' | 'project'; + source: "global" | "project"; content: string; filePath: string; enabled: boolean; @@ -18,7 +18,7 @@ export interface SkillsResponse { } function getClaudeDir(): string { - return path.join(os.homedir(), '.claude'); + return getClaudeConfigDir(); } function discoverSkills(): SkillInfo[] { @@ -26,23 +26,23 @@ function discoverSkills(): SkillInfo[] { const skills: SkillInfo[] = []; // Scan for .md skill files in global commands directory - const globalCommandsDir = path.join(claudeDir, 'commands'); + const globalCommandsDir = path.join(claudeDir, "commands"); if (fs.existsSync(globalCommandsDir)) { try { const files = fs.readdirSync(globalCommandsDir); for (const file of files) { - if (file.endsWith('.md')) { - const name = file.replace(/\.md$/, ''); + if (file.endsWith(".md")) { + const name = file.replace(/\.md$/, ""); const filePath = path.join(globalCommandsDir, file); - const content = fs.readFileSync(filePath, 'utf-8'); - const firstLine = content.split('\n')[0]?.trim() || ''; - const description = firstLine.startsWith('#') - ? firstLine.replace(/^#+\s*/, '') + const content = fs.readFileSync(filePath, "utf-8"); + const firstLine = content.split("\n")[0]?.trim() || ""; + const description = firstLine.startsWith("#") + ? firstLine.replace(/^#+\s*/, "") : `Skill: /${name}`; skills.push({ name, description, - source: 'global', + source: "global", content, filePath, enabled: true, @@ -55,23 +55,23 @@ function discoverSkills(): SkillInfo[] { } // Scan project-level .claude/commands - const projectCommandsDir = path.join(process.cwd(), '.claude', 'commands'); + const projectCommandsDir = path.join(process.cwd(), ".claude", "commands"); if (fs.existsSync(projectCommandsDir)) { try { const files = fs.readdirSync(projectCommandsDir); for (const file of files) { - if (file.endsWith('.md')) { - const name = file.replace(/\.md$/, ''); + if (file.endsWith(".md")) { + const name = file.replace(/\.md$/, ""); const filePath = path.join(projectCommandsDir, file); - const content = fs.readFileSync(filePath, 'utf-8'); - const firstLine = content.split('\n')[0]?.trim() || ''; - const description = firstLine.startsWith('#') - ? firstLine.replace(/^#+\s*/, '') + const content = fs.readFileSync(filePath, "utf-8"); + const firstLine = content.split("\n")[0]?.trim() || ""; + const description = firstLine.startsWith("#") + ? firstLine.replace(/^#+\s*/, "") : `Project skill: /${name}`; skills.push({ name: `project:${name}`, description, - source: 'project', + source: "project", content, filePath, enabled: true, @@ -86,14 +86,19 @@ function discoverSkills(): SkillInfo[] { return skills; } -export async function GET(): Promise> { +export async function GET(): Promise< + NextResponse +> { try { const plugins = discoverSkills(); return NextResponse.json({ plugins }); } catch (error) { return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to load plugins' }, - { status: 500 } + { + error: + error instanceof Error ? error.message : "Failed to load plugins", + }, + { status: 500 }, ); } } diff --git a/src/app/api/settings/app/route.ts b/src/app/api/settings/app/route.ts index 65aca03d..2cfd0d83 100644 --- a/src/app/api/settings/app/route.ts +++ b/src/app/api/settings/app/route.ts @@ -1,5 +1,7 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getSetting, setSetting } from '@/lib/db'; +import { NextRequest, NextResponse } from "next/server"; +import { getSetting, setSetting } from "@/lib/db"; +import { expandTilde } from "@/lib/cli-config"; +import fs from "fs"; /** * CodePilot app-level settings (stored in SQLite, separate from ~/.claude/settings.json). @@ -7,10 +9,12 @@ import { getSetting, setSetting } from '@/lib/db'; */ const ALLOWED_KEYS = [ - 'anthropic_auth_token', - 'anthropic_base_url', - 'dangerously_skip_permissions', - 'locale', + "anthropic_auth_token", + "anthropic_base_url", + "dangerously_skip_permissions", + "locale", + "claude_cli_path", + "claude_config_dir", ]; export async function GET() { @@ -20,8 +24,8 @@ export async function GET() { const value = getSetting(key); if (value !== undefined) { // Mask token for security (only return last 8 chars) - if (key === 'anthropic_auth_token' && value.length > 8) { - result[key] = '***' + value.slice(-8); + if (key === "anthropic_auth_token" && value.length > 8) { + result[key] = "***" + value.slice(-8); } else { result[key] = value; } @@ -29,7 +33,8 @@ export async function GET() { } return NextResponse.json({ settings: result }); } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to read app settings'; + const message = + error instanceof Error ? error.message : "Failed to read app settings"; return NextResponse.json({ error: message }, { status: 500 }); } } @@ -39,28 +44,63 @@ export async function PUT(request: NextRequest) { const body = await request.json(); const { settings } = body; - if (!settings || typeof settings !== 'object') { - return NextResponse.json({ error: 'Invalid settings data' }, { status: 400 }); + if (!settings || typeof settings !== "object") { + return NextResponse.json( + { error: "Invalid settings data" }, + { status: 400 }, + ); } + // Collect validation results for path-type settings + const validation: Record = + {}; + for (const [key, value] of Object.entries(settings)) { if (!ALLOWED_KEYS.includes(key)) continue; - const strValue = String(value ?? '').trim(); + const strValue = String(value ?? "").trim(); if (strValue) { // Don't overwrite token if user sent the masked version back - if (key === 'anthropic_auth_token' && strValue.startsWith('***')) { + if (key === "anthropic_auth_token" && strValue.startsWith("***")) { continue; } setSetting(key, strValue); + + // Validate path-type settings after saving + if (key === "claude_cli_path") { + const resolved = expandTilde(strValue); + let valid = false; + try { + const stat = fs.statSync(resolved); + valid = stat.isFile(); + } catch { + // file doesn't exist + } + validation[key] = { valid, resolved }; + } else if (key === "claude_config_dir") { + const resolved = expandTilde(strValue); + let valid = false; + try { + const stat = fs.statSync(resolved); + valid = stat.isDirectory(); + } catch { + // directory doesn't exist + } + validation[key] = { valid, resolved }; + } } else { // Empty value = remove the setting - setSetting(key, ''); + setSetting(key, ""); + // Clearing a path is always valid + if (key === "claude_cli_path" || key === "claude_config_dir") { + validation[key] = { valid: true }; + } } } - return NextResponse.json({ success: true }); + return NextResponse.json({ success: true, validation }); } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to save app settings'; + const message = + error instanceof Error ? error.message : "Failed to save app settings"; return NextResponse.json({ error: message }, { status: 500 }); } } diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts index 751e04f1..5403a30e 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 { @@ -32,7 +32,7 @@ export async function GET() { } catch (error) { return NextResponse.json( { error: "Failed to read settings" }, - { status: 500 } + { status: 500 }, ); } } @@ -45,7 +45,7 @@ export async function PUT(request: Request) { if (!settings || typeof settings !== "object") { return NextResponse.json( { error: "Invalid settings data" }, - { status: 400 } + { status: 400 }, ); } @@ -54,7 +54,7 @@ export async function PUT(request: Request) { } catch (error) { return NextResponse.json( { error: "Failed to save settings" }, - { status: 500 } + { status: 500 }, ); } } diff --git a/src/app/api/skills/[name]/route.ts b/src/app/api/skills/[name]/route.ts index 2f3702dc..d9e6734c 100644 --- a/src/app/api/skills/[name]/route.ts +++ b/src/app/api/skills/[name]/route.ts @@ -3,9 +3,10 @@ import fs from "fs"; import path from "path"; import os from "os"; import crypto from "crypto"; +import { getClaudeCommandsDir, getClaudeSkillsDir } from "@/lib/cli-config"; function getGlobalCommandsDir(): string { - return path.join(os.homedir(), ".claude", "commands"); + return getClaudeCommandsDir(); } function getProjectCommandsDir(cwd?: string): string { @@ -20,10 +21,6 @@ function getInstalledSkillsDir(): string { return path.join(os.homedir(), ".agents", "skills"); } -function getClaudeSkillsDir(): string { - return path.join(os.homedir(), ".claude", "skills"); -} - type InstalledSource = "agents" | "claude"; type SkillSource = "global" | "project" | "installed"; type SkillMatch = { @@ -40,7 +37,10 @@ function computeContentHash(content: string): string { * Parse YAML front matter from SKILL.md content. * Extracts `name` and `description` fields from the --- delimited block. */ -function parseSkillFrontMatter(content: string): { name?: string; description?: string } { +function parseSkillFrontMatter(content: string): { + name?: string; + description?: string; +} { const fmMatch = content.match(/^---\r?\n([\s\S]+?)\r?\n---/); if (!fmMatch) return {}; @@ -114,7 +114,7 @@ type InstalledMatch = { function findInstalledSkillMatches( name: string, - installedSource?: InstalledSource + installedSource?: InstalledSource, ): InstalledMatch[] { const matches: InstalledMatch[] = []; const dirs: Array<{ dir: string; source: InstalledSource }> = []; @@ -153,22 +153,30 @@ function findInstalledSkillMatches( function findSkillFile( name: string, - options?: { installedSource?: InstalledSource; installedOnly?: boolean; cwd?: string } -): - | SkillMatch - | { conflict: true; sources: InstalledSource[] } - | null { + options?: { + installedSource?: InstalledSource; + installedOnly?: boolean; + cwd?: string; + }, +): SkillMatch | { conflict: true; sources: InstalledSource[] } | null { const installedSource = options?.installedSource; if (!options?.installedOnly) { // Check project commands → project skills → global commands → installed - const projectPath = path.join(getProjectCommandsDir(options?.cwd), `${name}.md`); + const projectPath = path.join( + getProjectCommandsDir(options?.cwd), + `${name}.md`, + ); if (fs.existsSync(projectPath)) { return { filePath: projectPath, source: "project" }; } // Check project-level .claude/skills/{name}/SKILL.md - const projectSkillPath = path.join(getProjectSkillsDir(options?.cwd), name, "SKILL.md"); + const projectSkillPath = path.join( + getProjectSkillsDir(options?.cwd), + name, + "SKILL.md", + ); if (fs.existsSync(projectSkillPath)) { return { filePath: projectSkillPath, source: "project" }; } @@ -177,11 +185,17 @@ function findSkillFile( const projectSkillsDir = getProjectSkillsDir(options?.cwd); if (fs.existsSync(projectSkillsDir)) { try { - const entries = fs.readdirSync(projectSkillsDir, { withFileTypes: true }); + const entries = fs.readdirSync(projectSkillsDir, { + withFileTypes: true, + }); for (const entry of entries) { if (!entry.isDirectory() || entry.name.startsWith(".")) continue; if (entry.name === name) continue; // already checked above - const skillMdPath = path.join(projectSkillsDir, entry.name, "SKILL.md"); + const skillMdPath = path.join( + projectSkillsDir, + entry.name, + "SKILL.md", + ); if (!fs.existsSync(skillMdPath)) continue; const skillContent = fs.readFileSync(skillMdPath, "utf-8"); const meta = parseSkillFrontMatter(skillContent); @@ -227,7 +241,7 @@ function findSkillFile( return { conflict: true, sources: Array.from( - new Set(installedMatches.map((m) => m.installedSource)) + new Set(installedMatches.map((m) => m.installedSource)), ), }; } @@ -237,7 +251,7 @@ function findSkillFile( export async function GET( _request: Request, - { params }: { params: Promise<{ name: string }> } + { params }: { params: Promise<{ name: string }> }, ) { try { const { name } = await params; @@ -251,17 +265,24 @@ export async function GET( if (sourceParam && !installedSource) { return NextResponse.json( { error: "Invalid source; expected 'agents' or 'claude'" }, - { status: 400 } + { status: 400 }, ); } const found = installedSource - ? findSkillFile(name, { installedSource, installedOnly: true, cwd: cwdParam }) + ? findSkillFile(name, { + installedSource, + installedOnly: true, + cwd: cwdParam, + }) : findSkillFile(name, { cwd: cwdParam }); if (found && "conflict" in found) { return NextResponse.json( - { error: "Multiple skills with different content", sources: found.sources }, - { status: 409 } + { + error: "Multiple skills with different content", + sources: found.sources, + }, + { status: 409 }, ); } if (!found) { @@ -274,7 +295,11 @@ export async function GET( if (found.filePath.endsWith("SKILL.md")) { const meta = parseSkillFrontMatter(content); - description = meta.description || (firstLine.startsWith("#") ? firstLine.replace(/^#+\s*/, "") : firstLine || `Skill: /${name}`); + description = + meta.description || + (firstLine.startsWith("#") + ? firstLine.replace(/^#+\s*/, "") + : firstLine || `Skill: /${name}`); } else { description = firstLine.startsWith("#") ? firstLine.replace(/^#+\s*/, "") @@ -293,15 +318,17 @@ export async function GET( }); } catch (error) { return NextResponse.json( - { error: error instanceof Error ? error.message : "Failed to read skill" }, - { status: 500 } + { + error: error instanceof Error ? error.message : "Failed to read skill", + }, + { status: 500 }, ); } } export async function PUT( request: Request, - { params }: { params: Promise<{ name: string }> } + { params }: { params: Promise<{ name: string }> }, ) { try { const { name } = await params; @@ -318,17 +345,24 @@ export async function PUT( if (sourceParam && !installedSource) { return NextResponse.json( { error: "Invalid source; expected 'agents' or 'claude'" }, - { status: 400 } + { status: 400 }, ); } const found = installedSource - ? findSkillFile(name, { installedSource, installedOnly: true, cwd: cwdParam }) + ? findSkillFile(name, { + installedSource, + installedOnly: true, + cwd: cwdParam, + }) : findSkillFile(name, { cwd: cwdParam }); if (found && "conflict" in found) { return NextResponse.json( - { error: "Multiple skills with different content", sources: found.sources }, - { status: 409 } + { + error: "Multiple skills with different content", + sources: found.sources, + }, + { status: 409 }, ); } if (!found) { @@ -354,15 +388,18 @@ export async function PUT( }); } catch (error) { return NextResponse.json( - { error: error instanceof Error ? error.message : "Failed to update skill" }, - { status: 500 } + { + error: + error instanceof Error ? error.message : "Failed to update skill", + }, + { status: 500 }, ); } } export async function DELETE( _request: Request, - { params }: { params: Promise<{ name: string }> } + { params }: { params: Promise<{ name: string }> }, ) { try { const { name } = await params; @@ -376,17 +413,24 @@ export async function DELETE( if (sourceParam && !installedSource) { return NextResponse.json( { error: "Invalid source; expected 'agents' or 'claude'" }, - { status: 400 } + { status: 400 }, ); } const found = installedSource - ? findSkillFile(name, { installedSource, installedOnly: true, cwd: cwdParam }) + ? findSkillFile(name, { + installedSource, + installedOnly: true, + cwd: cwdParam, + }) : findSkillFile(name, { cwd: cwdParam }); if (found && "conflict" in found) { return NextResponse.json( - { error: "Multiple skills with different content", sources: found.sources }, - { status: 409 } + { + error: "Multiple skills with different content", + sources: found.sources, + }, + { status: 409 }, ); } if (!found) { @@ -397,8 +441,11 @@ export async function DELETE( return NextResponse.json({ success: true }); } catch (error) { return NextResponse.json( - { error: error instanceof Error ? error.message : "Failed to delete skill" }, - { status: 500 } + { + error: + error instanceof Error ? error.message : "Failed to delete skill", + }, + { status: 500 }, ); } } diff --git a/src/app/api/skills/route.ts b/src/app/api/skills/route.ts index a1e40114..b563d858 100644 --- a/src/app/api/skills/route.ts +++ b/src/app/api/skills/route.ts @@ -3,6 +3,11 @@ import fs from "fs"; import path from "path"; import os from "os"; import crypto from "crypto"; +import { + getClaudeCommandsDir, + getClaudePluginsDir, + getClaudeSkillsDir, +} from "@/lib/cli-config"; interface SkillFile { name: string; @@ -14,10 +19,13 @@ interface SkillFile { } type InstalledSource = "agents" | "claude"; -type InstalledSkill = SkillFile & { installedSource: InstalledSource; contentHash: string }; +type InstalledSkill = SkillFile & { + installedSource: InstalledSource; + contentHash: string; +}; function getGlobalCommandsDir(): string { - return path.join(os.homedir(), ".claude", "commands"); + return getClaudeCommandsDir(); } function getProjectCommandsDir(cwd?: string): string { @@ -30,7 +38,7 @@ function getProjectSkillsDir(cwd?: string): string { function getPluginCommandsDirs(): string[] { const dirs: string[] = []; - const marketplacesDir = path.join(os.homedir(), ".claude", "plugins", "marketplaces"); + const marketplacesDir = path.join(getClaudePluginsDir(), "marketplaces"); if (!fs.existsSync(marketplacesDir)) return dirs; try { @@ -57,10 +65,6 @@ function getInstalledSkillsDir(): string { return path.join(os.homedir(), ".agents", "skills"); } -function getClaudeSkillsDir(): string { - return path.join(os.homedir(), ".claude", "skills"); -} - /** * Scan project-level skills from .claude/skills/{name}/SKILL.md. * Each subdirectory may contain a SKILL.md with optional YAML front matter. @@ -103,7 +107,10 @@ function computeContentHash(content: string): string { * Parse YAML front matter from SKILL.md content. * Extracts `name` and `description` fields from the --- delimited block. */ -function parseSkillFrontMatter(content: string): { name?: string; description?: string } { +function parseSkillFrontMatter(content: string): { + name?: string; + description?: string; +} { // Extract front matter between --- delimiters const fmMatch = content.match(/^---\r?\n([\s\S]+?)\r?\n---/); if (!fmMatch) return {}; @@ -154,7 +161,7 @@ function parseSkillFrontMatter(content: string): { name?: string; description?: */ function scanInstalledSkills( dir: string, - installedSource: InstalledSource + installedSource: InstalledSource, ): InstalledSkill[] { const skills: InstalledSkill[] = []; if (!fs.existsSync(dir)) return skills; @@ -191,7 +198,7 @@ function scanInstalledSkills( function resolveInstalledSkills( agentsSkills: InstalledSkill[], claudeSkills: InstalledSkill[], - preferredSource: InstalledSource + preferredSource: InstalledSource, ): SkillFile[] { const all = [...agentsSkills, ...claudeSkills]; const byName = new Map(); @@ -228,7 +235,7 @@ function resolveInstalledSkills( function scanDirectory( dir: string, source: "global" | "project" | "plugin", - prefix = "" + prefix = "", ): SkillFile[] { const skills: SkillFile[] = []; if (!fs.existsSync(dir)) return skills; @@ -271,8 +278,12 @@ export async function GET(request: NextRequest) { const globalDir = getGlobalCommandsDir(); const projectDir = getProjectCommandsDir(cwd); - console.log(`[skills] Scanning global: ${globalDir} (exists: ${fs.existsSync(globalDir)})`); - console.log(`[skills] Scanning project: ${projectDir} (exists: ${fs.existsSync(projectDir)})`); + 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()}`); const globalSkills = scanDirectory(globalDir, "global"); @@ -280,20 +291,28 @@ export async function GET(request: NextRequest) { // Scan project-level skills (.claude/skills/*/SKILL.md) const projectSkillsDir = getProjectSkillsDir(cwd); - console.log(`[skills] Scanning project skills: ${projectSkillsDir} (exists: ${fs.existsSync(projectSkillsDir)})`); + console.log( + `[skills] Scanning project skills: ${projectSkillsDir} (exists: ${fs.existsSync(projectSkillsDir)})`, + ); const projectLevelSkills = scanProjectSkills(projectSkillsDir); - console.log(`[skills] Found ${projectLevelSkills.length} project-level skills`); + console.log( + `[skills] Found ${projectLevelSkills.length} project-level skills`, + ); // Deduplicate: project commands take priority over project skills with the same name const projectCommandNames = new Set(projectSkills.map((s) => s.name)); const dedupedProjectSkills = projectLevelSkills.filter( - (s) => !projectCommandNames.has(s.name) + (s) => !projectCommandNames.has(s.name), ); const agentsSkillsDir = getInstalledSkillsDir(); const claudeSkillsDir = getClaudeSkillsDir(); - console.log(`[skills] Scanning installed: ${agentsSkillsDir} (exists: ${fs.existsSync(agentsSkillsDir)})`); - console.log(`[skills] Scanning installed: ${claudeSkillsDir} (exists: ${fs.existsSync(claudeSkillsDir)})`); + console.log( + `[skills] Scanning installed: ${agentsSkillsDir} (exists: ${fs.existsSync(agentsSkillsDir)})`, + ); + console.log( + `[skills] Scanning installed: ${claudeSkillsDir} (exists: ${fs.existsSync(claudeSkillsDir)})`, + ); const agentsSkills = scanInstalledSkills(agentsSkillsDir, "agents"); const claudeSkills = scanInstalledSkills(claudeSkillsDir, "claude"); const preferredInstalledSource: InstalledSource = @@ -303,12 +322,12 @@ export async function GET(request: NextRequest) { ? "agents" : "claude"; console.log( - `[skills] Installed counts: agents=${agentsSkills.length}, claude=${claudeSkills.length}, preferred=${preferredInstalledSource}` + `[skills] Installed counts: agents=${agentsSkills.length}, claude=${claudeSkills.length}, preferred=${preferredInstalledSource}`, ); const installedSkills = resolveInstalledSkills( agentsSkills, claudeSkills, - preferredInstalledSource + preferredInstalledSource, ); // Scan installed plugin skills @@ -317,15 +336,25 @@ export async function GET(request: NextRequest) { pluginSkills.push(...scanDirectory(dir, "plugin")); } - const all = [...globalSkills, ...projectSkills, ...dedupedProjectSkills, ...installedSkills, ...pluginSkills]; - console.log(`[skills] Found: global=${globalSkills.length}, project=${projectSkills.length}, projectSkills=${dedupedProjectSkills.length}, installed=${installedSkills.length}, plugin=${pluginSkills.length}`); + const all = [ + ...globalSkills, + ...projectSkills, + ...dedupedProjectSkills, + ...installedSkills, + ...pluginSkills, + ]; + console.log( + `[skills] Found: global=${globalSkills.length}, project=${projectSkills.length}, projectSkills=${dedupedProjectSkills.length}, installed=${installedSkills.length}, plugin=${pluginSkills.length}`, + ); return NextResponse.json({ skills: all }); } catch (error) { - console.error('[skills] Error:', error); + console.error("[skills] Error:", error); return NextResponse.json( - { error: error instanceof Error ? error.message : "Failed to load skills" }, - { status: 500 } + { + error: error instanceof Error ? error.message : "Failed to load skills", + }, + { status: 500 }, ); } } @@ -343,7 +372,7 @@ export async function POST(request: Request) { if (!name || typeof name !== "string") { return NextResponse.json( { error: "Skill name is required" }, - { status: 400 } + { status: 400 }, ); } @@ -352,7 +381,7 @@ export async function POST(request: Request) { if (!safeName) { return NextResponse.json( { error: "Invalid skill name" }, - { status: 400 } + { status: 400 }, ); } @@ -367,7 +396,7 @@ export async function POST(request: Request) { if (fs.existsSync(filePath)) { return NextResponse.json( { error: "A skill with this name already exists" }, - { status: 409 } + { status: 409 }, ); } @@ -388,12 +417,15 @@ export async function POST(request: Request) { filePath, }, }, - { status: 201 } + { status: 201 }, ); } catch (error) { return NextResponse.json( - { error: error instanceof Error ? error.message : "Failed to create skill" }, - { status: 500 } + { + error: + error instanceof Error ? error.message : "Failed to create skill", + }, + { status: 500 }, ); } } diff --git a/src/components/settings/GeneralSection.tsx b/src/components/settings/GeneralSection.tsx index 41a2d610..eeaa2691 100644 --- a/src/components/settings/GeneralSection.tsx +++ b/src/components/settings/GeneralSection.tsx @@ -18,40 +18,62 @@ import { ReloadIcon, Loading02Icon } from "@hugeicons/core-free-icons"; import { useUpdate } from "@/hooks/useUpdate"; import { useTranslation } from "@/hooks/useTranslation"; import { SUPPORTED_LOCALES, type Locale } from "@/i18n"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Input } from "@/components/ui/input"; function UpdateCard() { - const { updateInfo, checking, checkForUpdates, downloadUpdate, quitAndInstall, setShowDialog } = useUpdate(); + const { + updateInfo, + checking, + checkForUpdates, + downloadUpdate, + quitAndInstall, + setShowDialog, + } = useUpdate(); const { t } = useTranslation(); const currentVersion = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0"; - const isDownloading = updateInfo?.isNativeUpdate && !updateInfo.readyToInstall - && updateInfo.downloadProgress != null; + const isDownloading = + updateInfo?.isNativeUpdate && + !updateInfo.readyToInstall && + updateInfo.downloadProgress != null; return (
-

{t('settings.codepilot')}

-

{t('settings.version', { version: currentVersion })}

+

{t("settings.codepilot")}

+

+ {t("settings.version", { version: currentVersion })} +

{/* Show install/restart button when update available */} - {updateInfo?.updateAvailable && !checking && ( - updateInfo.readyToInstall ? ( + {updateInfo?.updateAvailable && + !checking && + (updateInfo.readyToInstall ? ( ) : updateInfo.isNativeUpdate && !isDownloading ? ( ) : !updateInfo.isNativeUpdate ? ( - - ) : null - )} + ) : null)}
@@ -74,13 +99,19 @@ function UpdateCard() { {updateInfo.updateAvailable ? (
- + {updateInfo.readyToInstall - ? t('update.readyToInstall', { version: updateInfo.latestVersion }) + ? t("update.readyToInstall", { + version: updateInfo.latestVersion, + }) : isDownloading - ? `${t('update.downloading')} ${Math.round(updateInfo.downloadProgress!)}%` - : t('settings.updateAvailable', { version: updateInfo.latestVersion })} + ? `${t("update.downloading")} ${Math.round(updateInfo.downloadProgress!)}%` + : t("settings.updateAvailable", { + version: updateInfo.latestVersion, + })} {updateInfo.releaseNotes && ( )}
@@ -98,7 +129,9 @@ function UpdateCard() {
)} @@ -109,7 +142,9 @@ function UpdateCard() { )}
) : ( -

{t('settings.latestVersion')}

+

+ {t("settings.latestVersion")} +

)}
)} @@ -121,6 +156,16 @@ export function GeneralSection() { const [skipPermissions, setSkipPermissions] = useState(false); const [showSkipPermWarning, setShowSkipPermWarning] = useState(false); const [skipPermSaving, setSkipPermSaving] = useState(false); + const [cliPath, setCliPath] = useState(""); + const [cliPathSaving, setCliPathSaving] = useState(false); + const [cliPathStatus, setCliPathStatus] = useState<"" | "saved" | "invalid">( + "", + ); + const [configDir, setConfigDir] = useState(""); + const [configDirSaving, setConfigDirSaving] = useState(false); + const [configDirStatus, setConfigDirStatus] = useState< + "" | "saved" | "invalid" + >(""); const { t, locale, setLocale } = useTranslation(); const fetchAppSettings = useCallback(async () => { @@ -130,6 +175,8 @@ export function GeneralSection() { const data = await res.json(); const appSettings = data.settings || {}; setSkipPermissions(appSettings.dangerously_skip_permissions === "true"); + setCliPath(appSettings.claude_cli_path || ""); + setConfigDir(appSettings.claude_config_dir || ""); } } catch { // ignore @@ -169,17 +216,77 @@ export function GeneralSection() { } }; + const saveCliPath = async () => { + setCliPathSaving(true); + setCliPathStatus(""); + try { + const res = await fetch("/api/settings/app", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + settings: { claude_cli_path: cliPath.trim() }, + }), + }); + if (res.ok) { + const data = await res.json(); + const v = data.validation?.claude_cli_path; + if (!cliPath.trim() || v?.valid) { + setCliPathStatus("saved"); + setTimeout(() => setCliPathStatus(""), 3000); + } else { + setCliPathStatus("invalid"); + } + } + } catch { + // ignore + } finally { + setCliPathSaving(false); + } + }; + + const saveConfigDir = async () => { + setConfigDirSaving(true); + setConfigDirStatus(""); + try { + const res = await fetch("/api/settings/app", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + settings: { claude_config_dir: configDir.trim() }, + }), + }); + if (res.ok) { + const data = await res.json(); + const v = data.validation?.claude_config_dir; + if (!configDir.trim() || v?.valid) { + setConfigDirStatus("saved"); + setTimeout(() => setConfigDirStatus(""), 3000); + } else { + setConfigDirStatus("invalid"); + } + } + } catch { + // ignore + } finally { + setConfigDirSaving(false); + } + }; + return (
{/* Auto-approve toggle */} -
+
-

{t('settings.autoApproveTitle')}

+

+ {t("settings.autoApproveTitle")} +

- {t('settings.autoApproveDesc')} + {t("settings.autoApproveDesc")}

- {t('settings.autoApproveWarning')} + {t("settings.autoApproveWarning")}
)}
@@ -200,8 +307,10 @@ export function GeneralSection() {
-

{t('settings.language')}

-

{t('settings.languageDesc')}

+

{t("settings.language")}

+

+ {t("settings.languageDesc")} +

+ {/* Claude CLI path */} +
+
+

{t("settings.cliPathTitle")}

+

+ {t("settings.cliPathDesc")} +

+
+
+ { + setCliPath(e.target.value); + setCliPathStatus(""); + }} + placeholder={t("settings.cliPathPlaceholder")} + className="flex-1 font-mono text-xs" + /> + +
+ {cliPathStatus === "saved" && ( +

+ {t("settings.cliPathSaved")} +

+ )} + {cliPathStatus === "invalid" && ( +

+ {t("settings.cliPathInvalid")} +

+ )} +
+ + {/* Claude config directory */} +
+
+

+ {t("settings.configDirTitle")} +

+

+ {t("settings.configDirDesc")} +

+
+
+ { + setConfigDir(e.target.value); + setConfigDirStatus(""); + }} + placeholder={t("settings.configDirPlaceholder")} + className="flex-1 font-mono text-xs" + /> + +
+ {configDirStatus === "saved" && ( +

+ {t("settings.configDirSaved")} +

+ )} + {configDirStatus === "invalid" && ( +

+ {t("settings.configDirInvalid")} +

+ )} +
+ {/* Skip-permissions warning dialog */} - + - {t('settings.autoApproveDialogTitle')} + + {t("settings.autoApproveDialogTitle")} +
-

- {t('settings.autoApproveDialogDesc')} -

+

{t("settings.autoApproveDialogDesc")}

    -
  • {t('settings.autoApproveShellCommands')}
  • -
  • {t('settings.autoApproveFileOps')}
  • -
  • {t('settings.autoApproveNetwork')}
  • +
  • {t("settings.autoApproveShellCommands")}
  • +
  • {t("settings.autoApproveFileOps")}
  • +
  • {t("settings.autoApproveNetwork")}

- {t('settings.autoApproveTrustWarning')} + {t("settings.autoApproveTrustWarning")}

- {t('settings.cancel')} + {t("settings.cancel")} saveSkipPermissions(true)} className="bg-orange-600 hover:bg-orange-700 text-white" > - {t('settings.enableAutoApprove')} + {t("settings.enableAutoApprove")}
diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 79e59448..712386fd 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -5,625 +5,700 @@ */ const en = { // ── Navigation ────────────────────────────────────────────── - 'nav.chats': 'Chats', - 'nav.extensions': 'Extensions', - 'nav.settings': 'Settings', - 'nav.autoApproveOn': 'Auto-approve is ON', - 'nav.lightMode': 'Light mode', - 'nav.darkMode': 'Dark mode', - 'nav.toggleTheme': 'Toggle theme', - 'nav.bridge': 'Bridge', + "nav.chats": "Chats", + "nav.extensions": "Extensions", + "nav.settings": "Settings", + "nav.autoApproveOn": "Auto-approve is ON", + "nav.lightMode": "Light mode", + "nav.darkMode": "Dark mode", + "nav.toggleTheme": "Toggle theme", + "nav.bridge": "Bridge", // ── Chat list panel ───────────────────────────────────────── - 'chatList.justNow': 'just now', - 'chatList.minutesAgo': '{n}m', - 'chatList.hoursAgo': '{n}h', - 'chatList.daysAgo': '{n}d', - 'chatList.newConversation': 'New Conversation', - 'chatList.delete': 'Delete', - 'chatList.searchSessions': 'Search sessions...', - 'chatList.noSessions': 'No sessions yet', - 'chatList.importFromCli': 'Import from Claude Code', - 'chatList.addProjectFolder': 'Add project folder', - 'chatList.threads': 'Threads', + "chatList.justNow": "just now", + "chatList.minutesAgo": "{n}m", + "chatList.hoursAgo": "{n}h", + "chatList.daysAgo": "{n}d", + "chatList.newConversation": "New Conversation", + "chatList.delete": "Delete", + "chatList.searchSessions": "Search sessions...", + "chatList.noSessions": "No sessions yet", + "chatList.importFromCli": "Import from Claude Code", + "chatList.addProjectFolder": "Add project folder", + "chatList.threads": "Threads", // ── Message list ──────────────────────────────────────────── - 'messageList.claudeChat': 'CodePilot Chat', - 'messageList.emptyDescription': 'Start a conversation with CodePilot. Ask questions, get help with code, or explore ideas.', - 'messageList.loadEarlier': 'Load earlier messages', - 'messageList.loading': 'Loading...', + "messageList.claudeChat": "CodePilot Chat", + "messageList.emptyDescription": + "Start a conversation with CodePilot. Ask questions, get help with code, or explore ideas.", + "messageList.loadEarlier": "Load earlier messages", + "messageList.loading": "Loading...", // ── Message input ─────────────────────────────────────────── - 'messageInput.attachFiles': 'Attach files', - 'messageInput.helpDesc': 'Show available commands and tips', - 'messageInput.clearDesc': 'Clear conversation history', - 'messageInput.costDesc': 'Show token usage statistics', - 'messageInput.compactDesc': 'Compress conversation context', - 'messageInput.doctorDesc': 'Diagnose project health', - 'messageInput.initDesc': 'Initialize CLAUDE.md for project', - 'messageInput.reviewDesc': 'Review code quality', - 'messageInput.terminalSetupDesc': 'Configure terminal settings', - 'messageInput.memoryDesc': 'Edit project memory file', - 'messageInput.modeCode': 'Code', - 'messageInput.modePlan': 'Plan', - 'messageInput.aiSuggested': 'AI Suggested', + "messageInput.attachFiles": "Attach files", + "messageInput.helpDesc": "Show available commands and tips", + "messageInput.clearDesc": "Clear conversation history", + "messageInput.costDesc": "Show token usage statistics", + "messageInput.compactDesc": "Compress conversation context", + "messageInput.doctorDesc": "Diagnose project health", + "messageInput.initDesc": "Initialize CLAUDE.md for project", + "messageInput.reviewDesc": "Review code quality", + "messageInput.terminalSetupDesc": "Configure terminal settings", + "messageInput.memoryDesc": "Edit project memory file", + "messageInput.modeCode": "Code", + "messageInput.modePlan": "Plan", + "messageInput.aiSuggested": "AI Suggested", // ── Streaming message ─────────────────────────────────────── - 'streaming.thinking': 'Thinking...', - 'streaming.allowForSession': 'Allow for Session', - 'streaming.allowed': 'Allowed', - 'streaming.denied': 'Denied', + "streaming.thinking": "Thinking...", + "streaming.allowForSession": "Allow for Session", + "streaming.allowed": "Allowed", + "streaming.denied": "Denied", // ── Chat view / session page ──────────────────────────────── - 'chat.newConversation': 'New Conversation', + "chat.newConversation": "New Conversation", // ── Settings: General ─────────────────────────────────────── - 'settings.title': 'Settings', - 'settings.description': 'Manage CodePilot and Claude CLI settings', - 'settings.general': 'General', - 'settings.providers': 'Providers', - 'settings.claudeCli': 'Claude CLI', - 'settings.codepilot': 'CodePilot', - 'settings.version': 'Version {version}', - 'settings.checkForUpdates': 'Check for Updates', - 'settings.checking': 'Checking...', - 'settings.updateAvailable': 'Update available: v{version}', - 'settings.viewRelease': 'View Release', - 'settings.latestVersion': "You're on the latest version.", - 'settings.autoApproveTitle': 'Auto-approve All Actions', - 'settings.autoApproveDesc': 'Skip all permission checks and auto-approve every tool action. This is dangerous and should only be used for trusted tasks.', - 'settings.autoApproveWarning': 'All tool actions will be auto-approved without confirmation. Use with caution.', - 'settings.autoApproveDialogTitle': 'Enable Auto-approve All Actions?', - 'settings.autoApproveDialogDesc': 'This will bypass all permission checks. Claude will be able to execute any tool action without asking for your confirmation, including:', - 'settings.autoApproveShellCommands': 'Running arbitrary shell commands', - 'settings.autoApproveFileOps': 'Reading, writing, and deleting files', - 'settings.autoApproveNetwork': 'Making network requests', - 'settings.autoApproveTrustWarning': 'Only enable this if you fully trust the task at hand. This setting applies to all new chat sessions.', - 'settings.cancel': 'Cancel', - 'settings.enableAutoApprove': 'Enable Auto-approve', - 'settings.language': 'Language', - 'settings.languageDesc': 'Choose the display language for the interface', - 'settings.usage': 'Usage', + "settings.title": "Settings", + "settings.description": "Manage CodePilot and Claude CLI settings", + "settings.general": "General", + "settings.providers": "Providers", + "settings.claudeCli": "Claude CLI", + "settings.codepilot": "CodePilot", + "settings.version": "Version {version}", + "settings.checkForUpdates": "Check for Updates", + "settings.checking": "Checking...", + "settings.updateAvailable": "Update available: v{version}", + "settings.viewRelease": "View Release", + "settings.latestVersion": "You're on the latest version.", + "settings.autoApproveTitle": "Auto-approve All Actions", + "settings.autoApproveDesc": + "Skip all permission checks and auto-approve every tool action. This is dangerous and should only be used for trusted tasks.", + "settings.autoApproveWarning": + "All tool actions will be auto-approved without confirmation. Use with caution.", + "settings.autoApproveDialogTitle": "Enable Auto-approve All Actions?", + "settings.autoApproveDialogDesc": + "This will bypass all permission checks. Claude will be able to execute any tool action without asking for your confirmation, including:", + "settings.autoApproveShellCommands": "Running arbitrary shell commands", + "settings.autoApproveFileOps": "Reading, writing, and deleting files", + "settings.autoApproveNetwork": "Making network requests", + "settings.autoApproveTrustWarning": + "Only enable this if you fully trust the task at hand. This setting applies to all new chat sessions.", + "settings.cancel": "Cancel", + "settings.enableAutoApprove": "Enable Auto-approve", + "settings.language": "Language", + "settings.languageDesc": "Choose the display language for the interface", + "settings.cliPathTitle": "Claude CLI Path", + "settings.cliPathDesc": + "Custom path to Claude CLI executable. Leave empty to auto-detect.", + "settings.cliPathPlaceholder": "Auto-detect (default)", + "settings.cliPathSaved": "CLI path saved", + "settings.cliPathInvalid": + "File not found at this path. Setting saved but will fall back to auto-detect.", + "settings.configDirTitle": "Claude Config Directory", + "settings.configDirDesc": + "Custom path to Claude config directory (e.g. ~/.claude-internal). Leave empty for ~/.claude.", + "settings.configDirPlaceholder": "~/.claude (default)", + "settings.configDirSaved": "Config directory saved", + "settings.configDirInvalid": + "Directory not found at this path. Setting saved but will fall back to ~/.claude.", + "settings.usage": "Usage", // ── Settings: Usage Stats ─────────────────────────────────── - 'usage.totalTokens': 'Total Tokens', - 'usage.totalCost': 'Total Cost', - 'usage.sessions': 'Sessions', - 'usage.cacheHitRate': 'Cache Hit Rate', - 'usage.input': 'In', - 'usage.output': 'Out', - 'usage.cached': 'cached', - 'usage.dailyChart': 'Daily Token Usage', - 'usage.loading': 'Loading...', - 'usage.loadError': 'Failed to load usage stats', - 'usage.noData': 'No usage data yet', - 'usage.noDataHint': 'Start a conversation to see statistics here.', + "usage.totalTokens": "Total Tokens", + "usage.totalCost": "Total Cost", + "usage.sessions": "Sessions", + "usage.cacheHitRate": "Cache Hit Rate", + "usage.input": "In", + "usage.output": "Out", + "usage.cached": "cached", + "usage.dailyChart": "Daily Token Usage", + "usage.loading": "Loading...", + "usage.loadError": "Failed to load usage stats", + "usage.noData": "No usage data yet", + "usage.noDataHint": "Start a conversation to see statistics here.", // ── Settings: CLI ─────────────────────────────────────────── - 'cli.permissions': 'Permissions', - 'cli.permissionsDesc': 'Configure permission settings for Claude CLI', - 'cli.envVars': 'Environment Variables', - 'cli.envVarsDesc': 'Environment variables passed to Claude', - 'cli.form': 'Form', - 'cli.json': 'JSON', - 'cli.save': 'Save', - 'cli.format': 'Format', - 'cli.reset': 'Reset', - 'cli.settingsSaved': 'Settings saved successfully', - 'cli.confirmSaveTitle': 'Confirm Save', - 'cli.confirmSaveDesc': 'This will overwrite your current ~/.claude/settings.json file. Are you sure you want to continue?', + "cli.permissions": "Permissions", + "cli.permissionsDesc": "Configure permission settings for Claude CLI", + "cli.envVars": "Environment Variables", + "cli.envVarsDesc": "Environment variables passed to Claude", + "cli.form": "Form", + "cli.json": "JSON", + "cli.save": "Save", + "cli.format": "Format", + "cli.reset": "Reset", + "cli.settingsSaved": "Settings saved successfully", + "cli.confirmSaveTitle": "Confirm Save", + "cli.confirmSaveDesc": + "This will overwrite your current ~/.claude/settings.json file. Are you sure you want to continue?", // ── Settings: Providers ───────────────────────────────────── - 'provider.addProvider': 'Add Provider', - 'provider.editProvider': 'Edit Provider', - 'provider.deleteProvider': 'Delete Provider', - 'provider.deleteConfirm': 'Are you sure you want to delete "{name}"? This action cannot be undone.', - 'provider.deleting': 'Deleting...', - 'provider.delete': 'Delete', - 'provider.noProviders': 'No providers configured', - 'provider.addToStart': 'Add a provider to get started', - 'provider.quickPresets': 'Quick Presets', - 'provider.customProviders': 'Custom Providers', - 'provider.active': 'Active', - 'provider.configured': 'Configured', - 'provider.name': 'Name', - 'provider.providerType': 'Provider Type', - 'provider.apiKey': 'API Key', - 'provider.baseUrl': 'Base URL', - 'provider.modelName': 'Model Name', - 'provider.advancedOptions': 'Advanced Options', - 'provider.extraEnvVars': 'Extra Environment Variables', - 'provider.notes': 'Notes', - 'provider.notesPlaceholder': 'Optional notes about this provider...', - 'provider.saving': 'Saving...', - 'provider.update': 'Update', - 'provider.envDetected': 'Detected from environment', - 'provider.default': 'Default', - 'provider.setDefault': 'Set as Default', - 'provider.environment': 'Environment', - 'provider.connectedProviders': 'Connected Providers', - 'provider.noConnected': 'No providers connected yet', - 'provider.connect': 'Connect', - 'provider.disconnect': 'Disconnect', - 'provider.disconnecting': 'Disconnecting...', - 'provider.disconnectProvider': 'Disconnect Provider', - 'provider.disconnectConfirm': 'Are you sure you want to disconnect "{name}"? This action cannot be undone.', - 'provider.ccSwitchHint': 'Claude Code configurations added via tools like cc switch may not be readable by CodePilot. We recommend re-adding your provider here.', - 'provider.addProviderSection': 'Add Provider', - 'provider.addProviderDesc': 'Select a provider to connect. Most presets only require an API key.', + "provider.addProvider": "Add Provider", + "provider.editProvider": "Edit Provider", + "provider.deleteProvider": "Delete Provider", + "provider.deleteConfirm": + 'Are you sure you want to delete "{name}"? This action cannot be undone.', + "provider.deleting": "Deleting...", + "provider.delete": "Delete", + "provider.noProviders": "No providers configured", + "provider.addToStart": "Add a provider to get started", + "provider.quickPresets": "Quick Presets", + "provider.customProviders": "Custom Providers", + "provider.active": "Active", + "provider.configured": "Configured", + "provider.name": "Name", + "provider.providerType": "Provider Type", + "provider.apiKey": "API Key", + "provider.baseUrl": "Base URL", + "provider.modelName": "Model Name", + "provider.advancedOptions": "Advanced Options", + "provider.extraEnvVars": "Extra Environment Variables", + "provider.notes": "Notes", + "provider.notesPlaceholder": "Optional notes about this provider...", + "provider.saving": "Saving...", + "provider.update": "Update", + "provider.envDetected": "Detected from environment", + "provider.default": "Default", + "provider.setDefault": "Set as Default", + "provider.environment": "Environment", + "provider.connectedProviders": "Connected Providers", + "provider.noConnected": "No providers connected yet", + "provider.connect": "Connect", + "provider.disconnect": "Disconnect", + "provider.disconnecting": "Disconnecting...", + "provider.disconnectProvider": "Disconnect Provider", + "provider.disconnectConfirm": + 'Are you sure you want to disconnect "{name}"? This action cannot be undone.', + "provider.ccSwitchHint": + "Claude Code configurations added via tools like cc switch may not be readable by CodePilot. We recommend re-adding your provider here.", + "provider.addProviderSection": "Add Provider", + "provider.addProviderDesc": + "Select a provider to connect. Most presets only require an API key.", // ── Right panel / Files ───────────────────────────────────── - 'panel.files': 'Files', - 'panel.tasks': 'Tasks', - 'panel.openPanel': 'Open panel', - 'panel.closePanel': 'Close panel', + "panel.files": "Files", + "panel.tasks": "Tasks", + "panel.openPanel": "Open panel", + "panel.closePanel": "Close panel", // ── File tree ─────────────────────────────────────────────── - 'fileTree.filterFiles': 'Filter files...', - 'fileTree.refresh': 'Refresh', - 'fileTree.noFiles': 'No files found', - 'fileTree.selectFolder': 'Select a project folder to view files', + "fileTree.filterFiles": "Filter files...", + "fileTree.refresh": "Refresh", + "fileTree.noFiles": "No files found", + "fileTree.selectFolder": "Select a project folder to view files", // ── File preview ──────────────────────────────────────────── - 'filePreview.backToTree': 'Back to file tree', - 'filePreview.lines': '{count} lines', - 'filePreview.copyPath': 'Copy path', - 'filePreview.failedToLoad': 'Failed to load file', + "filePreview.backToTree": "Back to file tree", + "filePreview.lines": "{count} lines", + "filePreview.copyPath": "Copy path", + "filePreview.failedToLoad": "Failed to load file", // ── Doc preview ───────────────────────────────────────────── - 'docPreview.htmlPreview': 'HTML Preview', + "docPreview.htmlPreview": "HTML Preview", // ── Extensions page ───────────────────────────────────────── - 'extensions.title': 'Extensions', - 'extensions.skills': 'Skills', - 'extensions.mcpServers': 'MCP Servers', + "extensions.title": "Extensions", + "extensions.skills": "Skills", + "extensions.mcpServers": "MCP Servers", // ── Skills ────────────────────────────────────────────────── - 'skills.noSelected': 'No skill selected', - 'skills.selectOrCreate': 'Select a skill from the list or create a new one', - 'skills.newSkill': 'New Skill', - 'skills.loadingSkills': 'Loading skills...', - 'skills.noSkillsFound': 'No skills found', - 'skills.searchSkills': 'Search skills...', - 'skills.createSkill': 'Create Skill', - 'skills.nameRequired': 'Name is required', - 'skills.nameInvalid': 'Name can only contain letters, numbers, hyphens, and underscores', - 'skills.skillName': 'Skill Name', - 'skills.scope': 'Scope', - 'skills.global': 'Global', - 'skills.project': 'Project', - 'skills.template': 'Template', - 'skills.blank': 'Blank', - 'skills.commitHelper': 'Commit Helper', - 'skills.codeReviewer': 'Code Reviewer', - 'skills.saved': 'Saved', - 'skills.save': 'Save', - 'skills.edit': 'Edit', - 'skills.preview': 'Preview', - 'skills.splitView': 'Split view', - 'skills.deleteConfirm': 'Click again to confirm', - 'skills.placeholder': 'Write your skill prompt in Markdown...', - 'skills.mySkills': 'My Skills', - 'skills.marketplace': 'Marketplace', - 'skills.marketplaceSearch': 'Search Skills.sh...', - 'skills.install': 'Install', - 'skills.installed': 'Installed', - 'skills.installing': 'Installing...', - 'skills.uninstall': 'Uninstall', - 'skills.installs': 'installs', - 'skills.source': 'Source', - 'skills.installedAt': 'Installed At', - 'skills.installSuccess': 'Installation Complete', - 'skills.installFailed': 'Installation Failed', - 'skills.searchNoResults': 'No skills found', - 'skills.marketplaceError': 'Failed to load marketplace', - 'skills.marketplaceHint': 'Browse the Skills Marketplace', - 'skills.marketplaceHintDesc': 'Search and install community skills from Skills.sh', - 'skills.noReadme': 'No description available for this skill', + "skills.noSelected": "No skill selected", + "skills.selectOrCreate": "Select a skill from the list or create a new one", + "skills.newSkill": "New Skill", + "skills.loadingSkills": "Loading skills...", + "skills.noSkillsFound": "No skills found", + "skills.searchSkills": "Search skills...", + "skills.createSkill": "Create Skill", + "skills.nameRequired": "Name is required", + "skills.nameInvalid": + "Name can only contain letters, numbers, hyphens, and underscores", + "skills.skillName": "Skill Name", + "skills.scope": "Scope", + "skills.global": "Global", + "skills.project": "Project", + "skills.template": "Template", + "skills.blank": "Blank", + "skills.commitHelper": "Commit Helper", + "skills.codeReviewer": "Code Reviewer", + "skills.saved": "Saved", + "skills.save": "Save", + "skills.edit": "Edit", + "skills.preview": "Preview", + "skills.splitView": "Split view", + "skills.deleteConfirm": "Click again to confirm", + "skills.placeholder": "Write your skill prompt in Markdown...", + "skills.mySkills": "My Skills", + "skills.marketplace": "Marketplace", + "skills.marketplaceSearch": "Search Skills.sh...", + "skills.install": "Install", + "skills.installed": "Installed", + "skills.installing": "Installing...", + "skills.uninstall": "Uninstall", + "skills.installs": "installs", + "skills.source": "Source", + "skills.installedAt": "Installed At", + "skills.installSuccess": "Installation Complete", + "skills.installFailed": "Installation Failed", + "skills.searchNoResults": "No skills found", + "skills.marketplaceError": "Failed to load marketplace", + "skills.marketplaceHint": "Browse the Skills Marketplace", + "skills.marketplaceHintDesc": + "Search and install community skills from Skills.sh", + "skills.noReadme": "No description available for this skill", // ── MCP ───────────────────────────────────────────────────── - 'mcp.addServer': 'Add Server', - 'mcp.loadingServers': 'Loading MCP servers...', - 'mcp.serverConfig': 'MCP Server Configuration', - 'mcp.noServers': 'No MCP servers configured', - 'mcp.noServersDesc': "Add an MCP server to extend Claude's capabilities", - 'mcp.arguments': 'Arguments:', - 'mcp.environment': 'Environment:', - 'mcp.listTab': 'List', - 'mcp.jsonTab': 'JSON Config', - 'mcp.editServer': 'Edit Server', - 'mcp.serverName': 'Server Name', - 'mcp.serverType': 'Server Type', - 'mcp.command': 'Command', - 'mcp.argsLabel': 'Arguments (one per line)', - 'mcp.url': 'URL', - 'mcp.headers': 'Headers (JSON)', - 'mcp.envVars': 'Environment Variables (JSON)', - 'mcp.formTab': 'Form', - 'mcp.jsonEditTab': 'JSON', - 'mcp.saveChanges': 'Save Changes', + "mcp.addServer": "Add Server", + "mcp.loadingServers": "Loading MCP servers...", + "mcp.serverConfig": "MCP Server Configuration", + "mcp.noServers": "No MCP servers configured", + "mcp.noServersDesc": "Add an MCP server to extend Claude's capabilities", + "mcp.arguments": "Arguments:", + "mcp.environment": "Environment:", + "mcp.listTab": "List", + "mcp.jsonTab": "JSON Config", + "mcp.editServer": "Edit Server", + "mcp.serverName": "Server Name", + "mcp.serverType": "Server Type", + "mcp.command": "Command", + "mcp.argsLabel": "Arguments (one per line)", + "mcp.url": "URL", + "mcp.headers": "Headers (JSON)", + "mcp.envVars": "Environment Variables (JSON)", + "mcp.formTab": "Form", + "mcp.jsonEditTab": "JSON", + "mcp.saveChanges": "Save Changes", // ── Folder picker ─────────────────────────────────────────── - 'folderPicker.title': 'Select a project folder', - 'folderPicker.loading': 'Loading...', - 'folderPicker.noSubdirs': 'No subdirectories', - 'folderPicker.cancel': 'Cancel', - 'folderPicker.select': 'Select This Folder', + "folderPicker.title": "Select a project folder", + "folderPicker.loading": "Loading...", + "folderPicker.noSubdirs": "No subdirectories", + "folderPicker.cancel": "Cancel", + "folderPicker.select": "Select This Folder", // ── Import session dialog ─────────────────────────────────── - 'import.title': 'Import Session from Claude CLI', - 'import.searchSessions': 'Search sessions...', - 'import.noSessions': 'No sessions found', - 'import.import': 'Import', - 'import.importing': 'Importing...', - 'import.justNow': 'just now', - 'import.minutesAgo': '{n}m ago', - 'import.hoursAgo': '{n}h ago', - 'import.daysAgo': '{n}d ago', - 'import.messages': '{n} msg', - 'import.messagesPlural': '{n} msgs', + "import.title": "Import Session from Claude CLI", + "import.searchSessions": "Search sessions...", + "import.noSessions": "No sessions found", + "import.import": "Import", + "import.importing": "Importing...", + "import.justNow": "just now", + "import.minutesAgo": "{n}m ago", + "import.hoursAgo": "{n}h ago", + "import.daysAgo": "{n}d ago", + "import.messages": "{n} msg", + "import.messagesPlural": "{n} msgs", // ── Connection status ─────────────────────────────────────── - 'connection.notInstalled': 'Claude Code is not installed', - 'connection.installed': 'Claude Code is installed', - 'connection.version': 'Version: {version}', - 'connection.installPrompt': 'To use Claude Code features, you need to install the Claude Code CLI.', - 'connection.runCommand': 'Run the following command in your terminal:', - 'connection.installAuto': 'Install Claude Code Automatically', - 'connection.refresh': 'Refresh', - 'connection.installClaude': 'Install Claude Code', - 'connection.connected': 'Connected', - 'connection.disconnected': 'Disconnected', - 'connection.checking': 'Checking', + "connection.notInstalled": "Claude Code is not installed", + "connection.installed": "Claude Code is installed", + "connection.version": "Version: {version}", + "connection.installPrompt": + "To use Claude Code features, you need to install the Claude Code CLI.", + "connection.runCommand": "Run the following command in your terminal:", + "connection.installAuto": "Install Claude Code Automatically", + "connection.refresh": "Refresh", + "connection.installClaude": "Install Claude Code", + "connection.connected": "Connected", + "connection.disconnected": "Disconnected", + "connection.checking": "Checking", // ── Install wizard ────────────────────────────────────────── - 'install.title': 'Install Claude Code', - 'install.checkingPrereqs': 'Checking prerequisites...', - 'install.alreadyInstalled': 'Claude Code is already installed', - 'install.readyToInstall': 'Ready to install', - 'install.installing': 'Installing Claude Code...', - 'install.complete': 'Installation complete', - 'install.failed': 'Installation failed', - 'install.copyLogs': 'Copy Logs', - 'install.copied': 'Copied', - 'install.install': 'Install', - 'install.cancel': 'Cancel', - 'install.retry': 'Retry', - 'install.done': 'Done', - 'install.recheck': 'Recheck', - 'install.copy': 'Copy', - 'install.homebrewRequired': 'Homebrew is required', - 'install.homebrewDescription': 'Homebrew is the package manager for macOS. Node.js installation requires it.', - 'install.homebrewSteps': 'Follow these steps:', - 'install.homebrewStep1': 'Open Terminal', - 'install.homebrewStep2': 'Paste the command above and press Enter', - 'install.homebrewStep3': 'Follow the on-screen instructions to complete the installation', - 'install.homebrewStep4': 'Come back here and click "Recheck"', + "install.title": "Install Claude Code", + "install.checkingPrereqs": "Checking prerequisites...", + "install.alreadyInstalled": "Claude Code is already installed", + "install.readyToInstall": "Ready to install", + "install.installing": "Installing Claude Code...", + "install.complete": "Installation complete", + "install.failed": "Installation failed", + "install.copyLogs": "Copy Logs", + "install.copied": "Copied", + "install.install": "Install", + "install.cancel": "Cancel", + "install.retry": "Retry", + "install.done": "Done", + "install.recheck": "Recheck", + "install.copy": "Copy", + "install.homebrewRequired": "Homebrew is required", + "install.homebrewDescription": + "Homebrew is the package manager for macOS. Node.js installation requires it.", + "install.homebrewSteps": "Follow these steps:", + "install.homebrewStep1": "Open Terminal", + "install.homebrewStep2": "Paste the command above and press Enter", + "install.homebrewStep3": + "Follow the on-screen instructions to complete the installation", + "install.homebrewStep4": 'Come back here and click "Recheck"', // ── Task list ─────────────────────────────────────────────── - 'tasks.all': 'All', - 'tasks.active': 'Active', - 'tasks.done': 'Done', - 'tasks.addPlaceholder': 'Add a task...', - 'tasks.addTask': 'Add task', - 'tasks.loading': 'Loading tasks...', - 'tasks.noTasks': 'No tasks yet', - 'tasks.noMatching': 'No matching tasks', + "tasks.all": "All", + "tasks.active": "Active", + "tasks.done": "Done", + "tasks.addPlaceholder": "Add a task...", + "tasks.addTask": "Add task", + "tasks.loading": "Loading tasks...", + "tasks.noTasks": "No tasks yet", + "tasks.noMatching": "No matching tasks", // ── Tool call block ───────────────────────────────────────── - 'tool.running': 'running', - 'tool.success': 'success', - 'tool.error': 'error', + "tool.running": "running", + "tool.success": "success", + "tool.error": "error", // ── Common ────────────────────────────────────────────────── - 'common.cancel': 'Cancel', - 'common.save': 'Save', - 'common.delete': 'Delete', - 'common.loading': 'Loading...', - 'common.close': 'Close', - 'common.enabled': 'Enabled', - 'common.disabled': 'Disabled', + "common.cancel": "Cancel", + "common.save": "Save", + "common.delete": "Delete", + "common.loading": "Loading...", + "common.close": "Close", + "common.enabled": "Enabled", + "common.disabled": "Disabled", // ── Error boundary ──────────────────────────────────────── - 'error.title': 'Something went wrong', - 'error.description': 'An unexpected error occurred. You can try again or reload the app.', - 'error.showDetails': 'Show details', - 'error.hideDetails': 'Hide details', - 'error.tryAgain': 'Try Again', - 'error.reloadApp': 'Reload App', + "error.title": "Something went wrong", + "error.description": + "An unexpected error occurred. You can try again or reload the app.", + "error.showDetails": "Show details", + "error.hideDetails": "Hide details", + "error.tryAgain": "Try Again", + "error.reloadApp": "Reload App", // ── Update ───────────────────────────────────────────────── - 'update.newVersionAvailable': 'New Version Available', - 'update.downloading': 'Downloading', - 'update.restartToUpdate': 'Restart to Update', - 'update.restartNow': 'Restart Now', - 'update.readyToInstall': 'CodePilot v{version} is ready — restart to update', - 'update.installUpdate': 'Download & Install', - 'update.later': 'Later', + "update.newVersionAvailable": "New Version Available", + "update.downloading": "Downloading", + "update.restartToUpdate": "Restart to Update", + "update.restartNow": "Restart Now", + "update.readyToInstall": "CodePilot v{version} is ready — restart to update", + "update.installUpdate": "Download & Install", + "update.later": "Later", // ── Image Generation ────────────────────────────────────── - 'imageGen.toggle': 'Image Generation', - 'imageGen.toggleLabel': 'Image Agent', - 'imageGen.toggleTooltip': 'Toggle Image Agent — AI analyzes intent and generates single or batch images', - 'imageGen.generating': 'Generating image...', - 'imageGen.params': 'Generation Parameters', - 'imageGen.aspectRatio': 'Aspect Ratio', - 'imageGen.resolution': 'Resolution', - 'imageGen.generate': 'Generate', - 'imageGen.regenerate': 'Regenerate', - 'imageGen.download': 'Download', - 'imageGen.prompt': 'Prompt', - 'imageGen.model': 'Model', - 'imageGen.noApiKey': 'Please configure Gemini API Key in Settings > Providers', - 'imageGen.error': 'Image generation failed', - 'imageGen.success': 'Image generated successfully', - 'imageGen.referenceImages': 'Reference Images', - 'imageGen.uploadReference': 'Upload reference image', - 'imageGen.resetChat': 'Reset conversation', - 'imageGen.multiTurnHint': 'You can describe changes to modify the generated image', - 'imageGen.settings': 'Generation Settings', - 'imageGen.generated': 'Generated image', - 'imageGen.confirmTitle': 'Image Generation', - 'imageGen.editPrompt': 'Edit prompt', - 'imageGen.generateButton': 'Generate', - 'imageGen.retryButton': 'Retry', - 'imageGen.generatingStatus': 'Generating...', - 'imageGen.stopButton': 'Stop', + "imageGen.toggle": "Image Generation", + "imageGen.toggleLabel": "Image Agent", + "imageGen.toggleTooltip": + "Toggle Image Agent — AI analyzes intent and generates single or batch images", + "imageGen.generating": "Generating image...", + "imageGen.params": "Generation Parameters", + "imageGen.aspectRatio": "Aspect Ratio", + "imageGen.resolution": "Resolution", + "imageGen.generate": "Generate", + "imageGen.regenerate": "Regenerate", + "imageGen.download": "Download", + "imageGen.prompt": "Prompt", + "imageGen.model": "Model", + "imageGen.noApiKey": + "Please configure Gemini API Key in Settings > Providers", + "imageGen.error": "Image generation failed", + "imageGen.success": "Image generated successfully", + "imageGen.referenceImages": "Reference Images", + "imageGen.uploadReference": "Upload reference image", + "imageGen.resetChat": "Reset conversation", + "imageGen.multiTurnHint": + "You can describe changes to modify the generated image", + "imageGen.settings": "Generation Settings", + "imageGen.generated": "Generated image", + "imageGen.confirmTitle": "Image Generation", + "imageGen.editPrompt": "Edit prompt", + "imageGen.generateButton": "Generate", + "imageGen.retryButton": "Retry", + "imageGen.generatingStatus": "Generating...", + "imageGen.stopButton": "Stop", // ── Batch Image Generation ───────────────────────────────── - 'batchImageGen.toggle': 'Batch Generate', - 'batchImageGen.toggleTooltip': 'Toggle batch image generation mode', - 'batchImageGen.entryTitle': 'Batch Image Generation', - 'batchImageGen.stylePrompt': 'Style Prompt', - 'batchImageGen.stylePromptPlaceholder': 'Describe the visual style for all images...', - 'batchImageGen.uploadDocs': 'Upload Documents', - 'batchImageGen.uploadDocsHint': 'Upload text/markdown files as source content', - 'batchImageGen.count': 'Image Count', - 'batchImageGen.countAuto': 'Auto', - 'batchImageGen.aspectRatioStrategy': 'Aspect Ratio', - 'batchImageGen.resolution': 'Resolution', - 'batchImageGen.generatePlan': 'Generate Plan', - 'batchImageGen.planning': 'Planning...', - 'batchImageGen.planPreviewTitle': 'Generation Plan', - 'batchImageGen.planSummary': 'Plan Summary', - 'batchImageGen.editPrompt': 'Edit prompt', - 'batchImageGen.addItem': 'Add Item', - 'batchImageGen.removeItem': 'Remove', - 'batchImageGen.regeneratePlan': 'Regenerate Plan', - 'batchImageGen.confirmAndExecute': 'Confirm & Execute', - 'batchImageGen.executing': 'Executing...', - 'batchImageGen.totalProgress': 'Progress', - 'batchImageGen.itemPending': 'Pending', - 'batchImageGen.itemProcessing': 'Generating...', - 'batchImageGen.itemCompleted': 'Done', - 'batchImageGen.itemFailed': 'Failed', - 'batchImageGen.retryFailed': 'Retry Failed', - 'batchImageGen.retryItem': 'Retry', - 'batchImageGen.pause': 'Pause', - 'batchImageGen.resume': 'Resume', - 'batchImageGen.cancel': 'Cancel', - 'batchImageGen.syncToChat': 'Sync to Conversation', - 'batchImageGen.syncComplete': 'Synced to conversation', - 'batchImageGen.syncMode': 'Sync Mode', - 'batchImageGen.syncManual': 'Manual', - 'batchImageGen.syncAutoBatch': 'Auto Batch', - 'batchImageGen.completed': 'Batch generation complete', - 'batchImageGen.completedStats': '{completed}/{total} images generated', - 'batchImageGen.noProvider': 'Please configure a text generation provider in Settings', - 'batchImageGen.tags': 'Tags', - 'batchImageGen.sourceRefs': 'Sources', - 'batchImageGen.prompt': 'Prompt', - 'batchImageGen.aspectRatio': 'Ratio', - 'batchImageGen.imageSize': 'Size', + "batchImageGen.toggle": "Batch Generate", + "batchImageGen.toggleTooltip": "Toggle batch image generation mode", + "batchImageGen.entryTitle": "Batch Image Generation", + "batchImageGen.stylePrompt": "Style Prompt", + "batchImageGen.stylePromptPlaceholder": + "Describe the visual style for all images...", + "batchImageGen.uploadDocs": "Upload Documents", + "batchImageGen.uploadDocsHint": + "Upload text/markdown files as source content", + "batchImageGen.count": "Image Count", + "batchImageGen.countAuto": "Auto", + "batchImageGen.aspectRatioStrategy": "Aspect Ratio", + "batchImageGen.resolution": "Resolution", + "batchImageGen.generatePlan": "Generate Plan", + "batchImageGen.planning": "Planning...", + "batchImageGen.planPreviewTitle": "Generation Plan", + "batchImageGen.planSummary": "Plan Summary", + "batchImageGen.editPrompt": "Edit prompt", + "batchImageGen.addItem": "Add Item", + "batchImageGen.removeItem": "Remove", + "batchImageGen.regeneratePlan": "Regenerate Plan", + "batchImageGen.confirmAndExecute": "Confirm & Execute", + "batchImageGen.executing": "Executing...", + "batchImageGen.totalProgress": "Progress", + "batchImageGen.itemPending": "Pending", + "batchImageGen.itemProcessing": "Generating...", + "batchImageGen.itemCompleted": "Done", + "batchImageGen.itemFailed": "Failed", + "batchImageGen.retryFailed": "Retry Failed", + "batchImageGen.retryItem": "Retry", + "batchImageGen.pause": "Pause", + "batchImageGen.resume": "Resume", + "batchImageGen.cancel": "Cancel", + "batchImageGen.syncToChat": "Sync to Conversation", + "batchImageGen.syncComplete": "Synced to conversation", + "batchImageGen.syncMode": "Sync Mode", + "batchImageGen.syncManual": "Manual", + "batchImageGen.syncAutoBatch": "Auto Batch", + "batchImageGen.completed": "Batch generation complete", + "batchImageGen.completedStats": "{completed}/{total} images generated", + "batchImageGen.noProvider": + "Please configure a text generation provider in Settings", + "batchImageGen.tags": "Tags", + "batchImageGen.sourceRefs": "Sources", + "batchImageGen.prompt": "Prompt", + "batchImageGen.aspectRatio": "Ratio", + "batchImageGen.imageSize": "Size", // ── Gallery ───────────────────────────────────────────────── - 'gallery.title': 'Gallery', - 'gallery.empty': 'No images generated yet', - 'gallery.emptyHint': 'Generate images in chat to see them here.', - 'gallery.filterByTag': 'Filter by tag', - 'gallery.dateFrom': 'From', - 'gallery.dateTo': 'To', - 'gallery.sortNewest': 'Newest first', - 'gallery.sortOldest': 'Oldest first', - 'gallery.newestFirst': 'Newest first', - 'gallery.oldestFirst': 'Oldest first', - 'gallery.filters': 'Filters', - 'gallery.clearFilters': 'Clear filters', - 'gallery.loadMore': 'Load more', - 'gallery.openChat': 'Open Chat', - 'gallery.delete': 'Delete', - 'gallery.confirmDelete': 'Confirm delete?', - 'gallery.deleteConfirm': 'Delete this image? This cannot be undone.', - 'gallery.tags': 'Tags', - 'gallery.addTag': 'Add tag', - 'gallery.newTag': 'New tag name', - 'gallery.newTagPlaceholder': 'New tag name...', - 'gallery.cancel': 'Cancel', - 'gallery.removeTag': 'Remove tag', - 'gallery.noTags': 'No tags', - 'gallery.generatedAt': 'Generated at', - 'gallery.viewDetails': 'View details', - 'gallery.imageDetail': 'Image Detail', - 'gallery.prompt': 'Prompt', - 'gallery.download': 'Download', - 'gallery.favorites': 'Favorites', - 'gallery.favoritesOnly': 'Favorites', - 'gallery.addToFavorites': 'Add to favorites', - 'gallery.removeFromFavorites': 'Remove from favorites', + "gallery.title": "Gallery", + "gallery.empty": "No images generated yet", + "gallery.emptyHint": "Generate images in chat to see them here.", + "gallery.filterByTag": "Filter by tag", + "gallery.dateFrom": "From", + "gallery.dateTo": "To", + "gallery.sortNewest": "Newest first", + "gallery.sortOldest": "Oldest first", + "gallery.newestFirst": "Newest first", + "gallery.oldestFirst": "Oldest first", + "gallery.filters": "Filters", + "gallery.clearFilters": "Clear filters", + "gallery.loadMore": "Load more", + "gallery.openChat": "Open Chat", + "gallery.delete": "Delete", + "gallery.confirmDelete": "Confirm delete?", + "gallery.deleteConfirm": "Delete this image? This cannot be undone.", + "gallery.tags": "Tags", + "gallery.addTag": "Add tag", + "gallery.newTag": "New tag name", + "gallery.newTagPlaceholder": "New tag name...", + "gallery.cancel": "Cancel", + "gallery.removeTag": "Remove tag", + "gallery.noTags": "No tags", + "gallery.generatedAt": "Generated at", + "gallery.viewDetails": "View details", + "gallery.imageDetail": "Image Detail", + "gallery.prompt": "Prompt", + "gallery.download": "Download", + "gallery.favorites": "Favorites", + "gallery.favoritesOnly": "Favorites", + "gallery.addToFavorites": "Add to favorites", + "gallery.removeFromFavorites": "Remove from favorites", // ── Provider: Gemini Image ────────────────────────────────── - 'provider.chatProviders': 'Chat Providers', - 'provider.mediaProviders': 'Media Providers', - 'provider.geminiImageDesc': 'Nano Banana Pro — AI image generation by Google Gemini', + "provider.chatProviders": "Chat Providers", + "provider.mediaProviders": "Media Providers", + "provider.geminiImageDesc": + "Nano Banana Pro — AI image generation by Google Gemini", // ── CLI dynamic field labels ────────────────────────────── - 'cli.loadingSettings': 'Loading settings...', - 'cli.field.skipDangerousModePermissionPrompt': 'Skip Dangerous Mode Permission Prompt', - 'cli.field.verbose': 'Verbose', - 'cli.field.theme': 'Theme', - 'cli.formatError': 'Cannot format: invalid JSON', + "cli.loadingSettings": "Loading settings...", + "cli.field.skipDangerousModePermissionPrompt": + "Skip Dangerous Mode Permission Prompt", + "cli.field.verbose": "Verbose", + "cli.field.theme": "Theme", + "cli.formatError": "Cannot format: invalid JSON", // ── Split screen ───────────────────────────────────────────── - 'split.splitScreen': 'Split Screen', - 'split.closeSplit': 'Close Split', - 'split.splitGroup': 'Split', - 'chatList.splitScreen': 'Split Screen', + "split.splitScreen": "Split Screen", + "split.closeSplit": "Close Split", + "split.splitGroup": "Split", + "chatList.splitScreen": "Split Screen", // ── Telegram (Bridge) ────────────────────────────────────── - 'telegram.credentials': 'Bot Credentials', - 'telegram.credentialsDesc': 'Enter your Telegram Bot token and chat ID', - 'telegram.botToken': 'Bot Token', - 'telegram.chatId': 'Chat ID', - 'telegram.chatIdHint': 'Send /start to your bot first, then click "Auto Detect" to fill in your Chat ID', - 'telegram.detectChatId': 'Auto Detect', - 'telegram.chatIdDetected': 'Chat ID detected: {id} ({name})', - 'telegram.chatIdDetectFailed': 'Could not detect Chat ID. Send /start to the bot first, then try again.', - 'telegram.verify': 'Test Connection', - 'telegram.verified': 'Connection verified', - 'telegram.verifiedAs': 'Connected as @{name}', - 'telegram.verifyFailed': 'Connection failed', - 'telegram.enterTokenFirst': 'Enter a bot token first', - 'telegram.setupGuide': 'Setup Guide', - 'telegram.step1': 'Open Telegram and search for @BotFather', - 'telegram.step2': 'Send /newbot and follow the prompts to create a bot', - 'telegram.step3': 'Copy the bot token and paste it above', - 'telegram.step4': 'Click "Test Connection" to verify the token is valid', - 'telegram.step5': 'Send /start to your bot, then click "Auto Detect" next to the Chat ID field', - 'telegram.step6': 'Click "Save" to store your credentials', + "telegram.credentials": "Bot Credentials", + "telegram.credentialsDesc": "Enter your Telegram Bot token and chat ID", + "telegram.botToken": "Bot Token", + "telegram.chatId": "Chat ID", + "telegram.chatIdHint": + 'Send /start to your bot first, then click "Auto Detect" to fill in your Chat ID', + "telegram.detectChatId": "Auto Detect", + "telegram.chatIdDetected": "Chat ID detected: {id} ({name})", + "telegram.chatIdDetectFailed": + "Could not detect Chat ID. Send /start to the bot first, then try again.", + "telegram.verify": "Test Connection", + "telegram.verified": "Connection verified", + "telegram.verifiedAs": "Connected as @{name}", + "telegram.verifyFailed": "Connection failed", + "telegram.enterTokenFirst": "Enter a bot token first", + "telegram.setupGuide": "Setup Guide", + "telegram.step1": "Open Telegram and search for @BotFather", + "telegram.step2": "Send /newbot and follow the prompts to create a bot", + "telegram.step3": "Copy the bot token and paste it above", + "telegram.step4": 'Click "Test Connection" to verify the token is valid', + "telegram.step5": + 'Send /start to your bot, then click "Auto Detect" next to the Chat ID field', + "telegram.step6": 'Click "Save" to store your credentials', // ── Feishu (Bridge) ────────────────────────────────────── - 'feishu.credentials': 'App Credentials', - 'feishu.credentialsDesc': 'Enter your Feishu App ID and App Secret', - 'feishu.appId': 'App ID', - 'feishu.appSecret': 'App Secret', - 'feishu.domain': 'Platform', - 'feishu.domainFeishu': 'Feishu (feishu.cn)', - 'feishu.domainLark': 'Lark (larksuite.com)', - 'feishu.domainHint': 'Choose Feishu for China mainland or Lark for international', - 'feishu.verify': 'Test Connection', - 'feishu.verified': 'Connection verified', - 'feishu.verifiedAs': 'Connected as {name}', - 'feishu.verifyFailed': 'Connection failed', - 'feishu.enterCredentialsFirst': 'Enter App ID and App Secret first', - 'feishu.allowedUsers': 'Allowed Users', - 'feishu.allowedUsersDesc': 'Comma-separated open_id or chat_id values allowed to use bridge', - 'feishu.allowedUsersHint': 'Leave empty to allow all users', - 'feishu.groupSettings': 'Group Chat Settings', - 'feishu.groupSettingsDesc': 'Control how the bot responds in group chats', - 'feishu.groupPolicy': 'Group Policy', - 'feishu.groupPolicyOpen': 'Open — respond in all groups', - 'feishu.groupPolicyAllowlist': 'Allowlist — only specified groups', - 'feishu.groupPolicyDisabled': 'Disabled — ignore all group messages', - 'feishu.groupAllowFrom': 'Allowed Groups', - 'feishu.groupAllowFromHint': 'Comma-separated chat_id values of allowed groups', - 'feishu.requireMention': 'Require @mention', - 'feishu.requireMentionDesc': 'Only respond in groups when the bot is @mentioned', - 'feishu.setupGuide': 'Setup Guide', - 'feishu.step1': 'Go to Feishu Open Platform (open.feishu.cn) and create a custom app', - 'feishu.step2': 'Enable the Bot capability in the app features', - 'feishu.step3': 'Copy the App ID and App Secret from the Credentials page', - 'feishu.step4': 'Add event subscription: im.message.receive_v1', - 'feishu.step5': 'Publish the app version and approve it in the admin console', - 'feishu.step6': 'Paste the credentials above and click "Test Connection" to verify', + "feishu.credentials": "App Credentials", + "feishu.credentialsDesc": "Enter your Feishu App ID and App Secret", + "feishu.appId": "App ID", + "feishu.appSecret": "App Secret", + "feishu.domain": "Platform", + "feishu.domainFeishu": "Feishu (feishu.cn)", + "feishu.domainLark": "Lark (larksuite.com)", + "feishu.domainHint": + "Choose Feishu for China mainland or Lark for international", + "feishu.verify": "Test Connection", + "feishu.verified": "Connection verified", + "feishu.verifiedAs": "Connected as {name}", + "feishu.verifyFailed": "Connection failed", + "feishu.enterCredentialsFirst": "Enter App ID and App Secret first", + "feishu.allowedUsers": "Allowed Users", + "feishu.allowedUsersDesc": + "Comma-separated open_id or chat_id values allowed to use bridge", + "feishu.allowedUsersHint": "Leave empty to allow all users", + "feishu.groupSettings": "Group Chat Settings", + "feishu.groupSettingsDesc": "Control how the bot responds in group chats", + "feishu.groupPolicy": "Group Policy", + "feishu.groupPolicyOpen": "Open — respond in all groups", + "feishu.groupPolicyAllowlist": "Allowlist — only specified groups", + "feishu.groupPolicyDisabled": "Disabled — ignore all group messages", + "feishu.groupAllowFrom": "Allowed Groups", + "feishu.groupAllowFromHint": + "Comma-separated chat_id values of allowed groups", + "feishu.requireMention": "Require @mention", + "feishu.requireMentionDesc": + "Only respond in groups when the bot is @mentioned", + "feishu.setupGuide": "Setup Guide", + "feishu.step1": + "Go to Feishu Open Platform (open.feishu.cn) and create a custom app", + "feishu.step2": "Enable the Bot capability in the app features", + "feishu.step3": "Copy the App ID and App Secret from the Credentials page", + "feishu.step4": "Add event subscription: im.message.receive_v1", + "feishu.step5": "Publish the app version and approve it in the admin console", + "feishu.step6": + 'Paste the credentials above and click "Test Connection" to verify', // ── Settings: Remote Bridge ──────────────────────────────── - 'settings.bridge': 'Remote Bridge', - 'bridge.title': 'Remote Bridge', - 'bridge.description': 'Control Claude from external channels like Telegram or Feishu', - 'bridge.enabled': 'Enable Remote Bridge', - 'bridge.enabledDesc': 'Allow external messaging channels to interact with Claude', - 'bridge.activeHint': 'Bridge is active. External channels can send tasks to Claude.', - 'bridge.status': 'Bridge Status', - 'bridge.statusConnected': 'Connected', - 'bridge.statusDisconnected': 'Disconnected', - 'bridge.statusStarting': 'Starting...', - 'bridge.statusStopping': 'Stopping...', - 'bridge.activeBindings': '{count} active binding(s)', - 'bridge.noBindings': 'No active bindings', - 'bridge.channels': 'Channels', - 'bridge.channelsDesc': 'Enable or disable individual messaging channels', - 'bridge.telegramChannel': 'Telegram', - 'bridge.telegramChannelDesc': 'Receive and respond to messages via Telegram Bot', - 'bridge.bindings': 'Active Sessions', - 'bridge.bindingsDesc': 'Current session bindings from external channels', - 'bridge.bindingChat': 'Chat', - 'bridge.bindingChannel': 'Channel', - 'bridge.bindingCreated': 'Created', - 'bridge.bindingStatus': 'Status', - 'bridge.noActiveBindings': 'No active session bindings', - 'bridge.defaults': 'Defaults', - 'bridge.defaultsDesc': 'Default settings for bridge-initiated sessions', - 'bridge.defaultWorkDir': 'Working Directory', - 'bridge.defaultWorkDirHint': 'Default project folder for bridge sessions', - 'bridge.defaultModel': 'Model', - 'bridge.defaultModelHint': 'Default model for bridge sessions', - 'bridge.browse': 'Browse', - 'bridge.start': 'Start Bridge', - 'bridge.stop': 'Stop Bridge', - 'bridge.starting': 'Starting...', - 'bridge.stopping': 'Stopping...', - 'bridge.autoStart': 'Auto-start Bridge', - 'bridge.autoStartDesc': 'Automatically start the bridge when the application launches', - 'bridge.adapterRunning': 'Running', - 'bridge.adapterStopped': 'Stopped', - 'bridge.adapterLastMessage': 'Last message', - 'bridge.adapterLastError': 'Last error', - 'bridge.adapters': 'Adapter Status', - 'bridge.adaptersDesc': 'Real-time status of each channel adapter', - 'bridge.telegramSettings': 'Telegram Settings', - 'bridge.telegramSettingsDesc': 'Configure Telegram Bot credentials for bridge', - 'bridge.allowedUsers': 'Allowed Users', - 'bridge.allowedUsersDesc': 'Comma-separated Telegram user IDs allowed to use bridge', - 'bridge.allowedUsersHint': 'Leave empty to allow only the Chat ID configured above', - 'bridge.feishuChannel': 'Feishu', - 'bridge.feishuChannelDesc': 'Receive and respond to messages via Feishu Bot', - 'bridge.feishuSettings': 'Feishu Settings', - 'bridge.feishuSettingsDesc': 'Configure Feishu App credentials for bridge', - 'bridge.discordChannel': 'Discord', - 'bridge.discordChannelDesc': 'Receive and respond to messages via Discord Bot', - 'bridge.discordSettings': 'Discord Settings', - 'bridge.discordSettingsDesc': 'Configure Discord Bot credentials for bridge', + "settings.bridge": "Remote Bridge", + "bridge.title": "Remote Bridge", + "bridge.description": + "Control Claude from external channels like Telegram or Feishu", + "bridge.enabled": "Enable Remote Bridge", + "bridge.enabledDesc": + "Allow external messaging channels to interact with Claude", + "bridge.activeHint": + "Bridge is active. External channels can send tasks to Claude.", + "bridge.status": "Bridge Status", + "bridge.statusConnected": "Connected", + "bridge.statusDisconnected": "Disconnected", + "bridge.statusStarting": "Starting...", + "bridge.statusStopping": "Stopping...", + "bridge.activeBindings": "{count} active binding(s)", + "bridge.noBindings": "No active bindings", + "bridge.channels": "Channels", + "bridge.channelsDesc": "Enable or disable individual messaging channels", + "bridge.telegramChannel": "Telegram", + "bridge.telegramChannelDesc": + "Receive and respond to messages via Telegram Bot", + "bridge.bindings": "Active Sessions", + "bridge.bindingsDesc": "Current session bindings from external channels", + "bridge.bindingChat": "Chat", + "bridge.bindingChannel": "Channel", + "bridge.bindingCreated": "Created", + "bridge.bindingStatus": "Status", + "bridge.noActiveBindings": "No active session bindings", + "bridge.defaults": "Defaults", + "bridge.defaultsDesc": "Default settings for bridge-initiated sessions", + "bridge.defaultWorkDir": "Working Directory", + "bridge.defaultWorkDirHint": "Default project folder for bridge sessions", + "bridge.defaultModel": "Model", + "bridge.defaultModelHint": "Default model for bridge sessions", + "bridge.browse": "Browse", + "bridge.start": "Start Bridge", + "bridge.stop": "Stop Bridge", + "bridge.starting": "Starting...", + "bridge.stopping": "Stopping...", + "bridge.autoStart": "Auto-start Bridge", + "bridge.autoStartDesc": + "Automatically start the bridge when the application launches", + "bridge.adapterRunning": "Running", + "bridge.adapterStopped": "Stopped", + "bridge.adapterLastMessage": "Last message", + "bridge.adapterLastError": "Last error", + "bridge.adapters": "Adapter Status", + "bridge.adaptersDesc": "Real-time status of each channel adapter", + "bridge.telegramSettings": "Telegram Settings", + "bridge.telegramSettingsDesc": + "Configure Telegram Bot credentials for bridge", + "bridge.allowedUsers": "Allowed Users", + "bridge.allowedUsersDesc": + "Comma-separated Telegram user IDs allowed to use bridge", + "bridge.allowedUsersHint": + "Leave empty to allow only the Chat ID configured above", + "bridge.feishuChannel": "Feishu", + "bridge.feishuChannelDesc": "Receive and respond to messages via Feishu Bot", + "bridge.feishuSettings": "Feishu Settings", + "bridge.feishuSettingsDesc": "Configure Feishu App credentials for bridge", + "bridge.discordChannel": "Discord", + "bridge.discordChannelDesc": + "Receive and respond to messages via Discord Bot", + "bridge.discordSettings": "Discord Settings", + "bridge.discordSettingsDesc": "Configure Discord Bot credentials for bridge", // ── Settings: Discord Bridge ───────────────────────────────── - 'discord.credentials': 'Bot Credentials', - 'discord.credentialsDesc': 'Enter your Discord Bot token', - 'discord.botToken': 'Bot Token', - 'discord.verify': 'Test Connection', - 'discord.verified': 'Connection verified', - 'discord.verifiedAs': 'Connected as {name}', - 'discord.verifyFailed': 'Connection failed', - 'discord.enterTokenFirst': 'Enter a bot token first', - 'discord.allowedUsers': 'Authorization', - 'discord.allowedUsersDesc': 'Control which users and channels can interact with the bot', - 'discord.allowedUserIds': 'Allowed User IDs', - 'discord.allowedUsersHint': 'Discord user IDs, comma-separated for multiple. Denies all when both users and channels are empty.', - 'discord.allowedChannelIds': 'Allowed Channel IDs', - 'discord.allowedChannelsHint': 'Discord channel IDs, comma-separated for multiple.', - 'discord.guildSettings': 'Server & Group Settings', - 'discord.guildSettingsDesc': 'Control how the bot responds in Discord servers', - 'discord.allowedGuilds': 'Allowed Server (Guild) IDs', - 'discord.allowedGuildsHint': 'Guild IDs, comma-separated for multiple. Leave empty to allow all servers.', - 'discord.groupPolicy': 'Server Message Policy', - 'discord.groupPolicyOpen': 'Open — respond in all server channels', - 'discord.groupPolicyDisabled': 'Disabled — ignore all server messages (DM only)', - 'discord.requireMention': 'Require @mention', - 'discord.requireMentionDesc': 'Only respond in servers when the bot is @mentioned', - 'discord.streamPreview': 'Stream Preview', - 'discord.streamPreviewDesc': 'Show real-time response preview by editing messages', - 'discord.setupGuide': 'Configuration Guide', - 'discord.setupBotTitle': 'Create a Discord Bot', - 'discord.step1': 'Go to Discord Developer Portal (discord.com/developers), click "New Application" to create an app', - 'discord.step2': 'Navigate to "Bot" in the sidebar, click "Add Bot" to create the bot', - 'discord.step3': 'Scroll down to "Privileged Gateway Intents", enable the "MESSAGE CONTENT INTENT" toggle', - 'discord.step4': 'Click "Reset Token" to copy the Bot Token, paste it in the "Bot Credentials" section above and save', - 'discord.step5': 'Go to "OAuth2 → URL Generator", check "bot" under Scopes, then check "Send Messages" and "Read Message History" under Bot Permissions', - 'discord.step6': 'Copy the generated invite URL, open it in your browser to invite the Bot to your server', - 'discord.step7': 'Click "Test Connection" above — if the bot name appears, setup is complete', - 'discord.setupIdTitle': 'Getting IDs (Developer Mode required)', - 'discord.stepDevMode': 'Open Discord → User Settings → Advanced → enable "Developer Mode"', - 'discord.stepUserId': 'User ID: right-click your avatar or username → "Copy User ID"', - 'discord.stepChannelId': 'Channel ID: right-click a channel name in the sidebar → "Copy Channel ID"', - 'discord.stepGuildId': 'Server ID: right-click the server name at the top-left → "Copy Server ID"', + "discord.credentials": "Bot Credentials", + "discord.credentialsDesc": "Enter your Discord Bot token", + "discord.botToken": "Bot Token", + "discord.verify": "Test Connection", + "discord.verified": "Connection verified", + "discord.verifiedAs": "Connected as {name}", + "discord.verifyFailed": "Connection failed", + "discord.enterTokenFirst": "Enter a bot token first", + "discord.allowedUsers": "Authorization", + "discord.allowedUsersDesc": + "Control which users and channels can interact with the bot", + "discord.allowedUserIds": "Allowed User IDs", + "discord.allowedUsersHint": + "Discord user IDs, comma-separated for multiple. Denies all when both users and channels are empty.", + "discord.allowedChannelIds": "Allowed Channel IDs", + "discord.allowedChannelsHint": + "Discord channel IDs, comma-separated for multiple.", + "discord.guildSettings": "Server & Group Settings", + "discord.guildSettingsDesc": + "Control how the bot responds in Discord servers", + "discord.allowedGuilds": "Allowed Server (Guild) IDs", + "discord.allowedGuildsHint": + "Guild IDs, comma-separated for multiple. Leave empty to allow all servers.", + "discord.groupPolicy": "Server Message Policy", + "discord.groupPolicyOpen": "Open — respond in all server channels", + "discord.groupPolicyDisabled": + "Disabled — ignore all server messages (DM only)", + "discord.requireMention": "Require @mention", + "discord.requireMentionDesc": + "Only respond in servers when the bot is @mentioned", + "discord.streamPreview": "Stream Preview", + "discord.streamPreviewDesc": + "Show real-time response preview by editing messages", + "discord.setupGuide": "Configuration Guide", + "discord.setupBotTitle": "Create a Discord Bot", + "discord.step1": + 'Go to Discord Developer Portal (discord.com/developers), click "New Application" to create an app', + "discord.step2": + 'Navigate to "Bot" in the sidebar, click "Add Bot" to create the bot', + "discord.step3": + 'Scroll down to "Privileged Gateway Intents", enable the "MESSAGE CONTENT INTENT" toggle', + "discord.step4": + 'Click "Reset Token" to copy the Bot Token, paste it in the "Bot Credentials" section above and save', + "discord.step5": + 'Go to "OAuth2 → URL Generator", check "bot" under Scopes, then check "Send Messages" and "Read Message History" under Bot Permissions', + "discord.step6": + "Copy the generated invite URL, open it in your browser to invite the Bot to your server", + "discord.step7": + 'Click "Test Connection" above — if the bot name appears, setup is complete', + "discord.setupIdTitle": "Getting IDs (Developer Mode required)", + "discord.stepDevMode": + 'Open Discord → User Settings → Advanced → enable "Developer Mode"', + "discord.stepUserId": + 'User ID: right-click your avatar or username → "Copy User ID"', + "discord.stepChannelId": + 'Channel ID: right-click a channel name in the sidebar → "Copy Channel ID"', + "discord.stepGuildId": + 'Server ID: right-click the server name at the top-left → "Copy Server ID"', } as const; export type TranslationKey = keyof typeof en; diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 4ad12106..061f135a 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -1,626 +1,663 @@ -import type { TranslationKey } from './en'; +import type { TranslationKey } from "./en"; const zh: Record = { // ── Navigation ────────────────────────────────────────────── - 'nav.chats': '对话', - 'nav.extensions': '扩展', - 'nav.settings': '设置', - 'nav.autoApproveOn': '自动批准已开启', - 'nav.lightMode': '浅色模式', - 'nav.darkMode': '深色模式', - 'nav.toggleTheme': '切换主题', - 'nav.bridge': '桥接', + "nav.chats": "对话", + "nav.extensions": "扩展", + "nav.settings": "设置", + "nav.autoApproveOn": "自动批准已开启", + "nav.lightMode": "浅色模式", + "nav.darkMode": "深色模式", + "nav.toggleTheme": "切换主题", + "nav.bridge": "桥接", // ── Chat list panel ───────────────────────────────────────── - 'chatList.justNow': '刚刚', - 'chatList.minutesAgo': '{n}分钟', - 'chatList.hoursAgo': '{n}小时', - 'chatList.daysAgo': '{n}天', - 'chatList.newConversation': '新对话', - 'chatList.delete': '删除', - 'chatList.searchSessions': '搜索会话...', - 'chatList.noSessions': '暂无会话', - 'chatList.importFromCli': '从 Claude Code 导入', - 'chatList.addProjectFolder': '添加项目文件夹', - 'chatList.threads': '对话列表', + "chatList.justNow": "刚刚", + "chatList.minutesAgo": "{n}分钟", + "chatList.hoursAgo": "{n}小时", + "chatList.daysAgo": "{n}天", + "chatList.newConversation": "新对话", + "chatList.delete": "删除", + "chatList.searchSessions": "搜索会话...", + "chatList.noSessions": "暂无会话", + "chatList.importFromCli": "从 Claude Code 导入", + "chatList.addProjectFolder": "添加项目文件夹", + "chatList.threads": "对话列表", // ── Message list ──────────────────────────────────────────── - 'messageList.claudeChat': 'CodePilot 对话', - 'messageList.emptyDescription': '开始与 CodePilot 对话。提问、获取代码帮助或探索想法。', - 'messageList.loadEarlier': '加载更早的消息', - 'messageList.loading': '加载中...', + "messageList.claudeChat": "CodePilot 对话", + "messageList.emptyDescription": + "开始与 CodePilot 对话。提问、获取代码帮助或探索想法。", + "messageList.loadEarlier": "加载更早的消息", + "messageList.loading": "加载中...", // ── Message input ─────────────────────────────────────────── - 'messageInput.attachFiles': '附加文件', - 'messageInput.helpDesc': '显示可用命令和提示', - 'messageInput.clearDesc': '清除对话历史', - 'messageInput.costDesc': '显示 Token 用量统计', - 'messageInput.compactDesc': '压缩对话上下文', - 'messageInput.doctorDesc': '诊断项目健康状况', - 'messageInput.initDesc': '初始化项目 CLAUDE.md', - 'messageInput.reviewDesc': '审查代码质量', - 'messageInput.terminalSetupDesc': '配置终端设置', - 'messageInput.memoryDesc': '编辑项目记忆文件', - 'messageInput.modeCode': '代码', - 'messageInput.modePlan': '计划', - 'messageInput.aiSuggested': 'AI 推荐', + "messageInput.attachFiles": "附加文件", + "messageInput.helpDesc": "显示可用命令和提示", + "messageInput.clearDesc": "清除对话历史", + "messageInput.costDesc": "显示 Token 用量统计", + "messageInput.compactDesc": "压缩对话上下文", + "messageInput.doctorDesc": "诊断项目健康状况", + "messageInput.initDesc": "初始化项目 CLAUDE.md", + "messageInput.reviewDesc": "审查代码质量", + "messageInput.terminalSetupDesc": "配置终端设置", + "messageInput.memoryDesc": "编辑项目记忆文件", + "messageInput.modeCode": "代码", + "messageInput.modePlan": "计划", + "messageInput.aiSuggested": "AI 推荐", // ── Streaming message ─────────────────────────────────────── - 'streaming.thinking': '思考中...', - 'streaming.allowForSession': '本次会话允许', - 'streaming.allowed': '已允许', - 'streaming.denied': '已拒绝', + "streaming.thinking": "思考中...", + "streaming.allowForSession": "本次会话允许", + "streaming.allowed": "已允许", + "streaming.denied": "已拒绝", // ── Chat view / session page ──────────────────────────────── - 'chat.newConversation': '新对话', + "chat.newConversation": "新对话", // ── Settings: General ─────────────────────────────────────── - 'settings.title': '设置', - 'settings.description': '管理 CodePilot 和 Claude CLI 设置', - 'settings.general': '通用', - 'settings.providers': '服务商', - 'settings.claudeCli': 'Claude CLI', - 'settings.codepilot': 'CodePilot', - 'settings.version': '版本 {version}', - 'settings.checkForUpdates': '检查更新', - 'settings.checking': '检查中...', - 'settings.updateAvailable': '有新版本:v{version}', - 'settings.viewRelease': '查看发布', - 'settings.latestVersion': '已是最新版本。', - 'settings.autoApproveTitle': '自动批准所有操作', - 'settings.autoApproveDesc': '跳过所有权限检查并自动批准每个工具操作。这很危险,仅应用于受信任的任务。', - 'settings.autoApproveWarning': '所有工具操作将在无确认的情况下自动批准。请谨慎使用。', - 'settings.autoApproveDialogTitle': '启用自动批准所有操作?', - 'settings.autoApproveDialogDesc': '这将绕过所有权限检查。Claude 将能够在不征求您确认的情况下执行任何工具操作,包括:', - 'settings.autoApproveShellCommands': '运行任意 Shell 命令', - 'settings.autoApproveFileOps': '读取、写入和删除文件', - 'settings.autoApproveNetwork': '发起网络请求', - 'settings.autoApproveTrustWarning': '仅在您完全信任当前任务时才启用此选项。此设置适用于所有新的聊天会话。', - 'settings.cancel': '取消', - 'settings.enableAutoApprove': '启用自动批准', - 'settings.language': '语言', - 'settings.languageDesc': '选择界面显示语言', - 'settings.usage': '用量统计', + "settings.title": "设置", + "settings.description": "管理 CodePilot 和 Claude CLI 设置", + "settings.general": "通用", + "settings.providers": "服务商", + "settings.claudeCli": "Claude CLI", + "settings.codepilot": "CodePilot", + "settings.version": "版本 {version}", + "settings.checkForUpdates": "检查更新", + "settings.checking": "检查中...", + "settings.updateAvailable": "有新版本:v{version}", + "settings.viewRelease": "查看发布", + "settings.latestVersion": "已是最新版本。", + "settings.autoApproveTitle": "自动批准所有操作", + "settings.autoApproveDesc": + "跳过所有权限检查并自动批准每个工具操作。这很危险,仅应用于受信任的任务。", + "settings.autoApproveWarning": + "所有工具操作将在无确认的情况下自动批准。请谨慎使用。", + "settings.autoApproveDialogTitle": "启用自动批准所有操作?", + "settings.autoApproveDialogDesc": + "这将绕过所有权限检查。Claude 将能够在不征求您确认的情况下执行任何工具操作,包括:", + "settings.autoApproveShellCommands": "运行任意 Shell 命令", + "settings.autoApproveFileOps": "读取、写入和删除文件", + "settings.autoApproveNetwork": "发起网络请求", + "settings.autoApproveTrustWarning": + "仅在您完全信任当前任务时才启用此选项。此设置适用于所有新的聊天会话。", + "settings.cancel": "取消", + "settings.enableAutoApprove": "启用自动批准", + "settings.language": "语言", + "settings.languageDesc": "选择界面显示语言", + "settings.cliPathTitle": "Claude CLI 路径", + "settings.cliPathDesc": "自定义 Claude CLI 可执行文件路径。留空则自动检测。", + "settings.cliPathPlaceholder": "自动检测(默认)", + "settings.cliPathSaved": "CLI 路径已保存", + "settings.cliPathInvalid": + "该路径下未找到可执行文件。配置已保存,但会回退到自动检测。", + "settings.configDirTitle": "Claude 配置目录", + "settings.configDirDesc": + "自定义 Claude 配置目录路径(如 ~/.claude-internal)。留空则使用 ~/.claude。", + "settings.configDirPlaceholder": "~/.claude(默认)", + "settings.configDirSaved": "配置目录已保存", + "settings.configDirInvalid": + "该路径下未找到目录。配置已保存,但会回退到 ~/.claude。", + "settings.usage": "用量统计", // ── Settings: Usage Stats ─────────────────────────────────── - 'usage.totalTokens': '总 Token', - 'usage.totalCost': '总费用', - 'usage.sessions': '会话数', - 'usage.cacheHitRate': '缓存命中率', - 'usage.input': '输入', - 'usage.output': '输出', - 'usage.cached': '已缓存', - 'usage.dailyChart': '每日 Token 用量', - 'usage.loading': '加载中...', - 'usage.loadError': '加载用量数据失败', - 'usage.noData': '暂无用量数据', - 'usage.noDataHint': '开始对话后即可在此查看统计信息。', + "usage.totalTokens": "总 Token", + "usage.totalCost": "总费用", + "usage.sessions": "会话数", + "usage.cacheHitRate": "缓存命中率", + "usage.input": "输入", + "usage.output": "输出", + "usage.cached": "已缓存", + "usage.dailyChart": "每日 Token 用量", + "usage.loading": "加载中...", + "usage.loadError": "加载用量数据失败", + "usage.noData": "暂无用量数据", + "usage.noDataHint": "开始对话后即可在此查看统计信息。", // ── Settings: CLI ─────────────────────────────────────────── - 'cli.permissions': '权限', - 'cli.permissionsDesc': '配置 Claude CLI 的权限设置', - 'cli.envVars': '环境变量', - 'cli.envVarsDesc': '传递给 Claude 的环境变量', - 'cli.form': '表单', - 'cli.json': 'JSON', - 'cli.save': '保存', - 'cli.format': '格式化', - 'cli.reset': '重置', - 'cli.settingsSaved': '设置已保存', - 'cli.confirmSaveTitle': '确认保存', - 'cli.confirmSaveDesc': '这将覆盖您当前的 ~/.claude/settings.json 文件。确定要继续吗?', + "cli.permissions": "权限", + "cli.permissionsDesc": "配置 Claude CLI 的权限设置", + "cli.envVars": "环境变量", + "cli.envVarsDesc": "传递给 Claude 的环境变量", + "cli.form": "表单", + "cli.json": "JSON", + "cli.save": "保存", + "cli.format": "格式化", + "cli.reset": "重置", + "cli.settingsSaved": "设置已保存", + "cli.confirmSaveTitle": "确认保存", + "cli.confirmSaveDesc": + "这将覆盖您当前的 ~/.claude/settings.json 文件。确定要继续吗?", // ── Settings: Providers ───────────────────────────────────── - 'provider.addProvider': '添加服务商', - 'provider.editProvider': '编辑服务商', - 'provider.deleteProvider': '删除服务商', - 'provider.deleteConfirm': '确定要删除"{name}"吗?此操作无法撤销。', - 'provider.deleting': '删除中...', - 'provider.delete': '删除', - 'provider.noProviders': '未配置服务商', - 'provider.addToStart': '添加服务商以开始使用', - 'provider.quickPresets': '快速预设', - 'provider.customProviders': '自定义服务商', - 'provider.active': '活跃', - 'provider.configured': '已配置', - 'provider.name': '名称', - 'provider.providerType': '服务商类型', - 'provider.apiKey': 'API 密钥', - 'provider.baseUrl': '基础 URL', - 'provider.modelName': '模型名称', - 'provider.advancedOptions': '高级选项', - 'provider.extraEnvVars': '额外环境变量', - 'provider.notes': '备注', - 'provider.notesPlaceholder': '关于此服务商的可选备注...', - 'provider.saving': '保存中...', - 'provider.update': '更新', - 'provider.envDetected': '从环境变量检测到', - 'provider.default': '默认', - 'provider.setDefault': '设为默认', - 'provider.environment': '环境变量', - 'provider.connectedProviders': '已连接的提供商', - 'provider.noConnected': '尚未连接任何提供商', - 'provider.connect': '连接', - 'provider.disconnect': '断开连接', - 'provider.disconnecting': '断开中...', - 'provider.disconnectProvider': '断开提供商', - 'provider.disconnectConfirm': '确定要断开"{name}"吗?此操作无法撤销。', - 'provider.ccSwitchHint': '通过类似 cc switch 等工具添加的 Claude Code 配置可能无法被 CodePilot 读取,建议在此处重新添加。', - 'provider.addProviderSection': '添加提供商', - 'provider.addProviderDesc': '选择要连接的提供商。大多数预设只需填写 API 密钥。', + "provider.addProvider": "添加服务商", + "provider.editProvider": "编辑服务商", + "provider.deleteProvider": "删除服务商", + "provider.deleteConfirm": '确定要删除"{name}"吗?此操作无法撤销。', + "provider.deleting": "删除中...", + "provider.delete": "删除", + "provider.noProviders": "未配置服务商", + "provider.addToStart": "添加服务商以开始使用", + "provider.quickPresets": "快速预设", + "provider.customProviders": "自定义服务商", + "provider.active": "活跃", + "provider.configured": "已配置", + "provider.name": "名称", + "provider.providerType": "服务商类型", + "provider.apiKey": "API 密钥", + "provider.baseUrl": "基础 URL", + "provider.modelName": "模型名称", + "provider.advancedOptions": "高级选项", + "provider.extraEnvVars": "额外环境变量", + "provider.notes": "备注", + "provider.notesPlaceholder": "关于此服务商的可选备注...", + "provider.saving": "保存中...", + "provider.update": "更新", + "provider.envDetected": "从环境变量检测到", + "provider.default": "默认", + "provider.setDefault": "设为默认", + "provider.environment": "环境变量", + "provider.connectedProviders": "已连接的提供商", + "provider.noConnected": "尚未连接任何提供商", + "provider.connect": "连接", + "provider.disconnect": "断开连接", + "provider.disconnecting": "断开中...", + "provider.disconnectProvider": "断开提供商", + "provider.disconnectConfirm": '确定要断开"{name}"吗?此操作无法撤销。', + "provider.ccSwitchHint": + "通过类似 cc switch 等工具添加的 Claude Code 配置可能无法被 CodePilot 读取,建议在此处重新添加。", + "provider.addProviderSection": "添加提供商", + "provider.addProviderDesc": + "选择要连接的提供商。大多数预设只需填写 API 密钥。", // ── Right panel / Files ───────────────────────────────────── - 'panel.files': '文件', - 'panel.tasks': '任务', - 'panel.openPanel': '打开面板', - 'panel.closePanel': '关闭面板', + "panel.files": "文件", + "panel.tasks": "任务", + "panel.openPanel": "打开面板", + "panel.closePanel": "关闭面板", // ── File tree ─────────────────────────────────────────────── - 'fileTree.filterFiles': '筛选文件...', - 'fileTree.refresh': '刷新', - 'fileTree.noFiles': '未找到文件', - 'fileTree.selectFolder': '选择项目文件夹以查看文件', + "fileTree.filterFiles": "筛选文件...", + "fileTree.refresh": "刷新", + "fileTree.noFiles": "未找到文件", + "fileTree.selectFolder": "选择项目文件夹以查看文件", // ── File preview ──────────────────────────────────────────── - 'filePreview.backToTree': '返回文件树', - 'filePreview.lines': '{count} 行', - 'filePreview.copyPath': '复制路径', - 'filePreview.failedToLoad': '加载文件失败', + "filePreview.backToTree": "返回文件树", + "filePreview.lines": "{count} 行", + "filePreview.copyPath": "复制路径", + "filePreview.failedToLoad": "加载文件失败", // ── Doc preview ───────────────────────────────────────────── - 'docPreview.htmlPreview': 'HTML 预览', + "docPreview.htmlPreview": "HTML 预览", // ── Extensions page ───────────────────────────────────────── - 'extensions.title': '扩展', - 'extensions.skills': '技能', - 'extensions.mcpServers': 'MCP 服务器', + "extensions.title": "扩展", + "extensions.skills": "技能", + "extensions.mcpServers": "MCP 服务器", // ── Skills ────────────────────────────────────────────────── - 'skills.noSelected': '未选择技能', - 'skills.selectOrCreate': '从列表中选择一个技能或创建新技能', - 'skills.newSkill': '新建技能', - 'skills.loadingSkills': '加载技能中...', - 'skills.noSkillsFound': '未找到技能', - 'skills.searchSkills': '搜索技能...', - 'skills.createSkill': '创建技能', - 'skills.nameRequired': '名称为必填项', - 'skills.nameInvalid': '名称只能包含字母、数字、连字符和下划线', - 'skills.skillName': '技能名称', - 'skills.scope': '作用域', - 'skills.global': '全局', - 'skills.project': '项目', - 'skills.template': '模板', - 'skills.blank': '空白', - 'skills.commitHelper': '提交助手', - 'skills.codeReviewer': '代码审查', - 'skills.saved': '已保存', - 'skills.save': '保存', - 'skills.edit': '编辑', - 'skills.preview': '预览', - 'skills.splitView': '分屏视图', - 'skills.deleteConfirm': '再次点击以确认', - 'skills.placeholder': '用 Markdown 编写技能提示词...', - 'skills.mySkills': '我的技能', - 'skills.marketplace': '技能市场', - 'skills.marketplaceSearch': '搜索 Skills.sh...', - 'skills.install': '安装', - 'skills.installed': '已安装', - 'skills.installing': '安装中...', - 'skills.uninstall': '卸载', - 'skills.installs': '次安装', - 'skills.source': '来源', - 'skills.installedAt': '安装时间', - 'skills.installSuccess': '安装完成', - 'skills.installFailed': '安装失败', - 'skills.searchNoResults': '未找到技能', - 'skills.marketplaceError': '加载技能市场失败', - 'skills.marketplaceHint': '浏览技能市场', - 'skills.marketplaceHintDesc': '搜索并安装来自 Skills.sh 的社区技能', - 'skills.noReadme': '暂无技能描述', + "skills.noSelected": "未选择技能", + "skills.selectOrCreate": "从列表中选择一个技能或创建新技能", + "skills.newSkill": "新建技能", + "skills.loadingSkills": "加载技能中...", + "skills.noSkillsFound": "未找到技能", + "skills.searchSkills": "搜索技能...", + "skills.createSkill": "创建技能", + "skills.nameRequired": "名称为必填项", + "skills.nameInvalid": "名称只能包含字母、数字、连字符和下划线", + "skills.skillName": "技能名称", + "skills.scope": "作用域", + "skills.global": "全局", + "skills.project": "项目", + "skills.template": "模板", + "skills.blank": "空白", + "skills.commitHelper": "提交助手", + "skills.codeReviewer": "代码审查", + "skills.saved": "已保存", + "skills.save": "保存", + "skills.edit": "编辑", + "skills.preview": "预览", + "skills.splitView": "分屏视图", + "skills.deleteConfirm": "再次点击以确认", + "skills.placeholder": "用 Markdown 编写技能提示词...", + "skills.mySkills": "我的技能", + "skills.marketplace": "技能市场", + "skills.marketplaceSearch": "搜索 Skills.sh...", + "skills.install": "安装", + "skills.installed": "已安装", + "skills.installing": "安装中...", + "skills.uninstall": "卸载", + "skills.installs": "次安装", + "skills.source": "来源", + "skills.installedAt": "安装时间", + "skills.installSuccess": "安装完成", + "skills.installFailed": "安装失败", + "skills.searchNoResults": "未找到技能", + "skills.marketplaceError": "加载技能市场失败", + "skills.marketplaceHint": "浏览技能市场", + "skills.marketplaceHintDesc": "搜索并安装来自 Skills.sh 的社区技能", + "skills.noReadme": "暂无技能描述", // ── MCP ───────────────────────────────────────────────────── - 'mcp.addServer': '添加服务器', - 'mcp.loadingServers': '加载 MCP 服务器中...', - 'mcp.serverConfig': 'MCP 服务器配置', - 'mcp.noServers': '未配置 MCP 服务器', - 'mcp.noServersDesc': '添加 MCP 服务器以扩展 Claude 的能力', - 'mcp.arguments': '参数:', - 'mcp.environment': '环境变量:', - 'mcp.listTab': '列表', - 'mcp.jsonTab': 'JSON 配置', - 'mcp.editServer': '编辑服务器', - 'mcp.serverName': '服务器名称', - 'mcp.serverType': '服务器类型', - 'mcp.command': '命令', - 'mcp.argsLabel': '参数(每行一个)', - 'mcp.url': 'URL', - 'mcp.headers': '请求头(JSON)', - 'mcp.envVars': '环境变量(JSON)', - 'mcp.formTab': '表单', - 'mcp.jsonEditTab': 'JSON', - 'mcp.saveChanges': '保存更改', + "mcp.addServer": "添加服务器", + "mcp.loadingServers": "加载 MCP 服务器中...", + "mcp.serverConfig": "MCP 服务器配置", + "mcp.noServers": "未配置 MCP 服务器", + "mcp.noServersDesc": "添加 MCP 服务器以扩展 Claude 的能力", + "mcp.arguments": "参数:", + "mcp.environment": "环境变量:", + "mcp.listTab": "列表", + "mcp.jsonTab": "JSON 配置", + "mcp.editServer": "编辑服务器", + "mcp.serverName": "服务器名称", + "mcp.serverType": "服务器类型", + "mcp.command": "命令", + "mcp.argsLabel": "参数(每行一个)", + "mcp.url": "URL", + "mcp.headers": "请求头(JSON)", + "mcp.envVars": "环境变量(JSON)", + "mcp.formTab": "表单", + "mcp.jsonEditTab": "JSON", + "mcp.saveChanges": "保存更改", // ── Folder picker ─────────────────────────────────────────── - 'folderPicker.title': '选择项目文件夹', - 'folderPicker.loading': '加载中...', - 'folderPicker.noSubdirs': '无子目录', - 'folderPicker.cancel': '取消', - 'folderPicker.select': '选择此文件夹', + "folderPicker.title": "选择项目文件夹", + "folderPicker.loading": "加载中...", + "folderPicker.noSubdirs": "无子目录", + "folderPicker.cancel": "取消", + "folderPicker.select": "选择此文件夹", // ── Import session dialog ─────────────────────────────────── - 'import.title': '从 Claude CLI 导入会话', - 'import.searchSessions': '搜索会话...', - 'import.noSessions': '未找到会话', - 'import.import': '导入', - 'import.importing': '导入中...', - 'import.justNow': '刚刚', - 'import.minutesAgo': '{n}分钟前', - 'import.hoursAgo': '{n}小时前', - 'import.daysAgo': '{n}天前', - 'import.messages': '{n} 条消息', - 'import.messagesPlural': '{n} 条消息', + "import.title": "从 Claude CLI 导入会话", + "import.searchSessions": "搜索会话...", + "import.noSessions": "未找到会话", + "import.import": "导入", + "import.importing": "导入中...", + "import.justNow": "刚刚", + "import.minutesAgo": "{n}分钟前", + "import.hoursAgo": "{n}小时前", + "import.daysAgo": "{n}天前", + "import.messages": "{n} 条消息", + "import.messagesPlural": "{n} 条消息", // ── Connection status ─────────────────────────────────────── - 'connection.notInstalled': 'Claude Code 未安装', - 'connection.installed': 'Claude Code 已安装', - 'connection.version': '版本:{version}', - 'connection.installPrompt': '要使用 Claude Code 功能,您需要安装 Claude Code CLI。', - 'connection.runCommand': '在终端中运行以下命令:', - 'connection.installAuto': '自动安装 Claude Code', - 'connection.refresh': '刷新', - 'connection.installClaude': '安装 Claude Code', - 'connection.connected': '已连接', - 'connection.disconnected': '未连接', - 'connection.checking': '检测中', + "connection.notInstalled": "Claude Code 未安装", + "connection.installed": "Claude Code 已安装", + "connection.version": "版本:{version}", + "connection.installPrompt": + "要使用 Claude Code 功能,您需要安装 Claude Code CLI。", + "connection.runCommand": "在终端中运行以下命令:", + "connection.installAuto": "自动安装 Claude Code", + "connection.refresh": "刷新", + "connection.installClaude": "安装 Claude Code", + "connection.connected": "已连接", + "connection.disconnected": "未连接", + "connection.checking": "检测中", // ── Install wizard ────────────────────────────────────────── - 'install.title': '安装 Claude Code', - 'install.checkingPrereqs': '检查前置条件...', - 'install.alreadyInstalled': 'Claude Code 已安装', - 'install.readyToInstall': '准备安装', - 'install.installing': '正在安装 Claude Code...', - 'install.complete': '安装完成', - 'install.failed': '安装失败', - 'install.copyLogs': '复制日志', - 'install.copied': '已复制', - 'install.install': '安装', - 'install.cancel': '取消', - 'install.retry': '重试', - 'install.done': '完成', - 'install.recheck': '重新检测', - 'install.copy': '复制', - 'install.homebrewRequired': '需要先安装 Homebrew', - 'install.homebrewDescription': 'Homebrew 是 macOS 的包管理器,安装 Node.js 需要它。', - 'install.homebrewSteps': '请按以下步骤操作:', - 'install.homebrewStep1': '打开终端(Terminal)', - 'install.homebrewStep2': '粘贴上面的命令并按回车', - 'install.homebrewStep3': '按提示完成安装', - 'install.homebrewStep4': '回到这里点击"重新检测"', + "install.title": "安装 Claude Code", + "install.checkingPrereqs": "检查前置条件...", + "install.alreadyInstalled": "Claude Code 已安装", + "install.readyToInstall": "准备安装", + "install.installing": "正在安装 Claude Code...", + "install.complete": "安装完成", + "install.failed": "安装失败", + "install.copyLogs": "复制日志", + "install.copied": "已复制", + "install.install": "安装", + "install.cancel": "取消", + "install.retry": "重试", + "install.done": "完成", + "install.recheck": "重新检测", + "install.copy": "复制", + "install.homebrewRequired": "需要先安装 Homebrew", + "install.homebrewDescription": + "Homebrew 是 macOS 的包管理器,安装 Node.js 需要它。", + "install.homebrewSteps": "请按以下步骤操作:", + "install.homebrewStep1": "打开终端(Terminal)", + "install.homebrewStep2": "粘贴上面的命令并按回车", + "install.homebrewStep3": "按提示完成安装", + "install.homebrewStep4": '回到这里点击"重新检测"', // ── Task list ─────────────────────────────────────────────── - 'tasks.all': '全部', - 'tasks.active': '进行中', - 'tasks.done': '已完成', - 'tasks.addPlaceholder': '添加任务...', - 'tasks.addTask': '添加任务', - 'tasks.loading': '加载任务中...', - 'tasks.noTasks': '暂无任务', - 'tasks.noMatching': '无匹配任务', + "tasks.all": "全部", + "tasks.active": "进行中", + "tasks.done": "已完成", + "tasks.addPlaceholder": "添加任务...", + "tasks.addTask": "添加任务", + "tasks.loading": "加载任务中...", + "tasks.noTasks": "暂无任务", + "tasks.noMatching": "无匹配任务", // ── Tool call block ───────────────────────────────────────── - 'tool.running': '运行中', - 'tool.success': '成功', - 'tool.error': '错误', + "tool.running": "运行中", + "tool.success": "成功", + "tool.error": "错误", // ── Common ────────────────────────────────────────────────── - 'common.cancel': '取消', - 'common.save': '保存', - 'common.delete': '删除', - 'common.loading': '加载中...', - 'common.close': '关闭', - 'common.enabled': '已启用', - 'common.disabled': '已禁用', + "common.cancel": "取消", + "common.save": "保存", + "common.delete": "删除", + "common.loading": "加载中...", + "common.close": "关闭", + "common.enabled": "已启用", + "common.disabled": "已禁用", // ── Error boundary ──────────────────────────────────────── - 'error.title': '出错了', - 'error.description': '发生了意外错误。您可以重试或重新加载应用。', - 'error.showDetails': '显示详情', - 'error.hideDetails': '隐藏详情', - 'error.tryAgain': '重试', - 'error.reloadApp': '重新加载', + "error.title": "出错了", + "error.description": "发生了意外错误。您可以重试或重新加载应用。", + "error.showDetails": "显示详情", + "error.hideDetails": "隐藏详情", + "error.tryAgain": "重试", + "error.reloadApp": "重新加载", // ── Update ───────────────────────────────────────────────── - 'update.newVersionAvailable': '有新版本可用', - 'update.downloading': '下载中', - 'update.restartToUpdate': '重启以更新', - 'update.restartNow': '立即重启', - 'update.readyToInstall': 'CodePilot v{version} 已就绪 — 重启以完成更新', - 'update.installUpdate': '下载并安装', - 'update.later': '稍后', + "update.newVersionAvailable": "有新版本可用", + "update.downloading": "下载中", + "update.restartToUpdate": "重启以更新", + "update.restartNow": "立即重启", + "update.readyToInstall": "CodePilot v{version} 已就绪 — 重启以完成更新", + "update.installUpdate": "下载并安装", + "update.later": "稍后", // ── Image Generation ────────────────────────────────────── - 'imageGen.toggle': '图片生成', - 'imageGen.toggleLabel': 'Image Agent', - 'imageGen.toggleTooltip': '切换 Image Agent — AI 自动分析意图,支持单张和批量生图', - 'imageGen.generating': '正在生成图片...', - 'imageGen.params': '生成参数', - 'imageGen.aspectRatio': '画面比例', - 'imageGen.resolution': '分辨率', - 'imageGen.generate': '生成', - 'imageGen.regenerate': '重新生成', - 'imageGen.download': '下载', - 'imageGen.prompt': '提示词', - 'imageGen.model': '模型', - 'imageGen.noApiKey': '请先在设置 > 服务商中配置 Gemini API Key', - 'imageGen.error': '图片生成失败', - 'imageGen.success': '图片生成成功', - 'imageGen.referenceImages': '参考图片', - 'imageGen.uploadReference': '上传参考图片', - 'imageGen.resetChat': '重置对话', - 'imageGen.multiTurnHint': '你可以描述需要的修改来调整生成的图片', - 'imageGen.settings': '生成设置', - 'imageGen.generated': '生成的图片', - 'imageGen.confirmTitle': '图片生成', - 'imageGen.editPrompt': '编辑提示词', - 'imageGen.generateButton': '生成', - 'imageGen.retryButton': '重试', - 'imageGen.generatingStatus': '正在生成...', - 'imageGen.stopButton': '停止', + "imageGen.toggle": "图片生成", + "imageGen.toggleLabel": "Image Agent", + "imageGen.toggleTooltip": + "切换 Image Agent — AI 自动分析意图,支持单张和批量生图", + "imageGen.generating": "正在生成图片...", + "imageGen.params": "生成参数", + "imageGen.aspectRatio": "画面比例", + "imageGen.resolution": "分辨率", + "imageGen.generate": "生成", + "imageGen.regenerate": "重新生成", + "imageGen.download": "下载", + "imageGen.prompt": "提示词", + "imageGen.model": "模型", + "imageGen.noApiKey": "请先在设置 > 服务商中配置 Gemini API Key", + "imageGen.error": "图片生成失败", + "imageGen.success": "图片生成成功", + "imageGen.referenceImages": "参考图片", + "imageGen.uploadReference": "上传参考图片", + "imageGen.resetChat": "重置对话", + "imageGen.multiTurnHint": "你可以描述需要的修改来调整生成的图片", + "imageGen.settings": "生成设置", + "imageGen.generated": "生成的图片", + "imageGen.confirmTitle": "图片生成", + "imageGen.editPrompt": "编辑提示词", + "imageGen.generateButton": "生成", + "imageGen.retryButton": "重试", + "imageGen.generatingStatus": "正在生成...", + "imageGen.stopButton": "停止", // ── Batch Image Generation ───────────────────────────────── - 'batchImageGen.toggle': '批量生图', - 'batchImageGen.toggleTooltip': '切换批量图片生成模式', - 'batchImageGen.entryTitle': '批量图片生成', - 'batchImageGen.stylePrompt': '风格提示词', - 'batchImageGen.stylePromptPlaceholder': '描述所有图片的视觉风格...', - 'batchImageGen.uploadDocs': '上传文档', - 'batchImageGen.uploadDocsHint': '上传文本/Markdown 文件作为内容来源', - 'batchImageGen.count': '图片数量', - 'batchImageGen.countAuto': '自动', - 'batchImageGen.aspectRatioStrategy': '画面比例', - 'batchImageGen.resolution': '分辨率', - 'batchImageGen.generatePlan': '生成计划', - 'batchImageGen.planning': '规划中...', - 'batchImageGen.planPreviewTitle': '生成计划', - 'batchImageGen.planSummary': '计划摘要', - 'batchImageGen.editPrompt': '编辑提示词', - 'batchImageGen.addItem': '添加项目', - 'batchImageGen.removeItem': '删除', - 'batchImageGen.regeneratePlan': '重新生成计划', - 'batchImageGen.confirmAndExecute': '确认并执行', - 'batchImageGen.executing': '执行中...', - 'batchImageGen.totalProgress': '进度', - 'batchImageGen.itemPending': '等待中', - 'batchImageGen.itemProcessing': '生成中...', - 'batchImageGen.itemCompleted': '完成', - 'batchImageGen.itemFailed': '失败', - 'batchImageGen.retryFailed': '重试失败项', - 'batchImageGen.retryItem': '重试', - 'batchImageGen.pause': '暂停', - 'batchImageGen.resume': '继续', - 'batchImageGen.cancel': '取消', - 'batchImageGen.syncToChat': '同步到对话', - 'batchImageGen.syncComplete': '已同步到对话', - 'batchImageGen.syncMode': '同步模式', - 'batchImageGen.syncManual': '手动', - 'batchImageGen.syncAutoBatch': '自动批量', - 'batchImageGen.completed': '批量生成完成', - 'batchImageGen.completedStats': '已生成 {completed}/{total} 张图片', - 'batchImageGen.noProvider': '请先在设置中配置文本生成服务商', - 'batchImageGen.tags': '标签', - 'batchImageGen.sourceRefs': '来源', - 'batchImageGen.prompt': '提示词', - 'batchImageGen.aspectRatio': '比例', - 'batchImageGen.imageSize': '尺寸', + "batchImageGen.toggle": "批量生图", + "batchImageGen.toggleTooltip": "切换批量图片生成模式", + "batchImageGen.entryTitle": "批量图片生成", + "batchImageGen.stylePrompt": "风格提示词", + "batchImageGen.stylePromptPlaceholder": "描述所有图片的视觉风格...", + "batchImageGen.uploadDocs": "上传文档", + "batchImageGen.uploadDocsHint": "上传文本/Markdown 文件作为内容来源", + "batchImageGen.count": "图片数量", + "batchImageGen.countAuto": "自动", + "batchImageGen.aspectRatioStrategy": "画面比例", + "batchImageGen.resolution": "分辨率", + "batchImageGen.generatePlan": "生成计划", + "batchImageGen.planning": "规划中...", + "batchImageGen.planPreviewTitle": "生成计划", + "batchImageGen.planSummary": "计划摘要", + "batchImageGen.editPrompt": "编辑提示词", + "batchImageGen.addItem": "添加项目", + "batchImageGen.removeItem": "删除", + "batchImageGen.regeneratePlan": "重新生成计划", + "batchImageGen.confirmAndExecute": "确认并执行", + "batchImageGen.executing": "执行中...", + "batchImageGen.totalProgress": "进度", + "batchImageGen.itemPending": "等待中", + "batchImageGen.itemProcessing": "生成中...", + "batchImageGen.itemCompleted": "完成", + "batchImageGen.itemFailed": "失败", + "batchImageGen.retryFailed": "重试失败项", + "batchImageGen.retryItem": "重试", + "batchImageGen.pause": "暂停", + "batchImageGen.resume": "继续", + "batchImageGen.cancel": "取消", + "batchImageGen.syncToChat": "同步到对话", + "batchImageGen.syncComplete": "已同步到对话", + "batchImageGen.syncMode": "同步模式", + "batchImageGen.syncManual": "手动", + "batchImageGen.syncAutoBatch": "自动批量", + "batchImageGen.completed": "批量生成完成", + "batchImageGen.completedStats": "已生成 {completed}/{total} 张图片", + "batchImageGen.noProvider": "请先在设置中配置文本生成服务商", + "batchImageGen.tags": "标签", + "batchImageGen.sourceRefs": "来源", + "batchImageGen.prompt": "提示词", + "batchImageGen.aspectRatio": "比例", + "batchImageGen.imageSize": "尺寸", // ── Gallery ───────────────────────────────────────────────── - 'gallery.title': '素材库', - 'gallery.empty': '暂无生成的图片', - 'gallery.emptyHint': '在对话中生成图片后即可在此查看。', - 'gallery.filterByTag': '按标签筛选', - 'gallery.dateFrom': '开始日期', - 'gallery.dateTo': '结束日期', - 'gallery.sortNewest': '最新优先', - 'gallery.sortOldest': '最早优先', - 'gallery.newestFirst': '最新优先', - 'gallery.oldestFirst': '最早优先', - 'gallery.filters': '筛选', - 'gallery.clearFilters': '清除筛选', - 'gallery.loadMore': '加载更多', - 'gallery.openChat': '打开对话', - 'gallery.delete': '删除', - 'gallery.confirmDelete': '确认删除?', - 'gallery.deleteConfirm': '确定删除这张图片?此操作无法撤销。', - 'gallery.tags': '标签', - 'gallery.addTag': '添加标签', - 'gallery.newTag': '新标签名称', - 'gallery.newTagPlaceholder': '新标签名称...', - 'gallery.cancel': '取消', - 'gallery.removeTag': '移除标签', - 'gallery.noTags': '暂无标签', - 'gallery.generatedAt': '生成时间', - 'gallery.viewDetails': '查看详情', - 'gallery.imageDetail': '图片详情', - 'gallery.prompt': '提示词', - 'gallery.download': '下载', - 'gallery.favorites': '收藏', - 'gallery.favoritesOnly': '只看收藏', - 'gallery.addToFavorites': '添加到收藏', - 'gallery.removeFromFavorites': '取消收藏', + "gallery.title": "素材库", + "gallery.empty": "暂无生成的图片", + "gallery.emptyHint": "在对话中生成图片后即可在此查看。", + "gallery.filterByTag": "按标签筛选", + "gallery.dateFrom": "开始日期", + "gallery.dateTo": "结束日期", + "gallery.sortNewest": "最新优先", + "gallery.sortOldest": "最早优先", + "gallery.newestFirst": "最新优先", + "gallery.oldestFirst": "最早优先", + "gallery.filters": "筛选", + "gallery.clearFilters": "清除筛选", + "gallery.loadMore": "加载更多", + "gallery.openChat": "打开对话", + "gallery.delete": "删除", + "gallery.confirmDelete": "确认删除?", + "gallery.deleteConfirm": "确定删除这张图片?此操作无法撤销。", + "gallery.tags": "标签", + "gallery.addTag": "添加标签", + "gallery.newTag": "新标签名称", + "gallery.newTagPlaceholder": "新标签名称...", + "gallery.cancel": "取消", + "gallery.removeTag": "移除标签", + "gallery.noTags": "暂无标签", + "gallery.generatedAt": "生成时间", + "gallery.viewDetails": "查看详情", + "gallery.imageDetail": "图片详情", + "gallery.prompt": "提示词", + "gallery.download": "下载", + "gallery.favorites": "收藏", + "gallery.favoritesOnly": "只看收藏", + "gallery.addToFavorites": "添加到收藏", + "gallery.removeFromFavorites": "取消收藏", // ── Provider: Gemini Image ────────────────────────────────── - 'provider.chatProviders': '聊天服务商', - 'provider.mediaProviders': '媒体服务商', - 'provider.geminiImageDesc': 'Nano Banana Pro — Google Gemini AI 图片生成', + "provider.chatProviders": "聊天服务商", + "provider.mediaProviders": "媒体服务商", + "provider.geminiImageDesc": "Nano Banana Pro — Google Gemini AI 图片生成", // ── CLI dynamic field labels ────────────────────────────── - 'cli.loadingSettings': '加载设置中...', - 'cli.field.skipDangerousModePermissionPrompt': '跳过危险模式权限提示', - 'cli.field.verbose': '详细日志', - 'cli.field.theme': '主题', - 'cli.formatError': '无法格式化:无效的 JSON', + "cli.loadingSettings": "加载设置中...", + "cli.field.skipDangerousModePermissionPrompt": "跳过危险模式权限提示", + "cli.field.verbose": "详细日志", + "cli.field.theme": "主题", + "cli.formatError": "无法格式化:无效的 JSON", // ── Split screen ───────────────────────────────────────────── - 'split.splitScreen': '分屏', - 'split.closeSplit': '关闭分屏', - 'split.splitGroup': '分屏', - 'chatList.splitScreen': '分屏', + "split.splitScreen": "分屏", + "split.closeSplit": "关闭分屏", + "split.splitGroup": "分屏", + "chatList.splitScreen": "分屏", // ── Telegram (Bridge) ────────────────────────────────────── - 'telegram.credentials': 'Bot 凭据', - 'telegram.credentialsDesc': '输入您的 Telegram Bot Token 和 Chat ID', - 'telegram.botToken': 'Bot Token', - 'telegram.chatId': 'Chat ID', - 'telegram.chatIdHint': '先向您的 Bot 发送 /start,然后点击「自动检测」填入 Chat ID', - 'telegram.detectChatId': '自动检测', - 'telegram.chatIdDetected': '检测到 Chat ID:{id}({name})', - 'telegram.chatIdDetectFailed': '无法检测 Chat ID。请先向 Bot 发送 /start,然后重试。', - 'telegram.verify': '测试连接', - 'telegram.verified': '连接验证成功', - 'telegram.verifiedAs': '已连接为 @{name}', - 'telegram.verifyFailed': '连接失败', - 'telegram.enterTokenFirst': '请先输入 Bot Token', - 'telegram.setupGuide': '设置指南', - 'telegram.step1': '打开 Telegram 搜索 @BotFather', - 'telegram.step2': '发送 /newbot 并按提示创建 Bot', - 'telegram.step3': '复制 Bot Token 并粘贴到上方', - 'telegram.step4': '点击「测试连接」验证 Token 是否有效', - 'telegram.step5': '向您的 Bot 发送 /start,然后点击 Chat ID 旁的「自动检测」按钮', - 'telegram.step6': '点击「保存」存储凭据', + "telegram.credentials": "Bot 凭据", + "telegram.credentialsDesc": "输入您的 Telegram Bot Token 和 Chat ID", + "telegram.botToken": "Bot Token", + "telegram.chatId": "Chat ID", + "telegram.chatIdHint": + "先向您的 Bot 发送 /start,然后点击「自动检测」填入 Chat ID", + "telegram.detectChatId": "自动检测", + "telegram.chatIdDetected": "检测到 Chat ID:{id}({name})", + "telegram.chatIdDetectFailed": + "无法检测 Chat ID。请先向 Bot 发送 /start,然后重试。", + "telegram.verify": "测试连接", + "telegram.verified": "连接验证成功", + "telegram.verifiedAs": "已连接为 @{name}", + "telegram.verifyFailed": "连接失败", + "telegram.enterTokenFirst": "请先输入 Bot Token", + "telegram.setupGuide": "设置指南", + "telegram.step1": "打开 Telegram 搜索 @BotFather", + "telegram.step2": "发送 /newbot 并按提示创建 Bot", + "telegram.step3": "复制 Bot Token 并粘贴到上方", + "telegram.step4": "点击「测试连接」验证 Token 是否有效", + "telegram.step5": + "向您的 Bot 发送 /start,然后点击 Chat ID 旁的「自动检测」按钮", + "telegram.step6": "点击「保存」存储凭据", // ── Feishu (Bridge) ────────────────────────────────────── - 'feishu.credentials': '应用凭据', - 'feishu.credentialsDesc': '输入您的飞书 App ID 和 App Secret', - 'feishu.appId': 'App ID', - 'feishu.appSecret': 'App Secret', - 'feishu.domain': '平台', - 'feishu.domainFeishu': '飞书 (feishu.cn)', - 'feishu.domainLark': 'Lark (larksuite.com)', - 'feishu.domainHint': '中国大陆选择飞书,海外选择 Lark', - 'feishu.verify': '测试连接', - 'feishu.verified': '连接验证成功', - 'feishu.verifiedAs': '已连接为 {name}', - 'feishu.verifyFailed': '连接失败', - 'feishu.enterCredentialsFirst': '请先输入 App ID 和 App Secret', - 'feishu.allowedUsers': '允许的用户', - 'feishu.allowedUsersDesc': '允许使用桥接的 open_id 或 chat_id,逗号分隔', - 'feishu.allowedUsersHint': '留空则允许所有用户', - 'feishu.groupSettings': '群聊设置', - 'feishu.groupSettingsDesc': '控制机器人在群聊中的响应方式', - 'feishu.groupPolicy': '群聊策略', - 'feishu.groupPolicyOpen': '开放 — 响应所有群消息', - 'feishu.groupPolicyAllowlist': '白名单 — 仅指定群组', - 'feishu.groupPolicyDisabled': '禁用 — 忽略所有群消息', - 'feishu.groupAllowFrom': '允许的群组', - 'feishu.groupAllowFromHint': '允许的群组 chat_id,逗号分隔', - 'feishu.requireMention': '需要 @提及', - 'feishu.requireMentionDesc': '群聊中仅在 @机器人时响应', - 'feishu.setupGuide': '设置指南', - 'feishu.step1': '前往飞书开放平台 (open.feishu.cn) 创建自建应用', - 'feishu.step2': '在应用功能中启用「机器人」能力', - 'feishu.step3': '在凭证页面复制 App ID 和 App Secret', - 'feishu.step4': '添加事件订阅:im.message.receive_v1', - 'feishu.step5': '发布应用版本并在管理后台审批通过', - 'feishu.step6': '将凭据粘贴到上方,点击「测试连接」验证', + "feishu.credentials": "应用凭据", + "feishu.credentialsDesc": "输入您的飞书 App ID 和 App Secret", + "feishu.appId": "App ID", + "feishu.appSecret": "App Secret", + "feishu.domain": "平台", + "feishu.domainFeishu": "飞书 (feishu.cn)", + "feishu.domainLark": "Lark (larksuite.com)", + "feishu.domainHint": "中国大陆选择飞书,海外选择 Lark", + "feishu.verify": "测试连接", + "feishu.verified": "连接验证成功", + "feishu.verifiedAs": "已连接为 {name}", + "feishu.verifyFailed": "连接失败", + "feishu.enterCredentialsFirst": "请先输入 App ID 和 App Secret", + "feishu.allowedUsers": "允许的用户", + "feishu.allowedUsersDesc": "允许使用桥接的 open_id 或 chat_id,逗号分隔", + "feishu.allowedUsersHint": "留空则允许所有用户", + "feishu.groupSettings": "群聊设置", + "feishu.groupSettingsDesc": "控制机器人在群聊中的响应方式", + "feishu.groupPolicy": "群聊策略", + "feishu.groupPolicyOpen": "开放 — 响应所有群消息", + "feishu.groupPolicyAllowlist": "白名单 — 仅指定群组", + "feishu.groupPolicyDisabled": "禁用 — 忽略所有群消息", + "feishu.groupAllowFrom": "允许的群组", + "feishu.groupAllowFromHint": "允许的群组 chat_id,逗号分隔", + "feishu.requireMention": "需要 @提及", + "feishu.requireMentionDesc": "群聊中仅在 @机器人时响应", + "feishu.setupGuide": "设置指南", + "feishu.step1": "前往飞书开放平台 (open.feishu.cn) 创建自建应用", + "feishu.step2": "在应用功能中启用「机器人」能力", + "feishu.step3": "在凭证页面复制 App ID 和 App Secret", + "feishu.step4": "添加事件订阅:im.message.receive_v1", + "feishu.step5": "发布应用版本并在管理后台审批通过", + "feishu.step6": "将凭据粘贴到上方,点击「测试连接」验证", // ── Settings: Remote Bridge ──────────────────────────────── - 'settings.bridge': '远程桥接', - 'bridge.title': '远程桥接', - 'bridge.description': '通过 Telegram、飞书等外部渠道控制 Claude', - 'bridge.enabled': '启用远程桥接', - 'bridge.enabledDesc': '允许外部消息渠道与 Claude 交互', - 'bridge.activeHint': '桥接已激活。外部渠道可以向 Claude 发送任务。', - 'bridge.status': '桥接状态', - 'bridge.statusConnected': '已连接', - 'bridge.statusDisconnected': '未连接', - 'bridge.statusStarting': '启动中...', - 'bridge.statusStopping': '停止中...', - 'bridge.activeBindings': '{count} 个活跃绑定', - 'bridge.noBindings': '无活跃绑定', - 'bridge.channels': '渠道', - 'bridge.channelsDesc': '启用或禁用各消息渠道', - 'bridge.telegramChannel': 'Telegram', - 'bridge.telegramChannelDesc': '通过 Telegram Bot 接收和回复消息', - 'bridge.bindings': '活跃会话', - 'bridge.bindingsDesc': '来自外部渠道的当前会话绑定', - 'bridge.bindingChat': '对话', - 'bridge.bindingChannel': '渠道', - 'bridge.bindingCreated': '创建时间', - 'bridge.bindingStatus': '状态', - 'bridge.noActiveBindings': '无活跃会话绑定', - 'bridge.defaults': '默认设置', - 'bridge.defaultsDesc': '桥接发起会话的默认设置', - 'bridge.defaultWorkDir': '工作目录', - 'bridge.defaultWorkDirHint': '桥接会话的默认项目文件夹', - 'bridge.defaultModel': '模型', - 'bridge.defaultModelHint': '桥接会话的默认模型', - 'bridge.browse': '浏览', - 'bridge.start': '启动桥接', - 'bridge.stop': '停止桥接', - 'bridge.starting': '启动中...', - 'bridge.stopping': '停止中...', - 'bridge.autoStart': '自动启动桥接', - 'bridge.autoStartDesc': '应用启动时自动启动桥接', - 'bridge.adapterRunning': '运行中', - 'bridge.adapterStopped': '已停止', - 'bridge.adapterLastMessage': '最近消息', - 'bridge.adapterLastError': '最近错误', - 'bridge.adapters': '适配器状态', - 'bridge.adaptersDesc': '各渠道适配器的实时状态', - 'bridge.telegramSettings': 'Telegram 设置', - 'bridge.telegramSettingsDesc': '配置桥接使用的 Telegram Bot 凭据', - 'bridge.allowedUsers': '允许的用户', - 'bridge.allowedUsersDesc': '允许使用桥接的 Telegram 用户 ID,逗号分隔', - 'bridge.allowedUsersHint': '留空则仅允许上方配置的 Chat ID', - 'bridge.feishuChannel': '飞书', - 'bridge.feishuChannelDesc': '通过飞书机器人接收和回复消息', - 'bridge.feishuSettings': '飞书设置', - 'bridge.feishuSettingsDesc': '配置桥接使用的飞书应用凭据', - 'bridge.discordChannel': 'Discord', - 'bridge.discordChannelDesc': '通过 Discord Bot 接收和回复消息', - 'bridge.discordSettings': 'Discord 设置', - 'bridge.discordSettingsDesc': '配置桥接使用的 Discord Bot 凭据', + "settings.bridge": "远程桥接", + "bridge.title": "远程桥接", + "bridge.description": "通过 Telegram、飞书等外部渠道控制 Claude", + "bridge.enabled": "启用远程桥接", + "bridge.enabledDesc": "允许外部消息渠道与 Claude 交互", + "bridge.activeHint": "桥接已激活。外部渠道可以向 Claude 发送任务。", + "bridge.status": "桥接状态", + "bridge.statusConnected": "已连接", + "bridge.statusDisconnected": "未连接", + "bridge.statusStarting": "启动中...", + "bridge.statusStopping": "停止中...", + "bridge.activeBindings": "{count} 个活跃绑定", + "bridge.noBindings": "无活跃绑定", + "bridge.channels": "渠道", + "bridge.channelsDesc": "启用或禁用各消息渠道", + "bridge.telegramChannel": "Telegram", + "bridge.telegramChannelDesc": "通过 Telegram Bot 接收和回复消息", + "bridge.bindings": "活跃会话", + "bridge.bindingsDesc": "来自外部渠道的当前会话绑定", + "bridge.bindingChat": "对话", + "bridge.bindingChannel": "渠道", + "bridge.bindingCreated": "创建时间", + "bridge.bindingStatus": "状态", + "bridge.noActiveBindings": "无活跃会话绑定", + "bridge.defaults": "默认设置", + "bridge.defaultsDesc": "桥接发起会话的默认设置", + "bridge.defaultWorkDir": "工作目录", + "bridge.defaultWorkDirHint": "桥接会话的默认项目文件夹", + "bridge.defaultModel": "模型", + "bridge.defaultModelHint": "桥接会话的默认模型", + "bridge.browse": "浏览", + "bridge.start": "启动桥接", + "bridge.stop": "停止桥接", + "bridge.starting": "启动中...", + "bridge.stopping": "停止中...", + "bridge.autoStart": "自动启动桥接", + "bridge.autoStartDesc": "应用启动时自动启动桥接", + "bridge.adapterRunning": "运行中", + "bridge.adapterStopped": "已停止", + "bridge.adapterLastMessage": "最近消息", + "bridge.adapterLastError": "最近错误", + "bridge.adapters": "适配器状态", + "bridge.adaptersDesc": "各渠道适配器的实时状态", + "bridge.telegramSettings": "Telegram 设置", + "bridge.telegramSettingsDesc": "配置桥接使用的 Telegram Bot 凭据", + "bridge.allowedUsers": "允许的用户", + "bridge.allowedUsersDesc": "允许使用桥接的 Telegram 用户 ID,逗号分隔", + "bridge.allowedUsersHint": "留空则仅允许上方配置的 Chat ID", + "bridge.feishuChannel": "飞书", + "bridge.feishuChannelDesc": "通过飞书机器人接收和回复消息", + "bridge.feishuSettings": "飞书设置", + "bridge.feishuSettingsDesc": "配置桥接使用的飞书应用凭据", + "bridge.discordChannel": "Discord", + "bridge.discordChannelDesc": "通过 Discord Bot 接收和回复消息", + "bridge.discordSettings": "Discord 设置", + "bridge.discordSettingsDesc": "配置桥接使用的 Discord Bot 凭据", // ── Settings: Discord Bridge ───────────────────────────────── - 'discord.credentials': 'Bot 凭据', - 'discord.credentialsDesc': '输入您的 Discord Bot Token', - 'discord.botToken': 'Bot Token', - 'discord.verify': '测试连接', - 'discord.verified': '连接验证成功', - 'discord.verifiedAs': '已连接为 {name}', - 'discord.verifyFailed': '连接失败', - 'discord.enterTokenFirst': '请先输入 Bot Token', - 'discord.allowedUsers': '授权设置', - 'discord.allowedUsersDesc': '控制哪些用户和频道可以与 Bot 交互', - 'discord.allowedUserIds': '允许的用户 ID', - 'discord.allowedUsersHint': 'Discord 用户 ID,多个用逗号分隔。用户和频道均为空时拒绝所有请求。', - 'discord.allowedChannelIds': '允许的频道 ID', - 'discord.allowedChannelsHint': '允许的 Discord 频道 ID,多个用逗号分隔。', - 'discord.guildSettings': '服务器和群组设置', - 'discord.guildSettingsDesc': '控制 Bot 在 Discord 服务器中的响应方式', - 'discord.allowedGuilds': '允许的服务器 (Guild) ID', - 'discord.allowedGuildsHint': '服务器 ID,多个用逗号分隔。留空则允许所有服务器。', - 'discord.groupPolicy': '服务器消息策略', - 'discord.groupPolicyOpen': '开放 — 响应所有服务器频道消息', - 'discord.groupPolicyDisabled': '禁用 — 忽略所有服务器消息(仅私信)', - 'discord.requireMention': '需要 @提及', - 'discord.requireMentionDesc': '服务器中仅在 @机器人时响应', - 'discord.streamPreview': '流式预览', - 'discord.streamPreviewDesc': '通过编辑消息实时显示响应预览', - 'discord.setupGuide': '配置指南', - 'discord.setupBotTitle': '创建 Discord Bot', - 'discord.step1': '前往 Discord 开发者门户 (discord.com/developers),点击「New Application」创建应用', - 'discord.step2': '进入左侧「Bot」页面,点击「Add Bot」创建机器人', - 'discord.step3': '向下滚动到「Privileged Gateway Intents」,开启「MESSAGE CONTENT INTENT」开关', - 'discord.step4': '点击「Reset Token」复制 Bot Token,粘贴到上方「Bot 凭据」中保存', - 'discord.step5': '前往「OAuth2 → URL Generator」,Scopes 勾选「bot」,Bot Permissions 勾选「Send Messages」和「Read Message History」', - 'discord.step6': '复制生成的邀请链接,在浏览器中打开,将 Bot 邀请到你的服务器', - 'discord.step7': '回到上方点击「测试连接」,确认显示 Bot 名称即配置成功', - 'discord.setupIdTitle': '获取 ID(需开启开发者模式)', - 'discord.stepDevMode': '打开 Discord 客户端 →「用户设置」→「高级」→ 开启「开发者模式」', - 'discord.stepUserId': '获取用户 ID:右键点击自己的头像或用户名 →「复制用户 ID」', - 'discord.stepChannelId': '获取频道 ID:右键点击左侧频道名称 →「复制频道 ID」', - 'discord.stepGuildId': '获取服务器 ID:右键点击左上角服务器名称 →「复制服务器 ID」', + "discord.credentials": "Bot 凭据", + "discord.credentialsDesc": "输入您的 Discord Bot Token", + "discord.botToken": "Bot Token", + "discord.verify": "测试连接", + "discord.verified": "连接验证成功", + "discord.verifiedAs": "已连接为 {name}", + "discord.verifyFailed": "连接失败", + "discord.enterTokenFirst": "请先输入 Bot Token", + "discord.allowedUsers": "授权设置", + "discord.allowedUsersDesc": "控制哪些用户和频道可以与 Bot 交互", + "discord.allowedUserIds": "允许的用户 ID", + "discord.allowedUsersHint": + "Discord 用户 ID,多个用逗号分隔。用户和频道均为空时拒绝所有请求。", + "discord.allowedChannelIds": "允许的频道 ID", + "discord.allowedChannelsHint": "允许的 Discord 频道 ID,多个用逗号分隔。", + "discord.guildSettings": "服务器和群组设置", + "discord.guildSettingsDesc": "控制 Bot 在 Discord 服务器中的响应方式", + "discord.allowedGuilds": "允许的服务器 (Guild) ID", + "discord.allowedGuildsHint": + "服务器 ID,多个用逗号分隔。留空则允许所有服务器。", + "discord.groupPolicy": "服务器消息策略", + "discord.groupPolicyOpen": "开放 — 响应所有服务器频道消息", + "discord.groupPolicyDisabled": "禁用 — 忽略所有服务器消息(仅私信)", + "discord.requireMention": "需要 @提及", + "discord.requireMentionDesc": "服务器中仅在 @机器人时响应", + "discord.streamPreview": "流式预览", + "discord.streamPreviewDesc": "通过编辑消息实时显示响应预览", + "discord.setupGuide": "配置指南", + "discord.setupBotTitle": "创建 Discord Bot", + "discord.step1": + "前往 Discord 开发者门户 (discord.com/developers),点击「New Application」创建应用", + "discord.step2": "进入左侧「Bot」页面,点击「Add Bot」创建机器人", + "discord.step3": + "向下滚动到「Privileged Gateway Intents」,开启「MESSAGE CONTENT INTENT」开关", + "discord.step4": + "点击「Reset Token」复制 Bot Token,粘贴到上方「Bot 凭据」中保存", + "discord.step5": + "前往「OAuth2 → URL Generator」,Scopes 勾选「bot」,Bot Permissions 勾选「Send Messages」和「Read Message History」", + "discord.step6": + "复制生成的邀请链接,在浏览器中打开,将 Bot 邀请到你的服务器", + "discord.step7": "回到上方点击「测试连接」,确认显示 Bot 名称即配置成功", + "discord.setupIdTitle": "获取 ID(需开启开发者模式)", + "discord.stepDevMode": + "打开 Discord 客户端 →「用户设置」→「高级」→ 开启「开发者模式」", + "discord.stepUserId": + "获取用户 ID:右键点击自己的头像或用户名 →「复制用户 ID」", + "discord.stepChannelId": "获取频道 ID:右键点击左侧频道名称 →「复制频道 ID」", + "discord.stepGuildId": + "获取服务器 ID:右键点击左上角服务器名称 →「复制服务器 ID」", }; export default zh; diff --git a/src/lib/claude-client.ts b/src/lib/claude-client.ts index 61a813f3..49402f56 100644 --- a/src/lib/claude-client.ts +++ b/src/lib/claude-client.ts @@ -1,4 +1,4 @@ -import { query } from '@anthropic-ai/claude-agent-sdk'; +import { query } from "@anthropic-ai/claude-agent-sdk"; import type { SDKMessage, SDKAssistantMessage, @@ -14,17 +14,34 @@ import type { McpServerConfig, NotificationHookInput, PostToolUseHookInput, -} from '@anthropic-ai/claude-agent-sdk'; -import type { ClaudeStreamOptions, SSEEvent, TokenUsage, MCPServerConfig, PermissionRequestEvent, FileAttachment, ApiProvider } from '@/types'; -import { isImageFile } from '@/types'; -import { registerPendingPermission } from './permission-registry'; -import { registerConversation, unregisterConversation } from './conversation-registry'; -import { getSetting, getActiveProvider, updateSdkSessionId, createPermissionRequest } from './db'; -import { findClaudeBinary, findGitBash, getExpandedPath } from './platform'; -import { notifyPermissionRequest, notifyGeneric } from './telegram-bot'; -import os from 'os'; -import fs from 'fs'; -import path from 'path'; +} from "@anthropic-ai/claude-agent-sdk"; +import type { + ClaudeStreamOptions, + SSEEvent, + TokenUsage, + MCPServerConfig, + PermissionRequestEvent, + FileAttachment, + ApiProvider, +} from "@/types"; +import { isImageFile } from "@/types"; +import { registerPendingPermission } from "./permission-registry"; +import { + registerConversation, + unregisterConversation, +} from "./conversation-registry"; +import { + getSetting, + getActiveProvider, + updateSdkSessionId, + createPermissionRequest, +} from "./db"; +import { findClaudeBinary, findGitBash, getExpandedPath } from "./platform"; +import { getCustomCliPath } from "./cli-config"; +import { notifyPermissionRequest, notifyGeneric } from "./telegram-bot"; +import os from "os"; +import fs from "fs"; +import path from "path"; /** * Sanitize a string for use as an environment variable value. @@ -32,7 +49,7 @@ import path from 'path'; */ function sanitizeEnvValue(value: string): string { // eslint-disable-next-line no-control-regex - return value.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); + return value.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); } /** @@ -44,7 +61,7 @@ function sanitizeEnvValue(value: string): string { function sanitizeEnv(env: Record): Record { const clean: Record = {}; for (const [key, value] of Object.entries(env)) { - if (typeof value === 'string') { + if (typeof value === "string") { clean[key] = sanitizeEnvValue(value); } } @@ -58,7 +75,7 @@ function sanitizeEnv(env: Record): Record { */ function resolveScriptFromCmd(cmdPath: string): string | undefined { try { - const content = fs.readFileSync(cmdPath, 'utf-8'); + const content = fs.readFileSync(cmdPath, "utf-8"); const cmdDir = path.dirname(cmdPath); // npm .cmd wrappers typically contain a line like: @@ -100,20 +117,20 @@ function findClaudePath(): string | undefined { * Supports stdio, sse, and http transport types. */ function toSdkMcpConfig( - servers: Record + servers: Record, ): Record { const result: Record = {}; for (const [name, config] of Object.entries(servers)) { - const transport = config.type || 'stdio'; + const transport = config.type || "stdio"; switch (transport) { - case 'sse': { + case "sse": { if (!config.url) { console.warn(`[mcp] SSE server "${name}" is missing url, skipping`); continue; } const sseConfig: McpSSEServerConfig = { - type: 'sse', + type: "sse", url: config.url, }; if (config.headers && Object.keys(config.headers).length > 0) { @@ -123,13 +140,13 @@ function toSdkMcpConfig( break; } - case 'http': { + case "http": { if (!config.url) { console.warn(`[mcp] HTTP server "${name}" is missing url, skipping`); continue; } const httpConfig: McpHttpServerConfig = { - type: 'http', + type: "http", url: config.url, }; if (config.headers && Object.keys(config.headers).length > 0) { @@ -139,10 +156,12 @@ function toSdkMcpConfig( break; } - case 'stdio': + case "stdio": default: { if (!config.command) { - console.warn(`[mcp] stdio server "${name}" is missing command, skipping`); + console.warn( + `[mcp] stdio server "${name}" is missing command, skipping`, + ); continue; } const stdioConfig: McpStdioServerConfig = { @@ -171,11 +190,11 @@ function formatSSE(event: SSEEvent): string { function extractTextFromMessage(msg: SDKAssistantMessage): string { const parts: string[] = []; for (const block of msg.message.content) { - if (block.type === 'text') { + if (block.type === "text") { parts.push(block.text); } } - return parts.join(''); + return parts.join(""); } /** @@ -188,7 +207,7 @@ function extractTokenUsage(msg: SDKResultMessage): TokenUsage | null { output_tokens: msg.usage.output_tokens, cache_read_input_tokens: msg.usage.cache_read_input_tokens ?? 0, cache_creation_input_tokens: msg.usage.cache_creation_input_tokens ?? 0, - cost_usd: 'total_cost_usd' in msg ? msg.total_cost_usd : undefined, + cost_usd: "total_cost_usd" in msg ? msg.total_cost_usd : undefined, }; } @@ -201,7 +220,10 @@ function extractTokenUsage(msg: SDKResultMessage): TokenUsage | null { * persisted filePath (written by the uploads route), reuse it. Otherwise * fall back to writing the file to .codepilot-uploads/. */ -function getUploadedFilePaths(files: FileAttachment[], workDir: string): string[] { +function getUploadedFilePaths( + files: FileAttachment[], + workDir: string, +): string[] { const paths: string[] = []; let uploadDir: string | undefined; for (const file of files) { @@ -210,14 +232,16 @@ function getUploadedFilePaths(files: FileAttachment[], workDir: string): string[ } else { // Fallback: write file to disk (should not happen in normal flow) if (!uploadDir) { - uploadDir = path.join(workDir, '.codepilot-uploads'); + uploadDir = path.join(workDir, ".codepilot-uploads"); if (!fs.existsSync(uploadDir)) { fs.mkdirSync(uploadDir, { recursive: true }); } } - const safeName = path.basename(file.name).replace(/[^a-zA-Z0-9._-]/g, '_'); + const safeName = path + .basename(file.name) + .replace(/[^a-zA-Z0-9._-]/g, "_"); const filePath = path.join(uploadDir, `${Date.now()}-${safeName}`); - const buffer = Buffer.from(file.data, 'base64'); + const buffer = Buffer.from(file.data, "base64"); fs.writeFileSync(filePath, buffer); paths.push(filePath); } @@ -231,41 +255,48 @@ function getUploadedFilePaths(files: FileAttachment[], workDir: string): string[ */ function buildPromptWithHistory( prompt: string, - history?: Array<{ role: 'user' | 'assistant'; content: string }>, + history?: Array<{ role: "user" | "assistant"; content: string }>, ): string { if (!history || history.length === 0) return prompt; - const lines: string[] = ['']; + const lines: string[] = [""]; for (const msg of history) { // For assistant messages with tool blocks (JSON arrays), summarize let content = msg.content; - if (msg.role === 'assistant' && content.startsWith('[')) { + if (msg.role === "assistant" && content.startsWith("[")) { try { const blocks = JSON.parse(content); const parts: string[] = []; for (const b of blocks) { - if (b.type === 'text' && b.text) parts.push(b.text); - else if (b.type === 'tool_use') parts.push(`[Used tool: ${b.name}]`); - else if (b.type === 'tool_result') { - const resultStr = typeof b.content === 'string' ? b.content : JSON.stringify(b.content); + if (b.type === "text" && b.text) parts.push(b.text); + else if (b.type === "tool_use") parts.push(`[Used tool: ${b.name}]`); + else if (b.type === "tool_result") { + const resultStr = + typeof b.content === "string" + ? b.content + : JSON.stringify(b.content); // Truncate long tool results - parts.push(`[Tool result: ${resultStr.slice(0, 500)}${resultStr.length > 500 ? '...' : ''}]`); + parts.push( + `[Tool result: ${resultStr.slice(0, 500)}${resultStr.length > 500 ? "..." : ""}]`, + ); } } - content = parts.join('\n'); + content = parts.join("\n"); } catch { // Not JSON, use as-is } } - lines.push(`${msg.role === 'user' ? 'Human' : 'Assistant'}: ${content}`); + lines.push(`${msg.role === "user" ? "Human" : "Assistant"}: ${content}`); } - lines.push(''); - lines.push(''); + lines.push(""); + lines.push(""); lines.push(prompt); - return lines.join('\n'); + return lines.join("\n"); } -export function streamClaude(options: ClaudeStreamOptions): ReadableStream { +export function streamClaude( + options: ClaudeStreamOptions, +): ReadableStream { const { prompt, sessionId, @@ -286,13 +317,16 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream({ async start(controller) { // Hoist activeProvider so it's accessible in the catch block for error messages - const activeProvider: ApiProvider | undefined = options.provider ?? getActiveProvider(); + const activeProvider: ApiProvider | undefined = + options.provider ?? getActiveProvider(); try { // Build env for the Claude Code subprocess. // Start with process.env (includes user shell env from Electron's loadUserShellEnv). // Then overlay any API config the user set in CodePilot settings (optional). - const sdkEnv: Record = { ...process.env as Record }; + const sdkEnv: Record = { + ...(process.env as Record), + }; // Ensure HOME/USERPROFILE are set so Claude Code can find ~/.claude/commands/ if (!sdkEnv.HOME) sdkEnv.HOME = os.homedir(); @@ -307,7 +341,10 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream, telegramOpts).catch(() => {}); + notifyPermissionRequest( + toolName, + input as Record, + telegramOpts, + ).catch(() => {}); // Notify runtime status change - onRuntimeStatusChange?.('waiting_permission'); + onRuntimeStatusChange?.("waiting_permission"); // Wait for user response (resolved by POST /api/chat/permission) // Store original input so registry can inject updatedInput on allow - const result = await registerPendingPermission(permissionRequestId, input, opts.signal); + const result = await registerPendingPermission( + permissionRequestId, + input, + opts.signal, + ); // Restore runtime status after permission resolved - onRuntimeStatusChange?.('running'); + onRuntimeStatusChange?.("running"); return result; }; @@ -515,113 +604,158 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream { - const notif = input as NotificationHookInput; - controller.enqueue(formatSSE({ - type: 'status', - data: JSON.stringify({ - notification: true, - title: notif.title, - message: notif.message, - }), - })); - // Forward to Telegram (fire-and-forget) - notifyGeneric(notif.title || '', notif.message || '', telegramOpts).catch(() => {}); - return {}; - }], - }], - PostToolUse: [{ - hooks: [async (input) => { - const toolEvent = input as PostToolUseHookInput; - console.log('[claude-client] PostToolUse:', toolEvent.tool_name, 'id:', toolEvent.tool_use_id); - controller.enqueue(formatSSE({ - type: 'tool_result', - data: JSON.stringify({ - tool_use_id: toolEvent.tool_use_id, - content: typeof toolEvent.tool_response === 'string' - ? toolEvent.tool_response - : JSON.stringify(toolEvent.tool_response), - is_error: false, - }), - })); - - // Detect TodoWrite tool and emit task_update SSE for frontend sync - if (toolEvent.tool_name === 'TodoWrite') { - try { - // SDK TodoWriteInput: { todos: { content, status, activeForm }[] } - const toolInput = toolEvent.tool_input as { - todos?: Array<{ content: string; status: string; activeForm?: string }>; - }; - if (toolInput?.todos && Array.isArray(toolInput.todos)) { - console.log('[claude-client] TodoWrite detected, syncing', toolInput.todos.length, 'tasks'); - controller.enqueue(formatSSE({ - type: 'task_update', + Notification: [ + { + hooks: [ + async (input) => { + const notif = input as NotificationHookInput; + controller.enqueue( + formatSSE({ + type: "status", data: JSON.stringify({ - session_id: sessionId, - todos: toolInput.todos.map((t, i) => ({ - id: String(i), - content: t.content, - status: t.status, - activeForm: t.activeForm || '', - })), + notification: true, + title: notif.title, + message: notif.message, }), - })); + }), + ); + // Forward to Telegram (fire-and-forget) + notifyGeneric( + notif.title || "", + notif.message || "", + telegramOpts, + ).catch(() => {}); + return {}; + }, + ], + }, + ], + PostToolUse: [ + { + hooks: [ + async (input) => { + const toolEvent = input as PostToolUseHookInput; + console.log( + "[claude-client] PostToolUse:", + toolEvent.tool_name, + "id:", + toolEvent.tool_use_id, + ); + controller.enqueue( + formatSSE({ + type: "tool_result", + data: JSON.stringify({ + tool_use_id: toolEvent.tool_use_id, + content: + typeof toolEvent.tool_response === "string" + ? toolEvent.tool_response + : JSON.stringify(toolEvent.tool_response), + is_error: false, + }), + }), + ); + + // Detect TodoWrite tool and emit task_update SSE for frontend sync + if (toolEvent.tool_name === "TodoWrite") { + try { + // SDK TodoWriteInput: { todos: { content, status, activeForm }[] } + const toolInput = toolEvent.tool_input as { + todos?: Array<{ + content: string; + status: string; + activeForm?: string; + }>; + }; + if (toolInput?.todos && Array.isArray(toolInput.todos)) { + console.log( + "[claude-client] TodoWrite detected, syncing", + toolInput.todos.length, + "tasks", + ); + controller.enqueue( + formatSSE({ + type: "task_update", + data: JSON.stringify({ + session_id: sessionId, + todos: toolInput.todos.map((t, i) => ({ + id: String(i), + content: t.content, + status: t.status, + activeForm: t.activeForm || "", + })), + }), + }), + ); + } + } catch (e) { + console.warn( + "[claude-client] Failed to parse TodoWrite input:", + e, + ); + } } - } catch (e) { - console.warn('[claude-client] Failed to parse TodoWrite input:', e); - } - } - return {}; - }], - }], + return {}; + }, + ], + }, + ], }; // Capture real-time stderr output from Claude Code process queryOptions.stderr = (data: string) => { // Diagnostic: log raw stderr data length to server console - console.log(`[stderr] received ${data.length} bytes, first 200 chars:`, data.slice(0, 200).replace(/[\x00-\x1F\x7F]/g, '?')); + console.log( + `[stderr] received ${data.length} bytes, first 200 chars:`, + data.slice(0, 200).replace(/[\x00-\x1F\x7F]/g, "?"), + ); // Strip ANSI escape codes, OSC sequences, and control characters // but preserve tabs (\x09) and carriage returns (\x0D) const cleaned = data - .replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '') // CSI sequences (colors, cursor) - .replace(/\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)/g, '') // OSC sequences - .replace(/\x1B\([A-Z]/g, '') // Character set selection - .replace(/\x1B[=>]/g, '') // Keypad mode - .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '') // Control chars (keep \t \n \r) - .replace(/\r\n/g, '\n') // Normalize CRLF - .replace(/\r/g, '\n') // Convert remaining CR to LF - .replace(/\n{3,}/g, '\n\n') // Collapse multiple blank lines + .replace(/\x1B\[[0-9;]*[a-zA-Z]/g, "") // CSI sequences (colors, cursor) + .replace(/\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)/g, "") // OSC sequences + .replace(/\x1B\([A-Z]/g, "") // Character set selection + .replace(/\x1B[=>]/g, "") // Keypad mode + .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, "") // Control chars (keep \t \n \r) + .replace(/\r\n/g, "\n") // Normalize CRLF + .replace(/\r/g, "\n") // Convert remaining CR to LF + .replace(/\n{3,}/g, "\n\n") // Collapse multiple blank lines .trim(); if (cleaned) { - controller.enqueue(formatSSE({ - type: 'tool_output', - data: cleaned, - })); + controller.enqueue( + formatSSE({ + type: "tool_output", + data: cleaned, + }), + ); } }; // Build the prompt with file attachments and optional conversation history. // When resuming, the SDK has full context so we send the raw prompt. // When NOT resuming (fresh or fallback), prepend DB history for context. - function buildFinalPrompt(useHistory: boolean): string | AsyncIterable { + function buildFinalPrompt( + useHistory: boolean, + ): string | AsyncIterable { const basePrompt = useHistory ? buildPromptWithHistory(prompt, conversationHistory) : prompt; if (!files || files.length === 0) return basePrompt; - const imageFiles = files.filter(f => isImageFile(f.type)); - const nonImageFiles = files.filter(f => !isImageFile(f.type)); + const imageFiles = files.filter((f) => isImageFile(f.type)); + const nonImageFiles = files.filter((f) => !isImageFile(f.type)); let textPrompt = basePrompt; if (nonImageFiles.length > 0) { const workDir = workingDirectory || os.homedir(); const savedPaths = getUploadedFilePaths(nonImageFiles, workDir); const fileReferences = savedPaths - .map((p, i) => `[User attached file: ${p} (${nonImageFiles[i].name})]`) - .join('\n'); + .map( + (p, i) => + `[User attached file: ${p} (${nonImageFiles[i].name})]`, + ) + .join("\n"); textPrompt = `${fileReferences}\n\nPlease read the attached file(s) above using your Read tool, then respond to the user's message:\n\n${basePrompt}`; } @@ -637,37 +771,43 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream `[User attached image: ${p} (${imageFiles[i].name})]`) - .join('\n'); + .map( + (p, i) => + `[User attached image: ${p} (${imageFiles[i].name})]`, + ) + .join("\n"); return `${imageReferences}\n\n${textPrompt}`; })(); const contentBlocks: Array< - | { type: 'image'; source: { type: 'base64'; media_type: string; data: string } } - | { type: 'text'; text: string } + | { + type: "image"; + source: { type: "base64"; media_type: string; data: string }; + } + | { type: "text"; text: string } > = []; for (const img of imageFiles) { contentBlocks.push({ - type: 'image', + type: "image", source: { - type: 'base64', - media_type: img.type || 'image/png', + type: "base64", + media_type: img.type || "image/png", data: img.data, }, }); } - contentBlocks.push({ type: 'text', text: textWithImageRefs }); + contentBlocks.push({ type: "text", text: textWithImageRefs }); const userMessage: SDKUserMessage = { - type: 'user', + type: "user", message: { - role: 'user', + role: "user", content: contentBlocks, }, parent_tool_use_id: null, - session_id: sdkSessionId || '', + session_id: sdkSessionId || "", }; return (async function* () { @@ -705,21 +845,34 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream; } catch (resumeError) { - const errMsg = resumeError instanceof Error ? resumeError.message : String(resumeError); - console.warn('[claude-client] Resume failed, retrying without resume:', errMsg); + const errMsg = + resumeError instanceof Error + ? resumeError.message + : String(resumeError); + console.warn( + "[claude-client] Resume failed, retrying without resume:", + errMsg, + ); // Clear stale sdk_session_id so future messages don't retry this broken resume if (sessionId) { - try { updateSdkSessionId(sessionId, ''); } catch { /* best effort */ } + try { + updateSdkSessionId(sessionId, ""); + } catch { + /* best effort */ + } } // Notify frontend about the fallback - controller.enqueue(formatSSE({ - type: 'status', - data: JSON.stringify({ - notification: true, - title: 'Session fallback', - message: 'Previous session could not be resumed. Starting fresh conversation.', + controller.enqueue( + formatSSE({ + type: "status", + data: JSON.stringify({ + notification: true, + title: "Session fallback", + message: + "Previous session could not be resumed. Starting fresh conversation.", + }), }), - })); + ); // Remove resume and try again as a fresh conversation with history context delete queryOptions.resume; conversation = query({ @@ -731,7 +884,7 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream c.type === 'text') - .map((c: { text: string }) => c.text) - .join('\n') - : String(block.content ?? ''); - controller.enqueue(formatSSE({ - type: 'tool_result', - data: JSON.stringify({ - tool_use_id: block.tool_use_id, - content: resultContent, - is_error: block.is_error || false, + : Array.isArray(block.content) + ? block.content + .filter( + (c: { type: string }) => c.type === "text", + ) + .map((c: { text: string }) => c.text) + .join("\n") + : String(block.content ?? ""); + controller.enqueue( + formatSSE({ + type: "tool_result", + data: JSON.stringify({ + tool_use_id: block.tool_use_id, + content: resultContent, + is_error: block.is_error || false, + }), }), - })); + ); } } } break; } - case 'stream_event': { + case "stream_event": { const streamEvent = message as SDKPartialAssistantMessage; const evt = streamEvent.event; - if (evt.type === 'content_block_delta' && 'delta' in evt) { + if (evt.type === "content_block_delta" && "delta" in evt) { const delta = evt.delta; - if ('text' in delta && delta.text) { - controller.enqueue(formatSSE({ type: 'text', data: delta.text })); + if ("text" in delta && delta.text) { + controller.enqueue( + formatSSE({ type: "text", data: delta.text }), + ); } } break; } - case 'system': { + case "system": { const sysMsg = message as SDKSystemMessage; - if ('subtype' in sysMsg) { - if (sysMsg.subtype === 'init') { - controller.enqueue(formatSSE({ - type: 'status', - data: JSON.stringify({ - session_id: sysMsg.session_id, - model: sysMsg.model, - tools: sysMsg.tools, + if ("subtype" in sysMsg) { + if (sysMsg.subtype === "init") { + controller.enqueue( + formatSSE({ + type: "status", + data: JSON.stringify({ + session_id: sysMsg.session_id, + model: sysMsg.model, + tools: sysMsg.tools, + }), }), - })); - } else if (sysMsg.subtype === 'status') { + ); + } else if (sysMsg.subtype === "status") { // SDK sends status messages when permission mode changes (e.g. ExitPlanMode) - const statusMsg = sysMsg as SDKSystemMessage & { permissionMode?: string }; + const statusMsg = sysMsg as SDKSystemMessage & { + permissionMode?: string; + }; if (statusMsg.permissionMode) { - controller.enqueue(formatSSE({ - type: 'mode_changed', - data: statusMsg.permissionMode, - })); + controller.enqueue( + formatSSE({ + type: "mode_changed", + data: statusMsg.permissionMode, + }), + ); } } } break; } - case 'tool_progress': { + case "tool_progress": { const progressMsg = message as SDKToolProgressMessage; - controller.enqueue(formatSSE({ - type: 'tool_output', - data: JSON.stringify({ - _progress: true, - tool_use_id: progressMsg.tool_use_id, - tool_name: progressMsg.tool_name, - elapsed_time_seconds: progressMsg.elapsed_time_seconds, - }), - })); - // Auto-timeout: abort if tool runs longer than configured threshold - if (toolTimeoutSeconds > 0 && progressMsg.elapsed_time_seconds >= toolTimeoutSeconds) { - controller.enqueue(formatSSE({ - type: 'tool_timeout', + controller.enqueue( + formatSSE({ + type: "tool_output", data: JSON.stringify({ + _progress: true, + tool_use_id: progressMsg.tool_use_id, tool_name: progressMsg.tool_name, - elapsed_seconds: Math.round(progressMsg.elapsed_time_seconds), + elapsed_time_seconds: progressMsg.elapsed_time_seconds, }), - })); + }), + ); + // Auto-timeout: abort if tool runs longer than configured threshold + if ( + toolTimeoutSeconds > 0 && + progressMsg.elapsed_time_seconds >= toolTimeoutSeconds + ) { + controller.enqueue( + formatSSE({ + type: "tool_timeout", + data: JSON.stringify({ + tool_name: progressMsg.tool_name, + elapsed_seconds: Math.round( + progressMsg.elapsed_time_seconds, + ), + }), + }), + ); abortController?.abort(); } break; } - case 'result': { + case "result": { const resultMsg = message as SDKResultMessage; tokenUsage = extractTokenUsage(resultMsg); - controller.enqueue(formatSSE({ - type: 'result', - data: JSON.stringify({ - subtype: resultMsg.subtype, - is_error: resultMsg.is_error, - num_turns: resultMsg.num_turns, - duration_ms: resultMsg.duration_ms, - usage: tokenUsage, - session_id: resultMsg.session_id, + controller.enqueue( + formatSSE({ + type: "result", + data: JSON.stringify({ + subtype: resultMsg.subtype, + is_error: resultMsg.is_error, + num_turns: resultMsg.num_turns, + duration_ms: resultMsg.duration_ms, + usage: tokenUsage, + session_id: resultMsg.session_id, + }), }), - })); + ); break; } @@ -883,61 +1062,110 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream f.endsWith('.jsonl')); + const jsonlFiles = files.filter((f) => f.endsWith(".jsonl")); for (const jsonlFile of jsonlFiles) { const filePath = path.join(projectPath, jsonlFile); - const sessionId = jsonlFile.replace('.jsonl', ''); + const sessionId = jsonlFile.replace(".jsonl", ""); try { const info = extractSessionInfo(filePath, sessionId, decodedPath); @@ -205,7 +206,9 @@ export function listClaudeSessions(): ClaudeSessionInfo[] { } // Sort by most recent first - sessions.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); + sessions.sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), + ); return sessions; } @@ -214,14 +217,18 @@ export function listClaudeSessions(): ClaudeSessionInfo[] { * Read and split a JSONL file into lines, with size guard. * Returns null if the file exceeds MAX_FILE_SIZE. */ -function readJsonlLines(filePath: string): { lines: string[]; stat: fs.Stats } | null { +function readJsonlLines( + filePath: string, +): { lines: string[]; stat: fs.Stats } | null { const stat = fs.statSync(filePath); if (stat.size > MAX_FILE_SIZE) { - console.warn(`[claude-session-parser] Skipping ${filePath}: file too large (${(stat.size / 1024 / 1024).toFixed(1)} MB)`); + console.warn( + `[claude-session-parser] Skipping ${filePath}: file too large (${(stat.size / 1024 / 1024).toFixed(1)} MB)`, + ); return null; } - const content = fs.readFileSync(filePath, 'utf-8'); - const lines = content.split('\n').filter(l => l.trim()); + const content = fs.readFileSync(filePath, "utf-8"); + const lines = content.split("\n").filter((l) => l.trim()); return { lines, stat }; } @@ -239,12 +246,12 @@ function extractSessionInfo( if (lines.length === 0) return null; - let cwd = ''; - let gitBranch = ''; - let version = ''; - let preview = ''; - let createdAt = ''; - let updatedAt = ''; + let cwd = ""; + let gitBranch = ""; + let version = ""; + let preview = ""; + let createdAt = ""; + let updatedAt = ""; let userMessageCount = 0; let assistantMessageCount = 0; @@ -257,7 +264,7 @@ function extractSessionInfo( updatedAt = entry.timestamp as string; } - if (entry.type === 'user') { + if (entry.type === "user") { const userEntry = entry as UserEntry; userMessageCount++; @@ -267,16 +274,16 @@ function extractSessionInfo( if (!preview && userEntry.message?.content) { const msgContent = userEntry.message.content; - if (typeof msgContent === 'string') { + if (typeof msgContent === "string") { preview = msgContent.slice(0, 120); } else if (Array.isArray(msgContent)) { - const textBlock = msgContent.find(b => b.type === 'text'); + const textBlock = msgContent.find((b) => b.type === "text"); if (textBlock?.text) { preview = textBlock.text.slice(0, 120); } } } - } else if (entry.type === 'assistant') { + } else if (entry.type === "assistant") { assistantMessageCount++; } } catch { @@ -297,9 +304,9 @@ function extractSessionInfo( projectPath: effectivePath, projectName: path.basename(effectivePath), cwd: effectivePath, - gitBranch: gitBranch || '', - version: version || '', - preview: preview || '(no preview)', + gitBranch: gitBranch || "", + version: version || "", + preview: preview || "(no preview)", userMessageCount, assistantMessageCount, createdAt: createdAt || stat.birthtime.toISOString(), @@ -323,7 +330,7 @@ export function parseClaudeSession(sessionId: string): ParsedSession | null { // Find the session file across all project directories let filePath: string | null = null; - let projectPath = ''; + let projectPath = ""; try { const projectDirs = fs.readdirSync(projectsDir, { withFileTypes: true }); @@ -331,7 +338,11 @@ export function parseClaudeSession(sessionId: string): ParsedSession | null { for (const projectDir of projectDirs) { if (!projectDir.isDirectory()) continue; - const candidate = path.join(projectsDir, projectDir.name, `${sessionId}.jsonl`); + const candidate = path.join( + projectsDir, + projectDir.name, + `${sessionId}.jsonl`, + ); if (fs.existsSync(candidate)) { filePath = candidate; projectPath = decodeProjectPath(projectDir.name); @@ -352,12 +363,12 @@ export function parseClaudeSession(sessionId: string): ParsedSession | null { // Single pass: extract both metadata and messages const messages: ParsedMessage[] = []; - let cwd = ''; - let gitBranch = ''; - let version = ''; - let preview = ''; - let createdAt = ''; - let updatedAt = ''; + let cwd = ""; + let gitBranch = ""; + let version = ""; + let preview = ""; + let createdAt = ""; + let updatedAt = ""; let userMessageCount = 0; let assistantMessageCount = 0; @@ -370,7 +381,7 @@ export function parseClaudeSession(sessionId: string): ParsedSession | null { updatedAt = entry.timestamp as string; } - if (entry.type === 'user') { + if (entry.type === "user") { const userEntry = entry as UserEntry; userMessageCount++; @@ -380,10 +391,10 @@ export function parseClaudeSession(sessionId: string): ParsedSession | null { if (!preview && userEntry.message?.content) { const msgContent = userEntry.message.content; - if (typeof msgContent === 'string') { + if (typeof msgContent === "string") { preview = msgContent.slice(0, 120); } else if (Array.isArray(msgContent)) { - const textBlock = msgContent.find(b => b.type === 'text'); + const textBlock = msgContent.find((b) => b.type === "text"); if (textBlock?.text) { preview = textBlock.text.slice(0, 120); } @@ -392,7 +403,7 @@ export function parseClaudeSession(sessionId: string): ParsedSession | null { const parsed = parseUserMessage(userEntry); if (parsed) messages.push(parsed); - } else if (entry.type === 'assistant') { + } else if (entry.type === "assistant") { assistantMessageCount++; const assistantEntry = entry as AssistantEntry; @@ -416,9 +427,9 @@ export function parseClaudeSession(sessionId: string): ParsedSession | null { projectPath: effectivePath, projectName: path.basename(effectivePath), cwd: effectivePath, - gitBranch: gitBranch || '', - version: version || '', - preview: preview || '(no preview)', + gitBranch: gitBranch || "", + version: version || "", + preview: preview || "(no preview)", userMessageCount, assistantMessageCount, createdAt: createdAt || stat.birthtime.toISOString(), @@ -437,14 +448,14 @@ function parseUserMessage(entry: UserEntry): ParsedMessage | null { if (!msgContent) return null; let text: string; - if (typeof msgContent === 'string') { + if (typeof msgContent === "string") { text = msgContent; } else if (Array.isArray(msgContent)) { // User messages can have structured content (e.g., with images) text = msgContent - .filter(b => b.type === 'text') - .map(b => b.text || '') - .join('\n'); + .filter((b) => b.type === "text") + .map((b) => b.text || "") + .join("\n"); } else { return null; } @@ -452,9 +463,9 @@ function parseUserMessage(entry: UserEntry): ParsedMessage | null { if (!text.trim()) return null; return { - role: 'user', + role: "user", content: text, - contentBlocks: [{ type: 'text', text }], + contentBlocks: [{ type: "text", text }], hasToolBlocks: false, timestamp: entry.timestamp || new Date().toISOString(), }; @@ -474,36 +485,37 @@ function parseAssistantMessage(entry: AssistantEntry): ParsedMessage | null { for (const block of msgContent) { switch (block.type) { - case 'text': { + case "text": { if (block.text) { - contentBlocks.push({ type: 'text', text: block.text }); + contentBlocks.push({ type: "text", text: block.text }); textParts.push(block.text); } break; } - case 'tool_use': { + case "tool_use": { hasToolBlocks = true; contentBlocks.push({ - type: 'tool_use', - id: block.id || '', - name: block.name || '', + type: "tool_use", + id: block.id || "", + name: block.name || "", input: block.input, }); break; } - case 'tool_result': { + case "tool_result": { hasToolBlocks = true; - const resultContent = typeof block.content === 'string' - ? block.content - : Array.isArray(block.content) + const resultContent = + typeof block.content === "string" ? block.content - .filter(c => c.type === 'text') - .map(c => c.text || '') - .join('\n') - : ''; + : Array.isArray(block.content) + ? block.content + .filter((c) => c.type === "text") + .map((c) => c.text || "") + .join("\n") + : ""; contentBlocks.push({ - type: 'tool_result', - tool_use_id: block.tool_use_id || '', + type: "tool_result", + tool_use_id: block.tool_use_id || "", content: resultContent, is_error: block.is_error || false, }); @@ -515,10 +527,10 @@ function parseAssistantMessage(entry: AssistantEntry): ParsedMessage | null { if (contentBlocks.length === 0) return null; // Plain text content: join all text blocks - const plainText = textParts.join('\n'); + const plainText = textParts.join("\n"); return { - role: 'assistant', + role: "assistant", content: plainText, contentBlocks, hasToolBlocks, diff --git a/src/lib/cli-config.ts b/src/lib/cli-config.ts new file mode 100644 index 00000000..9ae37de4 --- /dev/null +++ b/src/lib/cli-config.ts @@ -0,0 +1,95 @@ +/** + * 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)); + // Strip extension for Windows .cmd/.exe + 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`); +} diff --git a/src/lib/platform.ts b/src/lib/platform.ts index 0be9860b..6d611a34 100644 --- a/src/lib/platform.ts +++ b/src/lib/platform.ts @@ -1,13 +1,14 @@ -import { execFileSync, execFile } from 'child_process'; -import fs from 'fs'; -import { promisify } from 'util'; -import os from 'os'; -import path from 'path'; +import { execFileSync, execFile } from "child_process"; +import fs from "fs"; +import { promisify } from "util"; +import os from "os"; +import path from "path"; +import { getClaudeBinDir, getClaudeBinaryName } from "./cli-config"; const execFileAsync = promisify(execFile); -export const isWindows = process.platform === 'win32'; -export const isMac = process.platform === 'darwin'; +export const isWindows = process.platform === "win32"; +export const isMac = process.platform === "darwin"; /** * Whether the given binary path requires shell execution. @@ -22,27 +23,30 @@ function needsShell(binPath: string): boolean { */ export function getExtraPathDirs(): string[] { const home = os.homedir(); + const claudeBin = getClaudeBinDir(); if (isWindows) { - const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming'); - const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'); + const appData = + process.env.APPDATA || path.join(home, "AppData", "Roaming"); + const localAppData = + process.env.LOCALAPPDATA || path.join(home, "AppData", "Local"); return [ - path.join(appData, 'npm'), - path.join(localAppData, 'npm'), - path.join(home, '.npm-global', 'bin'), - path.join(home, '.claude', 'bin'), - path.join(home, '.local', 'bin'), - path.join(home, '.nvm', 'current', 'bin'), + path.join(appData, "npm"), + path.join(localAppData, "npm"), + path.join(home, ".npm-global", "bin"), + claudeBin, + path.join(home, ".local", "bin"), + path.join(home, ".nvm", "current", "bin"), ]; } return [ - '/usr/local/bin', - '/opt/homebrew/bin', - '/usr/bin', - '/bin', - path.join(home, '.npm-global', 'bin'), - path.join(home, '.nvm', 'current', 'bin'), - path.join(home, '.local', 'bin'), - path.join(home, '.claude', 'bin'), + "/usr/local/bin", + "/opt/homebrew/bin", + "/usr/bin", + "/bin", + path.join(home, ".npm-global", "bin"), + path.join(home, ".nvm", "current", "bin"), + path.join(home, ".local", "bin"), + claudeBin, ]; } @@ -51,31 +55,35 @@ export function getExtraPathDirs(): string[] { */ export function getClaudeCandidatePaths(): string[] { const home = os.homedir(); + const binaryName = getClaudeBinaryName(); + const claudeBin = getClaudeBinDir(); if (isWindows) { - const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming'); - const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'); - const exts = ['.cmd', '.exe', '.bat', '']; + const appData = + process.env.APPDATA || path.join(home, "AppData", "Roaming"); + const localAppData = + process.env.LOCALAPPDATA || path.join(home, "AppData", "Local"); + const exts = [".cmd", ".exe", ".bat", ""]; const baseDirs = [ - path.join(appData, 'npm'), - path.join(localAppData, 'npm'), - path.join(home, '.npm-global', 'bin'), - path.join(home, '.claude', 'bin'), - path.join(home, '.local', 'bin'), + path.join(appData, "npm"), + path.join(localAppData, "npm"), + path.join(home, ".npm-global", "bin"), + claudeBin, + path.join(home, ".local", "bin"), ]; const candidates: string[] = []; for (const dir of baseDirs) { for (const ext of exts) { - candidates.push(path.join(dir, 'claude' + ext)); + candidates.push(path.join(dir, binaryName + ext)); } } return candidates; } return [ - '/usr/local/bin/claude', - '/opt/homebrew/bin/claude', - path.join(home, '.npm-global', 'bin', 'claude'), - path.join(home, '.local', 'bin', 'claude'), - path.join(home, '.claude', 'bin', 'claude'), + `/usr/local/bin/${binaryName}`, + `/opt/homebrew/bin/${binaryName}`, + path.join(home, ".npm-global", "bin", binaryName), + path.join(home, ".local", "bin", binaryName), + path.join(claudeBin, binaryName), ]; } @@ -83,7 +91,7 @@ export function getClaudeCandidatePaths(): string[] { * Build an expanded PATH string with extra directories, deduped and filtered. */ export function getExpandedPath(): string { - const current = process.env.PATH || ''; + const current = process.env.PATH || ""; const parts = current.split(path.delimiter).filter(Boolean); const seen = new Set(parts); for (const p of getExtraPathDirs()) { @@ -108,7 +116,10 @@ const BINARY_CACHE_TTL = 60_000; // 60 seconds */ export function findClaudeBinary(): string | undefined { const now = Date.now(); - if (_cachedBinaryPath !== null && now - _cachedBinaryTimestamp < BINARY_CACHE_TTL) { + if ( + _cachedBinaryPath !== null && + now - _cachedBinaryTimestamp < BINARY_CACHE_TTL + ) { return _cachedBinaryPath; } @@ -127,9 +138,9 @@ function _findClaudeBinaryUncached(): string | undefined { // Try known candidate paths first for (const p of getClaudeCandidatePaths()) { try { - execFileSync(p, ['--version'], { + execFileSync(p, ["--version"], { timeout: 3000, - stdio: 'pipe', + stdio: "pipe", shell: needsShell(p), }); return p; @@ -140,11 +151,12 @@ function _findClaudeBinaryUncached(): string | undefined { // Fallback: use `where` (Windows) or `which` (Unix) with expanded PATH try { - const cmd = isWindows ? 'where' : '/usr/bin/which'; - const args = isWindows ? ['claude'] : ['claude']; + const cmd = isWindows ? "where" : "/usr/bin/which"; + const bName = getClaudeBinaryName(); + const args = [bName]; const result = execFileSync(cmd, args, { timeout: 3000, - stdio: 'pipe', + stdio: "pipe", env: { ...process.env, PATH: getExpandedPath() }, shell: isWindows, }); @@ -154,9 +166,9 @@ function _findClaudeBinaryUncached(): string | undefined { const candidate = line.trim(); if (!candidate) continue; try { - execFileSync(candidate, ['--version'], { + execFileSync(candidate, ["--version"], { timeout: 3000, - stdio: 'pipe', + stdio: "pipe", shell: needsShell(candidate), }); return candidate; @@ -175,9 +187,11 @@ function _findClaudeBinaryUncached(): string | undefined { * Execute claude --version and return the version string. * Handles .cmd shell execution on Windows. */ -export async function getClaudeVersion(claudePath: string): Promise { +export async function getClaudeVersion( + claudePath: string, +): Promise { try { - const { stdout } = await execFileAsync(claudePath, ['--version'], { + const { stdout } = await execFileAsync(claudePath, ["--version"], { timeout: 5000, env: { ...process.env, PATH: getExpandedPath() }, shell: needsShell(claudePath), @@ -201,8 +215,8 @@ export function findGitBash(): string | null { // 2. Check common installation paths const commonPaths = [ - 'C:\\Program Files\\Git\\bin\\bash.exe', - 'C:\\Program Files (x86)\\Git\\bin\\bash.exe', + "C:\\Program Files\\Git\\bin\\bash.exe", + "C:\\Program Files (x86)\\Git\\bin\\bash.exe", ]; for (const p of commonPaths) { if (fs.existsSync(p)) { @@ -212,9 +226,9 @@ export function findGitBash(): string | null { // 3. Try to locate git.exe via `where git` and derive bash.exe path try { - const result = execFileSync('where', ['git'], { + const result = execFileSync("where", ["git"], { timeout: 3000, - stdio: 'pipe', + stdio: "pipe", shell: true, }); const lines = result.toString().trim().split(/\r?\n/); @@ -223,7 +237,7 @@ export function findGitBash(): string | null { if (!gitExe) continue; // git.exe is typically at \cmd\git.exe or \bin\git.exe const gitDir = path.dirname(path.dirname(gitExe)); - const bashPath = path.join(gitDir, 'bin', 'bash.exe'); + const bashPath = path.join(gitDir, "bin", "bash.exe"); if (fs.existsSync(bashPath)) { return bashPath; }