diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d7f15f..9e24466 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,7 @@ jobs: uses: actions/setup-node@v4 with: node-version: "22" + registry-url: https://registry.npmjs.org cache: npm - name: Install dependencies diff --git a/package-lock.json b/package-lock.json index ce39a1d..182f6dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@carboncode/cli", - "version": "0.1.2", + "version": "0.1.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@carboncode/cli", - "version": "0.1.2", + "version": "0.1.5", "license": "MIT", "dependencies": { "cli-highlight": "^2.1.11", @@ -2225,7 +2225,7 @@ }, "node_modules/@types/ws": { "version": "8.18.1", - "resolved": "https://registry.npmmirror.com/@types/ws/-/ws-8.18.1.tgz", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", "dev": true, "license": "MIT", @@ -6891,7 +6891,7 @@ }, "node_modules/ws": { "version": "8.20.1", - "resolved": "https://registry.npmmirror.com/ws/-/ws-8.20.1.tgz", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", "license": "MIT", "engines": { diff --git a/package.json b/package.json index 4aeaec7..7be76a3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@carboncode/cli", - "version": "0.1.2", + "version": "0.1.5", "description": "Chinese-first DeepSeek-powered terminal coding agent for personal developer workflows.", "type": "module", "bin": { diff --git a/src/cli/commands/doctor.ts b/src/cli/commands/doctor.ts index 082cd87..7f34a65 100644 --- a/src/cli/commands/doctor.ts +++ b/src/cli/commands/doctor.ts @@ -39,6 +39,16 @@ export interface DoctorOptions { type Level = DoctorLevel; type Check = DoctorCheck; +type DoctorLabelKey = + | "apiKey" + | "config" + | "proxy" + | "apiReach" + | "tokenizer" + | "sessions" + | "hooks" + | "semantic" + | "project"; export async function runDoctorChecks(projectRoot: string): Promise { return Promise.all([ @@ -59,9 +69,9 @@ function checkProxy(): Check { if (!url) { return { id: "proxy", - label: "http proxy ", + label: doctorLabel("proxy"), level: "ok", - detail: "no HTTPS_PROXY / HTTP_PROXY / ALL_PROXY set — direct connection", + detail: t("doctorReport.details.proxyDirect"), }; } let redacted = url; @@ -77,9 +87,9 @@ function checkProxy(): Check { } return { id: "proxy", - label: "http proxy ", + label: doctorLabel("proxy"), level: "ok", - detail: `routing fetch through ${redacted}`, + detail: t("doctorReport.details.proxyRouting", { url: redacted }), }; } @@ -106,14 +116,18 @@ function fmtBytes(n: number): string { return `${(n / 1024 / 1024).toFixed(1)} MB`; } +function doctorLabel(key: DoctorLabelKey): string { + return t(`doctorReport.labels.${key}`).padEnd(12); +} + async function checkApiKey(): Promise { const fromEnv = process.env.DEEPSEEK_API_KEY; if (fromEnv) { return { id: "api-key", - label: "api key ", + label: doctorLabel("apiKey"), level: "ok", - detail: `set via env DEEPSEEK_API_KEY (${tail4(fromEnv)})`, + detail: t("doctorReport.details.apiKeyEnv", { tail: tail4(fromEnv) }), }; } try { @@ -121,9 +135,12 @@ async function checkApiKey(): Promise { if (cfg.apiKey) { return { id: "api-key", - label: "api key ", + label: doctorLabel("apiKey"), level: "ok", - detail: `from ${defaultConfigPath()} (${tail4(cfg.apiKey)})`, + detail: t("doctorReport.details.apiKeyConfig", { + path: defaultConfigPath(), + tail: tail4(cfg.apiKey), + }), }; } } catch { @@ -131,10 +148,9 @@ async function checkApiKey(): Promise { } return { id: "api-key", - label: "api key ", + label: doctorLabel("apiKey"), level: "fail", - detail: - "not set — `carboncode setup` to save one, or export DEEPSEEK_API_KEY. Get a key at https://platform.deepseek.com/api_keys", + detail: t("doctorReport.details.apiKeyMissing"), }; } @@ -143,9 +159,9 @@ async function checkConfig(): Promise { if (!existsSync(path)) { return { id: "config", - label: "config ", + label: doctorLabel("config"), level: "warn", - detail: "missing — running with library defaults. `carboncode setup` writes one.", + detail: t("doctorReport.details.configMissing"), }; } try { @@ -156,14 +172,14 @@ async function checkConfig(): Promise { if (cfg.mcp && cfg.mcp.length > 0) parts.push(`mcp=${cfg.mcp.length}`); return { id: "config", - label: "config ", + label: doctorLabel("config"), level: "ok", detail: `${path}${parts.length ? ` (${parts.join(", ")})` : ""}`, }; } catch (err) { return { id: "config", - label: "config ", + label: doctorLabel("config"), level: "fail", detail: t("doctorErrors.unreadable", { path, message: (err as Error).message }), }; @@ -175,9 +191,9 @@ async function checkApiReach(): Promise { if (!key) { return { id: "api-reach", - label: "api reach ", + label: doctorLabel("apiReach"), level: "warn", - detail: "skipped — no api key to test with", + detail: t("doctorReport.details.apiReachSkipped"), }; } try { @@ -193,30 +209,32 @@ async function checkApiReach(): Promise { if (!balance) { return { id: "api-reach", - label: "api reach ", + label: doctorLabel("apiReach"), level: "fail", - detail: "/user/balance returned null — auth failed or network blocked", + detail: t("doctorReport.details.apiReachNull"), }; } const summary = summarizeBalances(balance.balance_infos); if (!balance.is_available) { return { id: "api-reach", - label: "api reach ", + label: doctorLabel("apiReach"), level: "warn", - detail: `account flagged not-available${summary ? ` (${summary})` : ""} — top up or check your dashboard`, + detail: t("doctorReport.details.apiReachUnavailable", { summary }), }; } return { id: "api-reach", - label: "api reach ", + label: doctorLabel("apiReach"), level: "ok", - detail: summary ? `/user/balance ok — ${summary}` : "/user/balance ok", + detail: summary + ? t("doctorReport.details.apiReachOkWithBalance", { summary }) + : t("doctorReport.details.apiReachOk"), }; } catch (err) { return { id: "api-reach", - label: "api reach ", + label: doctorLabel("apiReach"), level: "fail", detail: `${(err as Error).message}`, }; @@ -244,7 +262,7 @@ async function checkTokenizer(): Promise { const stat = statSync(path); return { id: "tokenizer", - label: "tokenizer ", + label: doctorLabel("tokenizer"), level: "ok", detail: `${path} (${fmtBytes(stat.size)})`, }; @@ -254,10 +272,9 @@ async function checkTokenizer(): Promise { } return { id: "tokenizer", - label: "tokenizer ", + label: doctorLabel("tokenizer"), level: "warn", - detail: - "data/deepseek-tokenizer.json.gz not found — token counts will fall back to char heuristics", + detail: t("doctorReport.details.tokenizerMissing"), }; } @@ -267,9 +284,9 @@ async function checkSessions(): Promise { if (list.length === 0) { return { id: "sessions", - label: "sessions ", + label: doctorLabel("sessions"), level: "ok", - detail: "0 saved", + detail: t("doctorReport.details.sessionsZero"), }; } const totalBytes = list.reduce((s, e) => s + e.size, 0); @@ -278,20 +295,24 @@ async function checkSessions(): Promise { const stale = list.filter( (e) => Date.now() - e.mtime.getTime() >= 90 * 24 * 60 * 60 * 1000, ).length; - const detail = `${list.length} saved · ${fmtBytes(totalBytes)} · oldest ${ageDays}d`; + const detail = t("doctorReport.details.sessionsSummary", { + count: list.length, + size: fmtBytes(totalBytes), + days: ageDays, + }); if (stale > 0) { return { id: "sessions", - label: "sessions ", + label: doctorLabel("sessions"), level: "warn", - detail: `${detail} · ${stale} idle ≥90d (run \`carboncode prune-sessions\`)`, + detail: t("doctorReport.details.sessionsStale", { detail, stale }), }; } - return { id: "sessions", label: "sessions ", level: "ok", detail }; + return { id: "sessions", label: doctorLabel("sessions"), level: "ok", detail }; } catch (err) { return { id: "sessions", - label: "sessions ", + label: doctorLabel("sessions"), level: "warn", detail: t("doctorErrors.cannotList", { message: (err as Error).message }), }; @@ -305,14 +326,14 @@ async function checkHooks(projectRoot: string): Promise { const project = all.filter((h) => h.scope === "project").length; return { id: "hooks", - label: "hooks ", + label: doctorLabel("hooks"), level: "ok", - detail: `${global} global, ${project} project`, + detail: t("doctorReport.details.hooksSummary", { global, project }), }; } catch (err) { return { id: "hooks", - label: "hooks ", + label: doctorLabel("hooks"), level: "warn", detail: t("doctorErrors.parseFailed", { message: (err as Error).message }), }; @@ -329,9 +350,9 @@ async function checkOllama(projectRoot: string): Promise { if (!exists) { return { id: "semantic", - label: "semantic ", + label: doctorLabel("semantic"), level: "ok", - detail: "not in use (no semantic index built; `carboncode index` to enable)", + detail: t("doctorReport.details.semanticNoIndex"), }; } const meta = readSemanticMeta(projectRoot); @@ -340,16 +361,24 @@ async function checkOllama(projectRoot: string): Promise { if (resolved.provider !== "openai-compat") { return { id: "semantic", - label: "semantic ", + label: doctorLabel("semantic"), level: "warn", - detail: `index uses openai-compat/${meta.model} but current config resolves to ${resolved.provider}/${resolved.model} — rebuild before searching`, + detail: t("doctorReport.details.semanticMismatch", { + indexProvider: "openai-compat", + indexModel: meta.model, + configProvider: resolved.provider, + configModel: resolved.model, + }), }; } return { id: "semantic", - label: "semantic ", + label: doctorLabel("semantic"), level: "ok", - detail: `openai-compat · ${resolved.baseUrl} · model ${resolved.model} · api key configured`, + detail: t("doctorReport.details.semanticOpenaiOk", { + baseUrl: resolved.baseUrl, + model: resolved.model, + }), }; } try { @@ -358,39 +387,39 @@ async function checkOllama(projectRoot: string): Promise { if (!status.binaryFound) { return { id: "semantic", - label: "semantic ", + label: doctorLabel("semantic"), level: "warn", - detail: - "ollama binary not on PATH — semantic_search will fail; install from https://ollama.com", + detail: t("doctorReport.details.semanticOllamaMissing"), }; } if (!status.daemonRunning) { return { id: "semantic", - label: "semantic ", + label: doctorLabel("semantic"), level: "warn", - detail: - "ollama daemon not running — `ollama serve` (or call /semantic in TUI to auto-start)", + detail: t("doctorReport.details.semanticOllamaStopped"), }; } if (!status.modelPulled) { return { id: "semantic", - label: "semantic ", + label: doctorLabel("semantic"), level: "warn", - detail: `model ${status.modelName} not pulled — \`ollama pull ${status.modelName}\``, + detail: t("doctorReport.details.semanticOllamaModelMissing", { + model: status.modelName, + }), }; } return { id: "semantic", - label: "semantic ", + label: doctorLabel("semantic"), level: "ok", - detail: `ollama daemon up · model ${status.modelName} ready`, + detail: t("doctorReport.details.semanticOllamaReady", { model: status.modelName }), }; } catch (err) { return { id: "semantic", - label: "semantic ", + label: doctorLabel("semantic"), level: "warn", detail: t("doctorErrors.probeFailed", { message: (err as Error).message }), }; @@ -432,16 +461,19 @@ async function checkProject(projectRoot: string): Promise { if (found.length === 0) { return { id: "project", - label: "project ", + label: doctorLabel("project"), level: "warn", - detail: `${projectRoot} has none of: ${markers.slice(0, 3).join(", ")} … — \`carboncode code\` will still run, but @-mentions and project memory have nothing to anchor`, + detail: t("doctorReport.details.projectMissing", { + path: projectRoot, + markers: markers.slice(0, 3).join(", "), + }), }; } return { id: "project", - label: "project ", + label: doctorLabel("project"), level: "ok", - detail: `${projectRoot} (${found.join(", ")})`, + detail: t("doctorReport.details.projectOk", { path: projectRoot, markers: found.join(", ") }), }; } diff --git a/src/i18n/EN.ts b/src/i18n/EN.ts index 0ac9539..e282fa0 100644 --- a/src/i18n/EN.ts +++ b/src/i18n/EN.ts @@ -1380,6 +1380,54 @@ export const EN: TranslationSchema = { parseFailed: "couldn't parse settings.json \u2014 {message}", probeFailed: "probe failed \u2014 {message}", }, + doctorReport: { + labels: { + apiKey: "api key", + config: "config", + proxy: "http proxy", + apiReach: "api reach", + tokenizer: "tokenizer", + sessions: "sessions", + hooks: "hooks", + semantic: "semantic", + project: "project", + }, + details: { + proxyDirect: "no HTTPS_PROXY / HTTP_PROXY / ALL_PROXY set \u2014 direct connection", + proxyRouting: "routing fetch through {url}", + apiKeyEnv: "set via env DEEPSEEK_API_KEY ({tail})", + apiKeyConfig: "from {path} ({tail})", + apiKeyMissing: + "not set \u2014 `carboncode setup` to save one, or export DEEPSEEK_API_KEY. Get a key at https://platform.deepseek.com/api_keys", + configMissing: "missing \u2014 running with library defaults. `carboncode setup` writes one.", + apiReachSkipped: "skipped \u2014 no api key to test with", + apiReachNull: "/user/balance returned null \u2014 auth failed or network blocked", + apiReachUnavailable: + "account flagged not-available ({summary}) \u2014 top up or check your dashboard", + apiReachOk: "/user/balance ok", + apiReachOkWithBalance: "/user/balance ok \u2014 {summary}", + tokenizerMissing: + "data/deepseek-tokenizer.json.gz not found \u2014 token counts will fall back to char heuristics", + sessionsZero: "0 saved", + sessionsSummary: "{count} saved \u00b7 {size} \u00b7 oldest {days}d", + sessionsStale: "{detail} \u00b7 {stale} idle \u226590d (run `carboncode prune-sessions`)", + hooksSummary: "{global} global, {project} project", + semanticNoIndex: "not in use (no semantic index built; `carboncode index` to enable)", + semanticMismatch: + "index uses {indexProvider}/{indexModel} but current config resolves to {configProvider}/{configModel} \u2014 rebuild before searching", + semanticOpenaiOk: + "openai-compat \u00b7 {baseUrl} \u00b7 model {model} \u00b7 api key configured", + semanticOllamaMissing: + "ollama binary not on PATH \u2014 semantic_search will fail; install from https://ollama.com", + semanticOllamaStopped: + "ollama daemon not running \u2014 `ollama serve` (or call /semantic in TUI to auto-start)", + semanticOllamaModelMissing: "model {model} not pulled \u2014 `ollama pull {model}`", + semanticOllamaReady: "ollama daemon up \u00b7 model {model} ready", + projectMissing: + "{path} has none of: {markers} \u2026 \u2014 `carboncode code` will still run, but @-mentions and project memory have nothing to anchor", + projectOk: "{path} ({markers})", + }, + }, webErrors: { status: "web_search {status} \u2014 try: the search backend returned an error; rephrase the query, or switch engine with /search-engine mojeek|searxng", diff --git a/src/i18n/types.ts b/src/i18n/types.ts index 8282c0a..bedc999 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -613,6 +613,46 @@ export interface TranslationSchema { parseFailed: string; probeFailed: string; }; + doctorReport: { + labels: { + apiKey: string; + config: string; + proxy: string; + apiReach: string; + tokenizer: string; + sessions: string; + hooks: string; + semantic: string; + project: string; + }; + details: { + proxyDirect: string; + proxyRouting: string; + apiKeyEnv: string; + apiKeyConfig: string; + apiKeyMissing: string; + configMissing: string; + apiReachSkipped: string; + apiReachNull: string; + apiReachUnavailable: string; + apiReachOk: string; + apiReachOkWithBalance: string; + tokenizerMissing: string; + sessionsZero: string; + sessionsSummary: string; + sessionsStale: string; + hooksSummary: string; + semanticNoIndex: string; + semanticMismatch: string; + semanticOpenaiOk: string; + semanticOllamaMissing: string; + semanticOllamaStopped: string; + semanticOllamaModelMissing: string; + semanticOllamaReady: string; + projectMissing: string; + projectOk: string; + }; + }; webErrors: { status: string; rateLimit429: string; diff --git a/src/i18n/zh-CN.ts b/src/i18n/zh-CN.ts index 7afc2e3..2a96879 100644 --- a/src/i18n/zh-CN.ts +++ b/src/i18n/zh-CN.ts @@ -1308,6 +1308,51 @@ export const zhCN: TranslationSchema = { parseFailed: "无法解析 settings.json — {message}", probeFailed: "探测失败 — {message}", }, + doctorReport: { + labels: { + apiKey: "API 密钥", + config: "配置", + proxy: "HTTP 代理", + apiReach: "API 连通", + tokenizer: "分词器", + sessions: "会话", + hooks: "钩子", + semantic: "语义索引", + project: "项目", + }, + details: { + proxyDirect: "未设置 HTTPS_PROXY / HTTP_PROXY / ALL_PROXY — 直连", + proxyRouting: "请求将通过代理 {url}", + apiKeyEnv: "通过环境变量 DEEPSEEK_API_KEY 设置({tail})", + apiKeyConfig: "来自 {path}({tail})", + apiKeyMissing: + "未设置 — 运行 `carboncode setup` 保存,或设置环境变量 DEEPSEEK_API_KEY。可在 https://platform.deepseek.com/api_keys 获取 key", + configMissing: "配置文件缺失 — 当前使用默认设置。运行 `carboncode setup` 会写入配置。", + apiReachSkipped: "已跳过 — 没有可用于测试的 API key", + apiReachNull: "/user/balance 返回空结果 — 可能是认证失败或网络被阻断", + apiReachUnavailable: "账号当前不可用({summary})— 请充值,或在 DeepSeek 控制台检查账号状态", + apiReachOk: "/user/balance 正常", + apiReachOkWithBalance: "/user/balance 正常 — {summary}", + tokenizerMissing: "未找到 data/deepseek-tokenizer.json.gz — token 统计将退回到字符数估算", + sessionsZero: "0 个已保存会话", + sessionsSummary: "{count} 个已保存 · {size} · 最早 {days} 天前", + sessionsStale: "{detail} · {stale} 个已闲置 ≥90 天(运行 `carboncode prune-sessions` 清理)", + hooksSummary: "{global} 个全局,{project} 个项目", + semanticNoIndex: "未启用(没有构建语义索引;运行 `carboncode index` 可启用)", + semanticMismatch: + "索引使用 {indexProvider}/{indexModel},但当前配置是 {configProvider}/{configModel} — 搜索前请重建索引", + semanticOpenaiOk: "openai-compat · {baseUrl} · 模型 {model} · API key 已配置", + semanticOllamaMissing: + "PATH 中找不到 ollama — semantic_search 将不可用;请从 https://ollama.com 安装", + semanticOllamaStopped: + "ollama 服务未运行 — 执行 `ollama serve`,或在 TUI 中调用 /semantic 自动启动", + semanticOllamaModelMissing: "模型 {model} 尚未拉取 — 执行 `ollama pull {model}`", + semanticOllamaReady: "ollama 服务运行中 · 模型 {model} 已就绪", + projectMissing: + "{path} 中没有 {markers} 等项目标记 — `carboncode code` 仍可运行,但 @ 引用和项目记忆缺少锚点", + projectOk: "{path}({markers})", + }, + }, webErrors: { status: "web_search {status} — try: 搜索后端返回错误;请改写查询,或使用 /search-engine mojeek|searxng 切换引擎", diff --git a/src/server/api/health.ts b/src/server/api/health.ts index 6583d11..d43fcea 100644 --- a/src/server/api/health.ts +++ b/src/server/api/health.ts @@ -1,9 +1,7 @@ import { existsSync, readdirSync, statSync } from "node:fs"; -import { homedir } from "node:os"; import { join } from "node:path"; -import { listSessions } from "../../memory/session.js"; import { VERSION } from "../../version.js"; -import type { DashboardContext } from "../context.js"; +import { type DashboardContext, resolveCarboncodeHome } from "../context.js"; import type { ApiResult } from "../router.js"; interface DirStat { @@ -56,6 +54,17 @@ function dirSize(path: string): DirStat { return { path, exists: true, fileCount, totalBytes }; } +function countSessionFiles(path: string): number { + if (!existsSync(path)) return 0; + try { + return readdirSync(path).filter( + (file) => file.endsWith(".jsonl") && !file.endsWith(".events.jsonl"), + ).length; + } catch { + return 0; + } +} + export async function handleHealth( method: string, _rest: string[], @@ -65,8 +74,7 @@ export async function handleHealth( if (method !== "GET") { return { status: 405, body: { error: "GET only" } }; } - const home = homedir(); - const carboncodeHome = join(home, ".carboncode"); + const carboncodeHome = resolveCarboncodeHome(ctx.configPath); const sessionsStat = dirSize(join(carboncodeHome, "sessions")); const memoryStat = dirSize(join(carboncodeHome, "memory")); @@ -81,8 +89,6 @@ export async function handleHealth( } } - const sessions = listSessions(); - return { status: 200, body: { @@ -91,7 +97,7 @@ export async function handleHealth( carboncodeHome, sessions: { path: sessionsStat.path, - count: sessions.length, + count: countSessionFiles(sessionsStat.path), totalBytes: sessionsStat.totalBytes, }, memory: { diff --git a/src/server/api/memory.ts b/src/server/api/memory.ts index e244ce5..28871d0 100644 --- a/src/server/api/memory.ts +++ b/src/server/api/memory.ts @@ -10,26 +10,25 @@ import { unlinkSync, writeFileSync, } from "node:fs"; -import { homedir } from "node:os"; import { basename, dirname, join, resolve as resolvePath } from "node:path"; import { PROJECT_MEMORY_FILE, findProjectMemoryPath, resolveProjectMemoryWritePath, } from "../../memory/project.js"; -import type { DashboardContext } from "../context.js"; +import { type DashboardContext, resolveCarboncodeHome } from "../context.js"; import type { ApiResult } from "../router.js"; function projectHash(rootDir: string): string { return createHash("sha1").update(resolvePath(rootDir)).digest("hex").slice(0, 16); } -function globalMemoryDir(): string { - return join(homedir(), ".carboncode", "memory", "global"); +function globalMemoryDir(carboncodeHome: string): string { + return join(carboncodeHome, "memory", "global"); } -function projectMemoryDir(rootDir: string): string { - return join(homedir(), ".carboncode", "memory", projectHash(rootDir)); +function projectMemoryDir(carboncodeHome: string, rootDir: string): string { + return join(carboncodeHome, "memory", projectHash(rootDir)); } interface WriteBody { @@ -74,8 +73,9 @@ export async function handleMemory( ctx: DashboardContext, ): Promise { const cwd = ctx.getCurrentCwd?.(); - const globalDir = globalMemoryDir(); - const projectMemDir = cwd ? projectMemoryDir(cwd) : ""; + const carboncodeHome = resolveCarboncodeHome(ctx.configPath); + const globalDir = globalMemoryDir(carboncodeHome); + const projectMemDir = cwd ? projectMemoryDir(carboncodeHome, cwd) : ""; if (method === "GET" && rest.length === 0) { const existingProjectMemory = cwd ? findProjectMemoryPath(cwd) : null; diff --git a/src/server/context.ts b/src/server/context.ts index 83fa7c8..50faefa 100644 --- a/src/server/context.ts +++ b/src/server/context.ts @@ -1,11 +1,20 @@ /** Callbacks (not refs) so endpoints read live loop state per request, not a frozen closure. */ +import { homedir } from "node:os"; +import { basename, dirname, join } from "node:path"; import type { McpServerSummary } from "../cli/ui/slash/types.js"; import type { EditMode } from "../config.js"; import type { CacheFirstLoop } from "../loop.js"; import type { ToolRegistry } from "../tools.js"; import type { JobRegistry } from "../tools/jobs.js"; +export function resolveCarboncodeHome(configPath: string): string { + if (!configPath.trim()) return join(homedir(), ".carboncode"); + const configDir = dirname(configPath); + if (basename(configDir).toLowerCase() === ".carboncode") return configDir; + return join(configDir, ".carboncode"); +} + export interface DashboardContext { /** Caller resolves via `defaultConfigPath()`; module deliberately avoids `homedir()` so tests can redirect. */ configPath: string; diff --git a/tests/cli-bare-routing.test.ts b/tests/cli-bare-routing.test.ts index 8c99a98..9556d7f 100644 --- a/tests/cli-bare-routing.test.ts +++ b/tests/cli-bare-routing.test.ts @@ -6,6 +6,7 @@ import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { writeConfig } from "../src/config.js"; +const HEAP_REEXEC_ENV = "REASONIX_HEAP_REEXEC"; const codeCommand = vi.fn(async () => {}); const chatCommand = vi.fn(async () => {}); const setupCommand = vi.fn(async () => {}); @@ -25,6 +26,7 @@ describe("bare CLI routing", () => { let cwd: string; const origHome = process.env.HOME; const origUserProfile = process.env.USERPROFILE; + const origHeapReexec = process.env[HEAP_REEXEC_ENV]; const origArgv = process.argv; const origCwd = process.cwd(); let stderr: ReturnType; @@ -38,6 +40,7 @@ describe("bare CLI routing", () => { cwd = realpathSync(mkdtempSync(join(tmpdir(), "carboncode-cli-cwd-"))); process.env.HOME = home; process.env.USERPROFILE = home; + process.env[HEAP_REEXEC_ENV] = "1"; process.chdir(cwd); codeCommand.mockClear(); chatCommand.mockClear(); @@ -63,6 +66,11 @@ describe("bare CLI routing", () => { } else { process.env.USERPROFILE = origUserProfile; } + if (origHeapReexec === undefined) { + delete process.env[HEAP_REEXEC_ENV]; + } else { + process.env[HEAP_REEXEC_ENV] = origHeapReexec; + } }); it("routes bare carboncode to code mode rooted at cwd", async () => { diff --git a/tests/doctor-json.test.ts b/tests/doctor-json.test.ts index 8f92e60..f4917fc 100644 --- a/tests/doctor-json.test.ts +++ b/tests/doctor-json.test.ts @@ -4,9 +4,19 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { type DoctorCheck, doctorCommand, formatDoctorJson } from "../src/cli/commands/doctor.js"; +import { + type DoctorCheck, + doctorCommand, + formatDoctorJson, + runDoctorChecks, +} from "../src/cli/commands/doctor.js"; +import { setLanguageRuntime } from "../src/i18n/index.js"; import { VERSION } from "../src/version.js"; +afterEach(() => { + setLanguageRuntime("EN"); +}); + describe("formatDoctorJson", () => { it("emits version, summary, and {id,status,message} per check", () => { const checks: DoctorCheck[] = [ @@ -104,3 +114,35 @@ describe("doctorCommand --json (integration)", () => { } }); }); + +describe("doctorCommand i18n", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("localizes plain zh-CN check labels and common details", async () => { + const tmpHome = mkdtempSync(join(tmpdir(), "carboncode-doctor-home-")); + const tmpCwd = mkdtempSync(join(tmpdir(), "carboncode-doctor-cwd-")); + try { + vi.stubEnv("HOME", tmpHome); + vi.stubEnv("USERPROFILE", tmpHome); + vi.stubEnv("DEEPSEEK_API_KEY", ""); + setLanguageRuntime("zh-CN"); + + const checks = await runDoctorChecks(tmpCwd); + const apiKey = checks.find((c) => c.id === "api-key"); + const apiReach = checks.find((c) => c.id === "api-reach"); + const semantic = checks.find((c) => c.id === "semantic"); + + expect(apiKey?.label.trim()).toBe("API 密钥"); + expect(apiKey?.detail).toContain("未设置"); + expect(apiReach?.label.trim()).toBe("API 连通"); + expect(apiReach?.detail).toContain("已跳过"); + expect(semantic?.label.trim()).toBe("语义索引"); + expect(semantic?.detail).toContain("未启用"); + } finally { + rmSync(tmpHome, { recursive: true, force: true }); + rmSync(tmpCwd, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/helpers/codex-parity-harness.ts b/tests/helpers/codex-parity-harness.ts index 67e127c..2bb03c6 100644 --- a/tests/helpers/codex-parity-harness.ts +++ b/tests/helpers/codex-parity-harness.ts @@ -139,6 +139,7 @@ function runCommand( const child = spawn(command, [...args], { cwd, env: { ...process.env, CI: "1" }, + shell: process.platform === "win32", stdio: ["ignore", "pipe", "pipe"], }); const chunks: Buffer[] = [];