From 01b68053838ef43f6ed1a4d1374865676941f247 Mon Sep 17 00:00:00 2001 From: hcyang Date: Tue, 23 Jun 2026 14:42:59 +0800 Subject: [PATCH 1/9] =?UTF-8?q?chore(vscode-ide-companion):=20=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=20package.json=20=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 preview 字段,标记为预览版本 - 修正 repository URL 结构,添加目录字段 - 扩展 categories,新增 Chat 分类 - 格式化部分字段列表,使结构更清晰 --- packages/vscode-ide-companion/package.json | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index fd4da3ac..6369f37b 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -7,15 +7,18 @@ "license": "MIT", "type": "commonjs", "main": "./out/extension.js", + "preview": true, "repository": { "type": "git", - "url": "git+https://github.com/lessweb/deepcode-cli.git" + "url": "https://github.com/lessweb/deepcode-cli.git", + "directory": "packages/vscode-ide-companion" }, "engines": { "vscode": "^1.85.0" }, "categories": [ - "AI" + "AI", + "Chat" ], "keywords": [ "deep-code", From ad11d9af15bb7114d32c4da02c78394adb5a60a7 Mon Sep 17 00:00:00 2001 From: hcyang Date: Tue, 23 Jun 2026 16:08:24 +0800 Subject: [PATCH 2/9] =?UTF-8?q?feat(cli):=20=E6=94=AF=E6=8C=81=E9=80=9A?= =?UTF-8?q?=E8=BF=87=20--resume=20=E5=8F=82=E6=95=B0=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 App 组件中新增 resumeSessionId 属性以支持恢复会话功能 - 在 AppContainer 中传递 resumeSessionId 以贯穿组件树 - 在 cli.tsx 中解析并传递 --resume 参数,支持无 ID 列出会话选择 - 新增构建退出摘要时显示 resume 会话提示的逻辑 - 添加对应单元测试覆盖 resumeSessionId 解析及退出摘要展示 - 抽离 cli 参数解析函数,支持提取初始提示和恢复会话 ID - 修复退出摘要文本,添加 resume 使用提示,提升用户体验 --- packages/cli/src/cli-args.ts | 31 +++++++++ packages/cli/src/cli.tsx | 12 ++-- packages/cli/src/tests/cli-args.test.ts | 76 +++++++++++++++++++++ packages/cli/src/tests/exit-summary.test.ts | 38 +++++++++++ packages/cli/src/ui/exit-summary.ts | 9 ++- packages/cli/src/ui/views/App.tsx | 21 +++++- packages/cli/src/ui/views/AppContainer.tsx | 10 ++- 7 files changed, 184 insertions(+), 13 deletions(-) create mode 100644 packages/cli/src/cli-args.ts create mode 100644 packages/cli/src/tests/cli-args.test.ts diff --git a/packages/cli/src/cli-args.ts b/packages/cli/src/cli-args.ts new file mode 100644 index 00000000..5ae0325e --- /dev/null +++ b/packages/cli/src/cli-args.ts @@ -0,0 +1,31 @@ +/** + * CLI argument parsing helpers. + * Extracted from cli.tsx for testability. + */ + +export function extractInitialPrompt(args: string[]): string | undefined { + const promptIndex = args.findIndex((arg) => arg === "-p" || arg === "--prompt"); + if (promptIndex !== -1 && promptIndex + 1 < args.length) { + return args[promptIndex + 1]; + } + return undefined; +} + +/** + * Extract the --resume flag value. + * + * Returns: + * - `undefined` — `--resume` was not used + * - `true` — `--resume` was used without a session ID (show session picker) + * - `string` — `--resume ` was used (resume specific session) + */ +export function extractResumeSessionId(args: string[]): string | true | undefined { + const idx = args.findIndex((arg) => arg === "--resume"); + if (idx === -1) { + return undefined; + } + if (idx + 1 < args.length && !args[idx + 1].startsWith("-")) { + return args[idx + 1]; + } + return true; +} diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index c595916b..4812f9eb 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -3,6 +3,7 @@ import { render } from "ink"; import { setShellIfWindows } from "@vegamo/deepcode-core"; import { checkForNpmUpdate, promptForPendingUpdate, type PackageInfo } from "./common/update-check"; import { AppContainer } from "./ui"; +import { extractInitialPrompt, extractResumeSessionId } from "./cli-args"; const args = process.argv.slice(2); const packageInfo = readPackageInfo(); @@ -21,6 +22,7 @@ if (args.includes("--help") || args.includes("-h")) { " deepcode Launch the interactive TUI in the current directory", " deepcode -p Launch with a pre-filled prompt", " deepcode --prompt Same as -p", + " deepcode --resume [sessionId] Resume a specific session by its ID. Use without an ID to show session picker", " deepcode --version Print the version", " deepcode --help Show this help", "", @@ -58,15 +60,8 @@ if (args.includes("--help") || args.includes("-h")) { process.exit(0); } -function extractInitialPrompt(args: string[]): string | undefined { - const promptIndex = args.findIndex((arg) => arg === "-p" || arg === "--prompt"); - if (promptIndex !== -1 && promptIndex + 1 < args.length) { - return args[promptIndex + 1]; - } - return undefined; -} - let initialPrompt = extractInitialPrompt(args); +const resumeSessionId = extractResumeSessionId(args); const projectRoot = process.cwd(); configureWindowsShell(); @@ -94,6 +89,7 @@ async function main(): Promise { projectRoot={projectRoot} version={packageInfo.version} initialPrompt={appInitialPrompt} + resumeSessionId={resumeSessionId} onRestart={() => restartRef.current?.()} />, { exitOnCtrlC: false } diff --git a/packages/cli/src/tests/cli-args.test.ts b/packages/cli/src/tests/cli-args.test.ts new file mode 100644 index 00000000..e42a4b00 --- /dev/null +++ b/packages/cli/src/tests/cli-args.test.ts @@ -0,0 +1,76 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { extractInitialPrompt, extractResumeSessionId } from "../cli-args"; + +// ── extractInitialPrompt ───────────────────────────────────────────────────── + +test("extractInitialPrompt returns prompt after -p", () => { + assert.equal(extractInitialPrompt(["-p", "hello world"]), "hello world"); +}); + +test("extractInitialPrompt returns prompt after --prompt", () => { + assert.equal(extractInitialPrompt(["--prompt", "hello world"]), "hello world"); +}); + +test("extractInitialPrompt returns undefined when -p is not present", () => { + assert.equal(extractInitialPrompt(["--version"]), undefined); +}); + +test("extractInitialPrompt returns undefined when -p has no value", () => { + assert.equal(extractInitialPrompt(["-p"]), undefined); +}); + +test("extractInitialPrompt returns undefined for empty args", () => { + assert.equal(extractInitialPrompt([]), undefined); +}); + +test("extractInitialPrompt ignores -p in non-flag position", () => { + assert.equal(extractInitialPrompt(["--resume", "-p", "hello"]), "hello"); +}); + +// ── extractResumeSessionId ─────────────────────────────────────────────────── + +test("extractResumeSessionId returns session ID after --resume", () => { + assert.equal( + extractResumeSessionId(["--resume", "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"]), + "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6" + ); +}); + +test("extractResumeSessionId returns true when --resume has no value (show picker)", () => { + assert.equal(extractResumeSessionId(["--resume"]), true); +}); + +test("extractResumeSessionId returns true when --resume is followed by another flag", () => { + assert.equal(extractResumeSessionId(["--resume", "--force"]), true); +}); + +test("extractResumeSessionId returns undefined when --resume is not present", () => { + assert.equal(extractResumeSessionId(["--version"]), undefined); +}); + +test("extractResumeSessionId returns undefined for empty args", () => { + assert.equal(extractResumeSessionId([]), undefined); +}); + +test("extractResumeSessionId works with other flags after sessionId", () => { + assert.equal(extractResumeSessionId(["--resume", "abc-123", "--force"]), "abc-123"); +}); + +test("extractResumeSessionId does not confuse --resume with other args", () => { + assert.equal(extractResumeSessionId(["-p", "test"]), undefined); +}); + +// ── combined usage ─────────────────────────────────────────────────────────── + +test("extractInitialPrompt and extractResumeSessionId work independently", () => { + const args = ["--resume", "session-123", "-p", "hello"]; + assert.equal(extractResumeSessionId(args), "session-123"); + assert.equal(extractInitialPrompt(args), "hello"); +}); + +test("extractResumeSessionId with --resume and -p but no sessionId", () => { + const args = ["--resume", "-p", "hello"]; + assert.equal(extractResumeSessionId(args), true); + assert.equal(extractInitialPrompt(args), "hello"); +}); diff --git a/packages/cli/src/tests/exit-summary.test.ts b/packages/cli/src/tests/exit-summary.test.ts index e0d481db..d768c165 100644 --- a/packages/cli/src/tests/exit-summary.test.ts +++ b/packages/cli/src/tests/exit-summary.test.ts @@ -90,6 +90,44 @@ test("buildExitSummaryText does not derive usage rows from legacy aggregate usag assert.doesNotMatch(summary, /11,966/); }); +test("buildExitSummaryText shows resume hint when sessionId is provided", () => { + const sessionId = "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"; + const summary = stripAnsi( + buildExitSummaryText({ + session: buildSession(null), + sessionId, + }) + ); + + assert.match(summary, /Goodbye!/); + assert.match(summary, /deepcode --resume 0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6/); + assert.match(summary, /To continue this session/); +}); + +test("buildExitSummaryText does not show resume hint when sessionId is omitted", () => { + const summary = stripAnsi( + buildExitSummaryText({ + session: buildSession(null), + }) + ); + + assert.match(summary, /Goodbye!/); + assert.doesNotMatch(summary, /deepcode --resume/); + assert.doesNotMatch(summary, /To continue this session/); +}); + +test("buildExitSummaryText shows resume hint with null session", () => { + const summary = stripAnsi( + buildExitSummaryText({ + session: null, + sessionId: "test-session-id", + }) + ); + + assert.match(summary, /Goodbye!/); + assert.match(summary, /deepcode --resume test-session-id/); +}); + function buildSession(usage: ModelUsage | null, usagePerModel: Record | null = null): SessionEntry { return { id: "session-1", diff --git a/packages/cli/src/ui/exit-summary.ts b/packages/cli/src/ui/exit-summary.ts index 25e09b48..baef723a 100644 --- a/packages/cli/src/ui/exit-summary.ts +++ b/packages/cli/src/ui/exit-summary.ts @@ -4,6 +4,7 @@ import type { ModelUsage, SessionEntry } from "@vegamo/deepcode-core"; type ExitSummaryInput = { session: SessionEntry | null; + sessionId?: string; }; const ANSI_RE = /\u001b\[[0-9;]*[a-zA-Z]/g; @@ -67,7 +68,7 @@ function extractUsageFields(usage: ModelUsage | null): UsageFields { } export function buildExitSummaryText(input: ExitSummaryInput): string { - const { session } = input; + const { session, sessionId } = input; const innerWidth = 98; const contentWidth = innerWidth - 4; // "│ " prefix + " │" suffix → 4 chars padding @@ -134,6 +135,12 @@ export function buildExitSummaryText(input: ExitSummaryInput): string { rows.push(""); + if (sessionId) { + const resumeHint = chalk.dim(`To continue this session, run deepcode --resume ${sessionId}`); + rows.push(resumeHint); + rows.push(""); + } + const border = borderColor("─".repeat(innerWidth)); const top = `${borderColor("╭")}${border}${borderColor("╮")}`; const bottom = `${borderColor("╰")}${border}${borderColor("╯")}`; diff --git a/packages/cli/src/ui/views/App.tsx b/packages/cli/src/ui/views/App.tsx index fe1f81cf..afcaa26c 100644 --- a/packages/cli/src/ui/views/App.tsx +++ b/packages/cli/src/ui/views/App.tsx @@ -53,6 +53,7 @@ const STATUS_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", type AppProps = { projectRoot: string; initialPrompt?: string; + resumeSessionId?: string | true; onRestart?: () => void; }; @@ -89,12 +90,13 @@ const StatusLine = React.memo(function StatusLine({ ); }); -function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactElement { +function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProps): React.ReactElement { const { exit } = useApp(); const { stdout, write } = useStdout(); const { columns, rows } = useWindowSize(); const { mode, setMode } = useRawModeContext(); const initialPromptSubmittedRef = useRef(false); + const resumeSessionIdRef = useRef(false); const processStdoutRef = useRef>(new Map()); const rawModeRef = useRef(mode); const writeRef = useRef(write); @@ -288,7 +290,7 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl setTimeout(() => { const activeSessionId = sessionManager.getActiveSessionId(); const session = activeSessionId ? sessionManager.getSession(activeSessionId) : null; - const summary = buildExitSummaryText({ session }); + const summary = buildExitSummaryText({ session, sessionId: activeSessionId ?? undefined }); process.stdout.write("\n"); process.stdout.write(chalk.rgb(34, 154, 195)("> /exit ")); process.stdout.write("\n\n"); @@ -506,6 +508,21 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl [sessionManager, resetStaticView, pendingPermissionReply, refreshSkills] ); + useEffect(() => { + if (resumeSessionIdRef.current || !resumeSessionId) { + return; + } + + resumeSessionIdRef.current = true; + if (resumeSessionId === true) { + // No session ID — show the session picker (same as /resume) + refreshSessionsList(); + navigateToSubView("session-list"); + } else { + handleSelectSession(resumeSessionId); + } + }, [handleSelectSession, navigateToSubView, refreshSessionsList, resumeSessionId]); + const handleDeleteSession = useCallback( async (id: string): Promise => { const isActiveSession = sessionManager.getActiveSessionId() === id; diff --git a/packages/cli/src/ui/views/AppContainer.tsx b/packages/cli/src/ui/views/AppContainer.tsx index d5f6363a..555588f1 100644 --- a/packages/cli/src/ui/views/AppContainer.tsx +++ b/packages/cli/src/ui/views/AppContainer.tsx @@ -7,12 +7,18 @@ const AppContainer: React.FC<{ projectRoot: string; version: string; initialPrompt: string | undefined; + resumeSessionId: string | true | undefined; onRestart: () => void; -}> = ({ version, projectRoot, initialPrompt, onRestart }) => { +}> = ({ version, projectRoot, initialPrompt, resumeSessionId, onRestart }) => { return ( - + ); From 361f2b171fe72efd4d588b5730105b22626a1394 Mon Sep 17 00:00:00 2001 From: hcyang Date: Tue, 23 Jun 2026 16:31:37 +0800 Subject: [PATCH 3/9] =?UTF-8?q?fix(cli):=20=E9=AA=8C=E8=AF=81=20--resume?= =?UTF-8?q?=20=E5=8F=82=E6=95=B0=E4=B8=AD=E7=9A=84=E4=BC=9A=E8=AF=9DID?= =?UTF-8?q?=E6=9C=89=E6=95=88=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在启动 TUI 之前校验传入的 resumeSessionId 是否在本地会话索引中存在 - 读取用户主目录下的 sessions-index.json 文件进行会话ID验证 - 未找到匹配会话时输出错误信息并退出进程 - 在 App 组件中移除对会话ID重复验证的注释补充说明 - 确保 resumeSessionId 已经校验通过后才调用 handleSelectSession --- packages/cli/src/cli.tsx | 22 +++++++++++++++++++++- packages/cli/src/ui/views/App.tsx | 1 + 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index 4812f9eb..80513f78 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -1,6 +1,9 @@ import React from "react"; import { render } from "ink"; -import { setShellIfWindows } from "@vegamo/deepcode-core"; +import { readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import { setShellIfWindows, getProjectCode } from "@vegamo/deepcode-core"; import { checkForNpmUpdate, promptForPendingUpdate, type PackageInfo } from "./common/update-check"; import { AppContainer } from "./ui"; import { extractInitialPrompt, extractResumeSessionId } from "./cli-args"; @@ -65,6 +68,23 @@ const resumeSessionId = extractResumeSessionId(args); const projectRoot = process.cwd(); configureWindowsShell(); +// Validate --resume before entering TUI +if (typeof resumeSessionId === "string") { + const projectCode = getProjectCode(projectRoot); + const indexPath = join(homedir(), ".deepcode", "projects", projectCode, "sessions-index.json"); + try { + const index = JSON.parse(readFileSync(indexPath, "utf-8")); + const found = Array.isArray(index?.entries) && index.entries.some((e: { id: string }) => e.id === resumeSessionId); + if (!found) { + process.stderr.write(`No saved session found with ID "${resumeSessionId}".\n`); + process.exit(1); + } + } catch { + process.stderr.write(`No saved session found with ID "${resumeSessionId}".\n`); + process.exit(1); + } +} + if (!process.stdin.isTTY) { process.stderr.write("deepcode requires an interactive terminal (TTY). " + "Re-run from a real terminal session.\n"); process.exit(1); diff --git a/packages/cli/src/ui/views/App.tsx b/packages/cli/src/ui/views/App.tsx index afcaa26c..4175b49d 100644 --- a/packages/cli/src/ui/views/App.tsx +++ b/packages/cli/src/ui/views/App.tsx @@ -519,6 +519,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp refreshSessionsList(); navigateToSubView("session-list"); } else { + // Session ID already validated in cli.tsx — guaranteed to exist handleSelectSession(resumeSessionId); } }, [handleSelectSession, navigateToSubView, refreshSessionsList, resumeSessionId]); From a3952e11115236dbd0bd31dee89f954a7c1a3b08 Mon Sep 17 00:00:00 2001 From: hcyang Date: Wed, 24 Jun 2026 09:24:54 +0800 Subject: [PATCH 4/9] =?UTF-8?q?refactor(cli):=20=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E6=9C=AA=E4=BD=BF=E7=94=A8=E7=9A=84=20existsSync=20=E5=AF=BC?= =?UTF-8?q?=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 node:fs 模块中未使用的 existsSync 导入 - 保持代码整洁,避免冗余依赖 - 优化代码可读性和维护性 --- packages/cli/src/cli.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index 80513f78..6ac5372d 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -1,6 +1,6 @@ import React from "react"; import { render } from "ink"; -import { readFileSync, existsSync } from "node:fs"; +import { readFileSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; import { setShellIfWindows, getProjectCode } from "@vegamo/deepcode-core"; From f4ded9a866c157169ad6fc16b70b700283c46ac6 Mon Sep 17 00:00:00 2001 From: hcyang Date: Wed, 24 Jun 2026 10:04:33 +0800 Subject: [PATCH 5/9] =?UTF-8?q?feat(cli):=20=E5=A2=9E=E5=8A=A0Git=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=E4=BF=A1=E6=81=AF=E5=92=8C=E6=94=B9=E8=BF=9B=E7=95=8C?= =?UTF-8?q?=E9=9D=A2=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 引入并展示CLI版本及Git提交信息 - 优化退出摘要界面颜色,提升可读性 - 在会话恢复提示中添加高亮命令显示 - 在PackageInfo类型中添加gitCommit字段支持 --- packages/cli/src/cli.tsx | 6 ++++-- packages/cli/src/common/update-check.ts | 1 + packages/cli/src/ui/exit-summary.ts | 7 ++++--- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index 6ac5372d..48152d0b 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -7,6 +7,7 @@ import { setShellIfWindows, getProjectCode } from "@vegamo/deepcode-core"; import { checkForNpmUpdate, promptForPendingUpdate, type PackageInfo } from "./common/update-check"; import { AppContainer } from "./ui"; import { extractInitialPrompt, extractResumeSessionId } from "./cli-args"; +import { CLI_VERSION, GIT_COMMIT_INFO } from "./generated/git-commit"; const args = process.argv.slice(2); const packageInfo = readPackageInfo(); @@ -151,9 +152,10 @@ function readPackageInfo(): PackageInfo { const pkg = require("../package.json") as { name?: unknown; version?: unknown }; return { name: typeof pkg.name === "string" ? pkg.name : "@vegamo/deepcode-cli", - version: typeof pkg.version === "string" ? pkg.version : "", + version: typeof pkg.version === "string" ? pkg.version : (CLI_VERSION ?? ""), + gitCommit: GIT_COMMIT_INFO ?? "", }; } catch { - return { name: "@vegamo/deepcode-cli", version: "" }; + return { name: "@vegamo/deepcode-cli", version: CLI_VERSION ?? "", gitCommit: GIT_COMMIT_INFO ?? "" }; } } diff --git a/packages/cli/src/common/update-check.ts b/packages/cli/src/common/update-check.ts index 7a4710be..3b82e51a 100644 --- a/packages/cli/src/common/update-check.ts +++ b/packages/cli/src/common/update-check.ts @@ -10,6 +10,7 @@ import { killProcessTree } from "@vegamo/deepcode-core"; export type PackageInfo = { name: string; version: string; + gitCommit?: string; }; type UpdateState = { diff --git a/packages/cli/src/ui/exit-summary.ts b/packages/cli/src/ui/exit-summary.ts index baef723a..1a28ab8f 100644 --- a/packages/cli/src/ui/exit-summary.ts +++ b/packages/cli/src/ui/exit-summary.ts @@ -73,7 +73,7 @@ export function buildExitSummaryText(input: ExitSummaryInput): string { const innerWidth = 98; const contentWidth = innerWidth - 4; // "│ " prefix + " │" suffix → 4 chars padding - const borderColor = chalk.hex("#229ac3e6"); + const borderColor = chalk.dim; const titleColor = gradientString("#229ac3e6", "rgb(125 51 247 / 0.7)"); const line = (text: string) => `${borderColor("│")} ${padRight(text, contentWidth)} ${borderColor("│")}`; @@ -114,7 +114,7 @@ export function buildExitSummaryText(input: ExitSummaryInput): string { padLeft("Output Tokens", colOutput) + padLeft("Cached Tokens", colCached); rows.push(chalk.bold(headerRow)); - rows.push(divider); + rows.push(chalk.gray(divider)); for (const { modelName, usage } of usageRows) { const reqsStr = formatNumber(usage.totalReqs).padStart(colReqs); @@ -136,7 +136,8 @@ export function buildExitSummaryText(input: ExitSummaryInput): string { rows.push(""); if (sessionId) { - const resumeHint = chalk.dim(`To continue this session, run deepcode --resume ${sessionId}`); + const resumeHint = + chalk.dim(`To continue this session, run `) + chalk.hex("#229ac3")(`deepcode --resume ${sessionId}`); rows.push(resumeHint); rows.push(""); } From 346ecee0c988eb6e62d7e4aadf7261d03453a4f5 Mon Sep 17 00:00:00 2001 From: hcyang Date: Thu, 25 Jun 2026 09:00:13 +0800 Subject: [PATCH 6/9] =?UTF-8?q?refactor(cli):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E8=A1=8C=E5=8F=82=E6=95=B0=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E5=B9=B6=E4=BC=98=E5=8C=96=E5=90=AF=E5=8A=A8=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 使用 yargs 库替代原有手写解析,增强参数解析的健壮性和可维护性 - 添加严格参数校验,提升错误提示的清晰度 - 统一处理 --resume 和 --prompt 参数的组合逻辑,避免冲突使用 - 改进启动流程,确保先恢复会话再提交初始提示 - 将 resetStaticView 方法修改为异步以支持启动流程等待 - 替换部分异步调用为 await 确保顺序执行,避免竞态问题 - 更新 CLI 帮助文案,优化用户体验和信息表达 - 调整欢迎界面文本样式,增加标题加粗显示 - 添加相关单元测试 covering 新的参数解析和校验逻辑 - 引入 yargs 及其类型依赖,更新 package.json 和锁文件依赖清单 --- package-lock.json | 146 +++++++++++++++- packages/cli/package.json | 6 +- packages/cli/src/cli-args.ts | 111 ++++++++++--- packages/cli/src/cli.tsx | 124 +++++++------- packages/cli/src/tests/cli-args.test.ts | 174 +++++++++++++++----- packages/cli/src/tests/exit-summary.test.ts | 2 +- packages/cli/src/ui/views/App.tsx | 72 ++++---- packages/cli/src/ui/views/WelcomeScreen.tsx | 4 +- 8 files changed, 487 insertions(+), 152 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0e4b47f0..954b83b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1647,6 +1647,23 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.61.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.1.tgz", @@ -2724,6 +2741,66 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/cockatiel": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/cockatiel/-/cockatiel-3.2.1.tgz", @@ -3109,7 +3186,6 @@ "version": "10.6.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, "license": "MIT" }, "node_modules/encoding-sniffer": { @@ -3266,7 +3342,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -3753,6 +3828,15 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-east-asian-width": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", @@ -7385,6 +7469,15 @@ "node": ">=4.0" } }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -7409,6 +7502,49 @@ "url": "https://github.com/sponsors/eemeli" } }, + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/yauzl": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz", @@ -7484,11 +7620,15 @@ "ignore": "^7.0.5", "ink": "^7.0.4", "ink-gradient": "^4.0.1", - "react": "^19.2.5" + "react": "^19.2.5", + "yargs": "^18.0.0" }, "bin": { "deepcode": "dist/cli.js" }, + "devDependencies": { + "@types/yargs": "^17.0.35" + }, "engines": { "node": ">=22" } diff --git a/packages/cli/package.json b/packages/cli/package.json index 654038ee..977bad59 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -37,6 +37,10 @@ "ignore": "^7.0.5", "ink": "^7.0.4", "ink-gradient": "^4.0.1", - "react": "^19.2.5" + "react": "^19.2.5", + "yargs": "^18.0.0" + }, + "devDependencies": { + "@types/yargs": "^17.0.35" } } diff --git a/packages/cli/src/cli-args.ts b/packages/cli/src/cli-args.ts index 5ae0325e..3851ac00 100644 --- a/packages/cli/src/cli-args.ts +++ b/packages/cli/src/cli-args.ts @@ -1,31 +1,102 @@ /** * CLI argument parsing helpers. - * Extracted from cli.tsx for testability. + * Uses yargs for robust argument parsing and validation. */ -export function extractInitialPrompt(args: string[]): string | undefined { - const promptIndex = args.findIndex((arg) => arg === "-p" || arg === "--prompt"); - if (promptIndex !== -1 && promptIndex + 1 < args.length) { - return args[promptIndex + 1]; - } - return undefined; +import Yargs from "yargs"; + +export interface ParsedCliArgs { + /** Prompt text from -p / --prompt */ + prompt: string | undefined; + /** + * Resume session identifier: + * - `undefined` — --resume was not used + * - `true` — --resume was used without a session ID (show picker) + * - `string` — --resume was used + */ + resume: string | true | undefined; + /** True when --version / -v was passed */ + version: boolean; + /** True when --help / -h was passed */ + help: boolean; +} + +export interface CliParseError { + message: string; } /** - * Extract the --resume flag value. - * - * Returns: - * - `undefined` — `--resume` was not used - * - `true` — `--resume` was used without a session ID (show session picker) - * - `string` — `--resume ` was used (resume specific session) + * Parse CLI arguments with validation. + * Returns parsed args on success, or an error object if the arguments are invalid. */ -export function extractResumeSessionId(args: string[]): string | true | undefined { - const idx = args.findIndex((arg) => arg === "--resume"); - if (idx === -1) { - return undefined; +export function parseCliArgs(argv: string[]): ParsedCliArgs | CliParseError { + let validationError: string | null = null; + + const y = Yargs(argv) + .locale("en") + .scriptName("deepcode") + .version(false) + .help(false) + .option("version", { + alias: "v", + type: "boolean", + describe: "Print the version", + }) + .option("help", { + alias: "h", + type: "boolean", + describe: "Show this help", + }) + .option("resume", { + alias: "r", + type: "string", + describe: "Resume a specific session by its ID. Use without an ID to show session picker.", + }) + .option("prompt", { + alias: "p", + type: "string", + describe: "Submit a prompt on launch", + }) + .strict() + .exitProcess(false) + .fail((msg) => { + validationError = msg; + }) + .check((parsed) => { + // bare --resume conflicts with --prompt + if (parsed.resume === "" && parsed.prompt) { + throw new Error( + "Cannot use --resume without a session ID together with --prompt.\n" + + "Use --resume -p to resume a session and send a prompt." + ); + } + // empty prompt is meaningless + if (parsed.prompt === "") { + throw new Error("--prompt / -p requires a non-empty value."); + } + return true; + }); + + const parsed = y.parseSync() as Record; + + if (validationError) { + return { message: validationError }; } - if (idx + 1 < args.length && !args[idx + 1].startsWith("-")) { - return args[idx + 1]; + + const resumeRaw = parsed.resume as string | undefined; + let resume: ParsedCliArgs["resume"]; + if (resumeRaw === undefined) { + resume = undefined; + } else if (resumeRaw === "") { + resume = true; + } else { + resume = resumeRaw; } - return true; + + return { + prompt: parsed.prompt as string | undefined, + resume, + version: parsed.version === true, + help: parsed.help === true, + }; } diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index 48152d0b..1ea702bc 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -6,69 +6,86 @@ import { homedir } from "node:os"; import { setShellIfWindows, getProjectCode } from "@vegamo/deepcode-core"; import { checkForNpmUpdate, promptForPendingUpdate, type PackageInfo } from "./common/update-check"; import { AppContainer } from "./ui"; -import { extractInitialPrompt, extractResumeSessionId } from "./cli-args"; +import { hideBin } from "yargs/helpers"; +import { parseCliArgs } from "./cli-args"; import { CLI_VERSION, GIT_COMMIT_INFO } from "./generated/git-commit"; -const args = process.argv.slice(2); +const args = hideBin(process.argv); const packageInfo = readPackageInfo(); -if (args.includes("--version") || args.includes("-v")) { +const HELP_TEXT = + [ + "", + "Usage: deepcode [options] [command]\n\nDeep Code - Launch an interactive CLI, use -p/--prompt for non-interactive mode", + "", + "Commands:", + " deepcode Launch the interactive TUI in the current directory", + "", + "Options:", + " -p, --prompt Launch with a pre-filled prompt", + " -r, --resume [sessionId] Resume a specific session by its ID. Use without an ID to show session picker", + " -v, --version Show version number", + " -h, --help Show help", + "", + "Configuration:", + " ~/.deepcode/settings.json User-level API key, model, base URL", + " ./.deepcode/settings.json Project-level settings", + " ./.deepcode/skills/*/SKILL.md Project-level native skills", + " ./.agents/skills/*/SKILL.md Project-level interoperable skills", + " ~/.deepcode/skills/*/SKILL.md User-level native skills", + " ~/.agents/skills/*/SKILL.md User-level interoperable skills", + "", + "Inside the TUI:", + " enter Send the prompt", + " shift+enter Insert a newline", + " home/end Move within the current line", + " alt+left/right Move by word", + " ctrl+w Delete the previous word", + " ctrl+v Paste an image from the clipboard", + " ctrl+x Clear pasted images", + " esc Interrupt the current model turn", + " / Open the skills/commands menu", + " /skills List available skills", + " /model Select model, thinking mode and effort control", + " /new Start a fresh conversation", + " /init Initialize an AGENTS.md file with instructions for LLM", + " /resume Pick a previous conversation to continue", + " /continue Continue the active conversation, or resume one if empty", + " /undo Restore code and/or conversation to a previous point", + " /mcp Show MCP server status and available tools", + " /raw Toggle display mode for viewing or collapsing reasoning content", + " /exit Quit", + " ctrl+d twice Quit", + ].join("\n") + "\n"; + +const parsed = parseCliArgs(args); + +if ("message" in parsed) { + process.stderr.write(parsed.message + "\n\n"); + process.stdout.write(HELP_TEXT); + process.exit(1); +} + +if (parsed.version) { process.stdout.write(`${packageInfo.version || "unknown"}\n`); process.exit(0); } -if (args.includes("--help") || args.includes("-h")) { - process.stdout.write( - [ - "deepcode - Deep Code CLI", - "", - "Usage:", - " deepcode Launch the interactive TUI in the current directory", - " deepcode -p Launch with a pre-filled prompt", - " deepcode --prompt Same as -p", - " deepcode --resume [sessionId] Resume a specific session by its ID. Use without an ID to show session picker", - " deepcode --version Print the version", - " deepcode --help Show this help", - "", - "Configuration:", - " ~/.deepcode/settings.json User-level API key, model, base URL", - " ./.deepcode/settings.json Project-level settings", - " ./.deepcode/skills/*/SKILL.md Project-level native skills", - " ./.agents/skills/*/SKILL.md Project-level interoperable skills", - " ~/.deepcode/skills/*/SKILL.md User-level native skills", - " ~/.agents/skills/*/SKILL.md User-level interoperable skills", - "", - "Inside the TUI:", - " enter Send the prompt", - " shift+enter Insert a newline", - " home/end Move within the current line", - " alt+left/right Move by word", - " ctrl+w Delete the previous word", - " ctrl+v Paste an image from the clipboard", - " ctrl+x Clear pasted images", - " esc Interrupt the current model turn", - " / Open the skills/commands menu", - " /skills List available skills", - " /model Select model, thinking mode and effort control", - " /new Start a fresh conversation", - " /init Initialize an AGENTS.md file with instructions for LLM", - " /resume Pick a previous conversation to continue", - " /continue Continue the active conversation, or resume one if empty", - " /undo Restore code and/or conversation to a previous point", - " /mcp Show MCP server status and available tools", - " /raw Toggle display mode for viewing or collapsing reasoning content", - " /exit Quit", - " ctrl+d twice Quit", - ].join("\n") + "\n" - ); +if (parsed.help) { + process.stdout.write(HELP_TEXT); process.exit(0); } -let initialPrompt = extractInitialPrompt(args); -const resumeSessionId = extractResumeSessionId(args); +let initialPrompt = parsed.prompt; +let resumeSessionId = parsed.resume; const projectRoot = process.cwd(); configureWindowsShell(); +if (!process.stdin.isTTY) { + process.stderr.write("deepcode requires an interactive terminal (TTY). " + "Re-run from a real terminal session.\n"); + process.exit(1); +} + // Validate --resume before entering TUI if (typeof resumeSessionId === "string") { const projectCode = getProjectCode(projectRoot); @@ -86,11 +103,6 @@ if (typeof resumeSessionId === "string") { } } -if (!process.stdin.isTTY) { - process.stderr.write("deepcode requires an interactive terminal (TTY). " + "Re-run from a real terminal session.\n"); - process.exit(1); -} - void main(); async function main(): Promise { @@ -105,12 +117,14 @@ async function main(): Promise { let restarting = false; const appInitialPrompt = initialPrompt; initialPrompt = undefined; + const appResumeSessionId = resumeSessionId; + resumeSessionId = undefined; const inkInstance = render( restartRef.current?.()} />, { exitOnCtrlC: false } diff --git a/packages/cli/src/tests/cli-args.test.ts b/packages/cli/src/tests/cli-args.test.ts index e42a4b00..fd97de76 100644 --- a/packages/cli/src/tests/cli-args.test.ts +++ b/packages/cli/src/tests/cli-args.test.ts @@ -1,76 +1,168 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { extractInitialPrompt, extractResumeSessionId } from "../cli-args"; +import { parseCliArgs } from "../cli-args"; -// ── extractInitialPrompt ───────────────────────────────────────────────────── +// ── parseCliArgs: basic parsing ────────────────────────────────────────────── -test("extractInitialPrompt returns prompt after -p", () => { - assert.equal(extractInitialPrompt(["-p", "hello world"]), "hello world"); +test("parseCliArgs returns prompt after -p", () => { + const r = parseCliArgs(["-p", "hello world"]); + assert.ok(!("message" in r)); + assert.equal(r.prompt, "hello world"); }); -test("extractInitialPrompt returns prompt after --prompt", () => { - assert.equal(extractInitialPrompt(["--prompt", "hello world"]), "hello world"); +test("parseCliArgs returns prompt after --prompt", () => { + const r = parseCliArgs(["--prompt", "hello world"]); + assert.ok(!("message" in r)); + assert.equal(r.prompt, "hello world"); }); -test("extractInitialPrompt returns undefined when -p is not present", () => { - assert.equal(extractInitialPrompt(["--version"]), undefined); +test("parseCliArgs returns undefined prompt when -p is not present", () => { + const r = parseCliArgs(["--resume"]); + assert.ok(!("message" in r)); + assert.equal(r.prompt, undefined); }); -test("extractInitialPrompt returns undefined when -p has no value", () => { - assert.equal(extractInitialPrompt(["-p"]), undefined); +test("parseCliArgs returns session ID after --resume", () => { + const r = parseCliArgs(["--resume", "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"]); + assert.ok(!("message" in r)); + assert.equal(r.resume, "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"); }); -test("extractInitialPrompt returns undefined for empty args", () => { - assert.equal(extractInitialPrompt([]), undefined); +test("parseCliArgs returns true when --resume has no value", () => { + const r = parseCliArgs(["--resume"]); + assert.ok(!("message" in r)); + assert.equal(r.resume, true); }); -test("extractInitialPrompt ignores -p in non-flag position", () => { - assert.equal(extractInitialPrompt(["--resume", "-p", "hello"]), "hello"); +test("parseCliArgs returns undefined resume when not present", () => { + const r = parseCliArgs(["-p", "test"]); + assert.ok(!("message" in r)); + assert.equal(r.resume, undefined); }); -// ── extractResumeSessionId ─────────────────────────────────────────────────── +test("parseCliArgs returns defaults for empty args", () => { + const r = parseCliArgs([]); + assert.ok(!("message" in r)); + assert.equal(r.prompt, undefined); + assert.equal(r.resume, undefined); + assert.equal(r.version, false); + assert.equal(r.help, false); +}); + +// ── parseCliArgs: -r alias ─────────────────────────────────────────────────── + +test("parseCliArgs returns session ID after -r", () => { + const r = parseCliArgs(["-r", "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"]); + assert.ok(!("message" in r)); + assert.equal(r.resume, "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"); +}); + +test("parseCliArgs returns true when -r has no value", () => { + const r = parseCliArgs(["-r"]); + assert.ok(!("message" in r)); + assert.equal(r.resume, true); +}); + +test("parseCliArgs handles -r combined with -p", () => { + const r = parseCliArgs(["-r", "session-123", "-p", "hello"]); + assert.ok(!("message" in r)); + assert.equal(r.resume, "session-123"); + assert.equal(r.prompt, "hello"); +}); + +test("parseCliArgs rejects bare -r with -p", () => { + const r = parseCliArgs(["-r", "-p", "hello"]); + assert.ok("message" in r); + assert.match(r.message, /Cannot use --resume/); +}); -test("extractResumeSessionId returns session ID after --resume", () => { - assert.equal( - extractResumeSessionId(["--resume", "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"]), - "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6" - ); +// ── parseCliArgs: --version / --help ───────────────────────────────────────── + +test("parseCliArgs detects --version", () => { + const r = parseCliArgs(["--version"]); + assert.ok(!("message" in r)); + assert.equal(r.version, true); + assert.equal(r.help, false); +}); + +test("parseCliArgs detects -v", () => { + const r = parseCliArgs(["-v"]); + assert.ok(!("message" in r)); + assert.equal(r.version, true); +}); + +test("parseCliArgs detects --help", () => { + const r = parseCliArgs(["--help"]); + assert.ok(!("message" in r)); + assert.equal(r.help, true); + assert.equal(r.version, false); }); -test("extractResumeSessionId returns true when --resume has no value (show picker)", () => { - assert.equal(extractResumeSessionId(["--resume"]), true); +test("parseCliArgs detects -h", () => { + const r = parseCliArgs(["-h"]); + assert.ok(!("message" in r)); + assert.equal(r.help, true); }); -test("extractResumeSessionId returns true when --resume is followed by another flag", () => { - assert.equal(extractResumeSessionId(["--resume", "--force"]), true); +test("parseCliArgs version and help are false when not passed", () => { + const r = parseCliArgs(["-p", "hello"]); + assert.ok(!("message" in r)); + assert.equal(r.version, false); + assert.equal(r.help, false); }); -test("extractResumeSessionId returns undefined when --resume is not present", () => { - assert.equal(extractResumeSessionId(["--version"]), undefined); +test("parseCliArgs handles -v combined with -r (both flags set)", () => { + const r = parseCliArgs(["-v", "-r", "abc"]); + assert.ok(!("message" in r)); + assert.equal(r.version, true); + assert.equal(r.resume, "abc"); }); -test("extractResumeSessionId returns undefined for empty args", () => { - assert.equal(extractResumeSessionId([]), undefined); +// ── parseCliArgs: combined usage ───────────────────────────────────────────── + +test("parseCliArgs handles --resume combined with -p", () => { + const r = parseCliArgs(["--resume", "session-123", "-p", "hello"]); + assert.ok(!("message" in r)); + assert.equal(r.resume, "session-123"); + assert.equal(r.prompt, "hello"); }); -test("extractResumeSessionId works with other flags after sessionId", () => { - assert.equal(extractResumeSessionId(["--resume", "abc-123", "--force"]), "abc-123"); +test("parseCliArgs handles -p before --resume ", () => { + const r = parseCliArgs(["-p", "hello", "--resume", "session-123"]); + assert.ok(!("message" in r)); + assert.equal(r.resume, "session-123"); + assert.equal(r.prompt, "hello"); }); -test("extractResumeSessionId does not confuse --resume with other args", () => { - assert.equal(extractResumeSessionId(["-p", "test"]), undefined); +// ── parseCliArgs: validation ───────────────────────────────────────────────── + +test("parseCliArgs rejects bare --resume with -p", () => { + const r = parseCliArgs(["--resume", "-p", "hello"]); + assert.ok("message" in r); + assert.match(r.message, /Cannot use --resume/); }); -// ── combined usage ─────────────────────────────────────────────────────────── +test("parseCliArgs rejects -p with bare --resume (reversed order)", () => { + const r = parseCliArgs(["-p", "hello", "--resume"]); + assert.ok("message" in r); + assert.match(r.message, /Cannot use --resume/); +}); + +test("parseCliArgs rejects unknown flags in strict mode", () => { + const r = parseCliArgs(["--unknown-flag"]); + assert.ok("message" in r); + assert.match(r.message, /Unknown argument/); +}); -test("extractInitialPrompt and extractResumeSessionId work independently", () => { - const args = ["--resume", "session-123", "-p", "hello"]; - assert.equal(extractResumeSessionId(args), "session-123"); - assert.equal(extractInitialPrompt(args), "hello"); +test("parseCliArgs rejects empty -p value", () => { + const r = parseCliArgs(["-p", ""]); + assert.ok("message" in r); + assert.match(r.message, /non-empty/); }); -test("extractResumeSessionId with --resume and -p but no sessionId", () => { - const args = ["--resume", "-p", "hello"]; - assert.equal(extractResumeSessionId(args), true); - assert.equal(extractInitialPrompt(args), "hello"); +test("parseCliArgs --version takes precedence over --help", () => { + const r = parseCliArgs(["--version", "--help"]); + assert.ok(!("message" in r)); + assert.equal(r.version, true); + assert.equal(r.help, true); }); diff --git a/packages/cli/src/tests/exit-summary.test.ts b/packages/cli/src/tests/exit-summary.test.ts index d768c165..fd6b8ad0 100644 --- a/packages/cli/src/tests/exit-summary.test.ts +++ b/packages/cli/src/tests/exit-summary.test.ts @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { buildExitSummaryText } from "../ui"; import type { ModelUsage, SessionEntry } from "@vegamo/deepcode-core"; -const stripAnsi = (text: string): string => text.replace(/\u001b\[[0-9;]*m/g, ""); +const stripAnsi = (text: string): string => text.replace(/\u001b\[[0-9;]*[a-zA-Z]/g, ""); test("buildExitSummaryText only shows Goodbye and model usage with cached tokens", () => { const summary = stripAnsi( diff --git a/packages/cli/src/ui/views/App.tsx b/packages/cli/src/ui/views/App.tsx index 4175b49d..5e345802 100644 --- a/packages/cli/src/ui/views/App.tsx +++ b/packages/cli/src/ui/views/App.tsx @@ -97,6 +97,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp const { mode, setMode } = useRawModeContext(); const initialPromptSubmittedRef = useRef(false); const resumeSessionIdRef = useRef(false); + const startupDoneRef = useRef(false); const processStdoutRef = useRef>(new Map()); const rawModeRef = useRef(mode); const writeRef = useRef(write); @@ -190,17 +191,20 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp * Reset the static view to the welcome screen. */ const resetStaticView = useCallback( - (loadedMessages: SessionMessage[], options?: { clearScreen?: boolean }) => { + (loadedMessages: SessionMessage[], options?: { clearScreen?: boolean }): Promise => { if (options?.clearScreen) { process.stdout.write(ANSI_CLEAR_SCREEN); } setMessages([]); setWelcomeNonce((n) => n + 1); navigateToSubView("chat"); - setTimeout(() => { - setMessages(loadedMessages); - setShowWelcome(true); - }, 0); + return new Promise((resolve) => { + setTimeout(() => { + setMessages(loadedMessages); + setShowWelcome(true); + resolve(); + }, 0); + }); }, [navigateToSubView] ); @@ -246,7 +250,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp setActiveAskPermissions(undefined); setPendingPermissionReply(null); setDismissedQuestionIds(new Set()); - resetStaticView([]); + await resetStaticView([]); await refreshSkills(); }, [sessionManager, resetStaticView, refreshSkills]); @@ -477,24 +481,11 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp [resetStaticView, sessionManager] ); - useEffect(() => { - if (initialPromptSubmittedRef.current || !initialPrompt || !initialPrompt.trim()) { - return; - } - - initialPromptSubmittedRef.current = true; - handleSubmit({ - text: initialPrompt, - imageUrls: [], - selectedSkills: undefined, - }); - }, [handleSubmit, initialPrompt]); - const handleSelectSession = useCallback( async (sessionId: string) => { sessionManager.setActiveSessionId(sessionId); // Clear first so resets its index to 0. - resetStaticView(loadVisibleMessages(sessionManager, sessionId), { clearScreen: true }); + await resetStaticView(loadVisibleMessages(sessionManager, sessionId), { clearScreen: true }); const session = sessionManager.getSession(sessionId); setStatusLine(session ? buildStatusLine(session) : ""); setRunningProcesses(session?.processes ?? null); @@ -508,21 +499,42 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp [sessionManager, resetStaticView, pendingPermissionReply, refreshSkills] ); + /** + * Coordinated startup effect: handle --resume and --prompt together. + * When both are present, resume the session first, then submit the prompt. + */ useEffect(() => { - if (resumeSessionIdRef.current || !resumeSessionId) { + if (startupDoneRef.current) { return; } + startupDoneRef.current = true; + + async function run() { + // Step 1: Resume session if requested + if (resumeSessionId) { + resumeSessionIdRef.current = true; + if (resumeSessionId === true) { + // Bare --resume — show session picker; prompt makes no sense here + refreshSessionsList(); + navigateToSubView("session-list"); + return; + } + await handleSelectSession(resumeSessionId); + } - resumeSessionIdRef.current = true; - if (resumeSessionId === true) { - // No session ID — show the session picker (same as /resume) - refreshSessionsList(); - navigateToSubView("session-list"); - } else { - // Session ID already validated in cli.tsx — guaranteed to exist - handleSelectSession(resumeSessionId); + // Step 2: Submit prompt if provided + if (initialPrompt && initialPrompt.trim()) { + initialPromptSubmittedRef.current = true; + handleSubmit({ + text: initialPrompt, + imageUrls: [], + selectedSkills: undefined, + }); + } } - }, [handleSelectSession, navigateToSubView, refreshSessionsList, resumeSessionId]); + + void run(); + }, [handleSubmit, handleSelectSession, initialPrompt, navigateToSubView, refreshSessionsList, resumeSessionId]); const handleDeleteSession = useCallback( async (id: string): Promise => { diff --git a/packages/cli/src/ui/views/WelcomeScreen.tsx b/packages/cli/src/ui/views/WelcomeScreen.tsx index fdcf9211..e465a2f6 100644 --- a/packages/cli/src/ui/views/WelcomeScreen.tsx +++ b/packages/cli/src/ui/views/WelcomeScreen.tsx @@ -58,7 +58,9 @@ export function WelcomeScreen({ projectRoot, settings, skills, width }: WelcomeS paddingX={1} > - {">"}_ Deep Code + + {">"}_ Deep Code{" "} + (v{version || "unknown"}) {!compact ? : null} From 24a2ad934e2d59caf74a5a0515432603d618dc04 Mon Sep 17 00:00:00 2001 From: hcyang Date: Thu, 25 Jun 2026 14:37:41 +0800 Subject: [PATCH 7/9] =?UTF-8?q?refactor(cli):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E8=A1=8C=E5=8F=82=E6=95=B0=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E5=92=8C=E4=B8=BB=E5=85=A5=E5=8F=A3=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 使用异步方式获取包信息,替代同步读取package.json - 用yargs重构参数解析,加入严格校验和格式验证 - 新增UUID格式验证函数,确保会话ID合法性 - 改善错误输出,统一通过writeStderrLine打印错误信息 - 移除过时的手工参数解析逻辑,改用parseArguments异步解析 - 统一并简化应用启动流程,支持终端交互性检查 - 替换process.stdout.write为writeStdoutLine,增强代码一致性 - 增加对版本号、帮助信息参数的自动处理和退出 - 使用read-package-up获取package.json,保证包信息准确 - 测试覆盖parseArguments及isValidSessionId的多种场景和错误处理 - 修改构建脚本为异步导入fs模块的chmodSync操作,兼容现代Node版本 --- package-lock.json | 121 ++++++++++++-- packages/cli/package.json | 3 +- packages/cli/src/cli-args.ts | 166 +++++++++++++------- packages/cli/src/cli.tsx | 146 +++++------------ packages/cli/src/common/update-check.ts | 9 +- packages/cli/src/tests/cli-args.test.ts | 199 ++++++++++++++---------- packages/cli/src/utils/package.ts | 29 ++++ packages/cli/src/utils/stdioHelpers.ts | 25 +++ packages/cli/src/utils/version.ts | 6 + 9 files changed, 447 insertions(+), 257 deletions(-) create mode 100644 packages/cli/src/utils/package.ts create mode 100644 packages/cli/src/utils/stdioHelpers.ts create mode 100644 packages/cli/src/utils/version.ts diff --git a/package-lock.json b/package-lock.json index 954b83b3..66cf2bce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -242,7 +242,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", @@ -384,7 +383,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1614,7 +1612,6 @@ "version": "2.4.4", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", - "dev": true, "license": "MIT" }, "node_modules/@types/react": { @@ -3732,6 +3729,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/find-up-simple": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.1.tgz", + "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", @@ -4359,7 +4368,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -4630,7 +4638,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -5690,7 +5697,6 @@ "version": "8.3.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.26.2", @@ -5708,7 +5714,6 @@ "version": "4.41.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=16" @@ -5870,7 +5875,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -6130,6 +6134,101 @@ "node": ">=0.8" } }, + "node_modules/read-package-up": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-12.0.0.tgz", + "integrity": "sha512-Q5hMVBYur/eQNWDdbF4/Wqqr9Bjvtrw2kjGxxBbKLbx8bVCL8gcArjTy8zDUuLGQicftpMuU0riQNcAsbtOVsw==", + "license": "MIT", + "dependencies": { + "find-up-simple": "^1.0.1", + "read-pkg": "^10.0.0", + "type-fest": "^5.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-package-up/node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/read-package-up/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/read-package-up/node_modules/normalize-package-data": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-8.0.0.tgz", + "integrity": "sha512-RWk+PI433eESQ7ounYxIp67CYuVsS1uYSonX3kA6ps/3LWfjVQa/ptEg6Y3T6uAMq1mWpX9PQ+qx+QaHpsc7gQ==", + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^9.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/read-package-up/node_modules/read-pkg": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-10.1.0.tgz", + "integrity": "sha512-I8g2lArQiP78ll51UeMZojewtYgIRCKCWqZEgOO8c/uefTI+XDXvCSXu3+YNUaTNvZzobrL5+SqHjBrByRRTdg==", + "license": "MIT", + "dependencies": { + "@types/normalize-package-data": "^2.4.4", + "normalize-package-data": "^8.0.0", + "parse-json": "^8.3.0", + "type-fest": "^5.4.4", + "unicorn-magic": "^0.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-package-up/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/read-package-up/node_modules/unicorn-magic": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.0.tgz", + "integrity": "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/read-pkg": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-9.0.1.tgz", @@ -6584,7 +6683,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "spdx-expression-parse": "^3.0.0", @@ -6595,14 +6693,12 @@ "version": "2.5.0", "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", - "dev": true, "license": "CC-BY-3.0" }, "node_modules/spdx-expression-parse": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", - "dev": true, "license": "MIT", "dependencies": { "spdx-exceptions": "^2.1.0", @@ -6613,7 +6709,6 @@ "version": "3.0.23", "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", - "dev": true, "license": "CC0-1.0" }, "node_modules/sprintf-js": { @@ -7286,7 +7381,6 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", - "dev": true, "license": "Apache-2.0", "dependencies": { "spdx-correct": "^3.0.0", @@ -7621,6 +7715,7 @@ "ink": "^7.0.4", "ink-gradient": "^4.0.1", "react": "^19.2.5", + "read-package-up": "^12.0.0", "yargs": "^18.0.0" }, "bin": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 977bad59..1f657304 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -25,7 +25,7 @@ "scripts": { "typecheck": "tsc -p ./ --noEmit", "bundle": "node ../../scripts/esbuild.config.js", - "build": "npm run typecheck && npm run bundle && node ../../scripts/copy-bundle-assets.js && node -e \"require('fs').chmodSync('dist/cli.js', 0o755)\"", + "build": "npm run typecheck && npm run bundle && node ../../scripts/copy-bundle-assets.js && node -e \"import('node:fs').then(f => f.chmodSync('dist/cli.js', 0o755))\"", "prepublishOnly": "npm run build", "format": "prettier --write .", "test": "node src/tests/run-tests.mjs" @@ -38,6 +38,7 @@ "ink": "^7.0.4", "ink-gradient": "^4.0.1", "react": "^19.2.5", + "read-package-up": "^12.0.0", "yargs": "^18.0.0" }, "devDependencies": { diff --git a/packages/cli/src/cli-args.ts b/packages/cli/src/cli-args.ts index 3851ac00..780869c0 100644 --- a/packages/cli/src/cli-args.ts +++ b/packages/cli/src/cli-args.ts @@ -3,7 +3,21 @@ * Uses yargs for robust argument parsing and validation. */ +import type { Argv } from "yargs"; import Yargs from "yargs"; +import { getCliVersion } from "./utils/version"; +import { writeStderrLine } from "./utils/stdioHelpers"; +import { hideBin } from "yargs/helpers"; + +// UUID v4 regex pattern for validation +const SESSION_ID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +/** + * Validates if a string is a valid session ID format. + */ +export function isValidSessionId(value: string): boolean { + return SESSION_ID_REGEX.test(value); +} export interface ParsedCliArgs { /** Prompt text from -p / --prompt */ @@ -21,68 +35,112 @@ export interface ParsedCliArgs { help: boolean; } -export interface CliParseError { - message: string; +const EPILOG = [ + "Configuration:", + " ~/.deepcode/settings.json User-level API key, model, base URL", + " ./.deepcode/settings.json Project-level settings", + " ./.deepcode/skills/*/SKILL.md Project-level native skills", + " ./.agents/skills/*/SKILL.md Project-level interoperable skills", + " ~/.deepcode/skills/*/SKILL.md User-level native skills", + " ~/.agents/skills/*/SKILL.md User-level interoperable skills", + "", + "Inside the TUI:", + " enter Send the prompt", + " shift+enter Insert a newline", + " home/end Move within the current line", + " alt+left/right Move by word", + " ctrl+w Delete the previous word", + " ctrl+v Paste an image from the clipboard", + " ctrl+x Clear pasted images", + " esc Interrupt the current model turn", + " / Open the skills/commands menu", + " /skills List available skills", + " /model Select model, thinking mode and effort control", + " /new Start a fresh conversation", + " /init Initialize an AGENTS.md file with instructions for LLM", + " /resume Pick a previous conversation to continue", + " /continue Continue the active conversation, or resume one if empty", + " /undo Restore code and/or conversation to a previous point", + " /mcp Show MCP server status and available tools", + " /raw Toggle display mode for viewing or collapsing reasoning content", + " /exit Quit", + " ctrl+d twice Quit", +].join("\n"); + +async function configureYargs(argv?: string[]) { + const rawArgv = argv ?? hideBin(process.argv); + const yargsInstance = Yargs(rawArgv) + .locale("en") + .scriptName("deepcode") + .usage( + "Usage: $0 [options] [command]\n\nDeep Code - Launch an interactive CLI, use -p/--prompt for non-interactive mode" + ) + .command("$0 [query..]", "Launch Deep Code CLI", (yargsInstance: Argv) => + yargsInstance + .option("prompt", { + alias: "p", + type: "string", + describe: "Submit a prompt on launch", + }) + .option("resume", { + alias: "r", + type: "string", + describe: "Resume a specific session by its ID. Use without an ID to show session picker.", + }) + .check((argv: { [x: string]: unknown }) => { + const query = argv["query"] as string | string[] | undefined; + const hasPositionalQuery = Array.isArray(query) ? query.length > 0 : !!query; + + if (argv["prompt"] && hasPositionalQuery) { + return "Cannot use both a positional prompt and the --prompt (-p) flag together"; + } + // bare --resume conflicts with --prompt + if (argv["resume"] === "" && argv["prompt"]) { + return "Cannot use --resume without a session ID together with --prompt.\nUse --resume -p to resume a session and send a prompt."; + } + // validate --resume format if provided + if (argv["resume"] && argv["resume"] !== "" && !isValidSessionId(argv["resume"] as string)) { + return `Invalid session ID: "${argv["resume"]}". Must be a valid UUID (e.g., "123e4567-e89b-12d3-a456-426614174000").`; + } + // empty prompt is meaningless + if (argv["prompt"] === "") { + return "--prompt / -p requires a non-empty value."; + } + return true; + }) + ) + .example("deepcode", "Launch the interactive TUI in the current directory") + .example("deepcode -p ", "Launch with a pre-filled prompt") + .example("deepcode -r, --resume [sessionId]", "Resume a session or show session picker") + .epilog(EPILOG) + .strict() + .demandCommand(0, 0) + .wrap(Math.min(process.stdout.columns || 80, 120)); + yargsInstance + .version(await getCliVersion()) + .alias("v", "version") + .help() + .alias("h", "help"); + yargsInstance.wrap(yargsInstance.terminalWidth()); + return yargsInstance; } /** * Parse CLI arguments with validation. - * Returns parsed args on success, or an error object if the arguments are invalid. + * + * On validation failure the `.fail()` handler prints the error, shows help, + * and calls `process.exit(1)`, so this function always either returns a + * valid `ParsedCliArgs` or terminates the process. */ -export function parseCliArgs(argv: string[]): ParsedCliArgs | CliParseError { - let validationError: string | null = null; - - const y = Yargs(argv) - .locale("en") - .scriptName("deepcode") - .version(false) - .help(false) - .option("version", { - alias: "v", - type: "boolean", - describe: "Print the version", - }) - .option("help", { - alias: "h", - type: "boolean", - describe: "Show this help", - }) - .option("resume", { - alias: "r", - type: "string", - describe: "Resume a specific session by its ID. Use without an ID to show session picker.", - }) - .option("prompt", { - alias: "p", - type: "string", - describe: "Submit a prompt on launch", - }) - .strict() - .exitProcess(false) - .fail((msg) => { - validationError = msg; - }) - .check((parsed) => { - // bare --resume conflicts with --prompt - if (parsed.resume === "" && parsed.prompt) { - throw new Error( - "Cannot use --resume without a session ID together with --prompt.\n" + - "Use --resume -p to resume a session and send a prompt." - ); - } - // empty prompt is meaningless - if (parsed.prompt === "") { - throw new Error("--prompt / -p requires a non-empty value."); - } - return true; - }); +export async function parseArguments(argv?: string[]): Promise { + const y = (await configureYargs(argv)).exitProcess(false).fail((msg, _err, yargs) => { + writeStderrLine(msg || _err?.message || "Unknown error"); + yargs.showHelp(); + process.exit(1); + }); const parsed = y.parseSync() as Record; - if (validationError) { - return { message: validationError }; - } - const resumeRaw = parsed.resume as string | undefined; let resume: ParsedCliArgs["resume"]; if (resumeRaw === undefined) { diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index 1ea702bc..edf5fdbd 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -4,108 +4,53 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; import { setShellIfWindows, getProjectCode } from "@vegamo/deepcode-core"; -import { checkForNpmUpdate, promptForPendingUpdate, type PackageInfo } from "./common/update-check"; +import { checkForNpmUpdate, promptForPendingUpdate } from "./common/update-check"; import { AppContainer } from "./ui"; -import { hideBin } from "yargs/helpers"; -import { parseCliArgs } from "./cli-args"; -import { CLI_VERSION, GIT_COMMIT_INFO } from "./generated/git-commit"; +import { parseArguments } from "./cli-args"; +import { writeStderrLine, writeStdoutLine } from "./utils/stdioHelpers"; +import { getPackageJson } from "./utils/package"; +import { CLI_VERSION } from "./generated/git-commit"; -const args = hideBin(process.argv); -const packageInfo = readPackageInfo(); - -const HELP_TEXT = - [ - "", - "Usage: deepcode [options] [command]\n\nDeep Code - Launch an interactive CLI, use -p/--prompt for non-interactive mode", - "", - "Commands:", - " deepcode Launch the interactive TUI in the current directory", - "", - "Options:", - " -p, --prompt Launch with a pre-filled prompt", - " -r, --resume [sessionId] Resume a specific session by its ID. Use without an ID to show session picker", - " -v, --version Show version number", - " -h, --help Show help", - "", - "Configuration:", - " ~/.deepcode/settings.json User-level API key, model, base URL", - " ./.deepcode/settings.json Project-level settings", - " ./.deepcode/skills/*/SKILL.md Project-level native skills", - " ./.agents/skills/*/SKILL.md Project-level interoperable skills", - " ~/.deepcode/skills/*/SKILL.md User-level native skills", - " ~/.agents/skills/*/SKILL.md User-level interoperable skills", - "", - "Inside the TUI:", - " enter Send the prompt", - " shift+enter Insert a newline", - " home/end Move within the current line", - " alt+left/right Move by word", - " ctrl+w Delete the previous word", - " ctrl+v Paste an image from the clipboard", - " ctrl+x Clear pasted images", - " esc Interrupt the current model turn", - " / Open the skills/commands menu", - " /skills List available skills", - " /model Select model, thinking mode and effort control", - " /new Start a fresh conversation", - " /init Initialize an AGENTS.md file with instructions for LLM", - " /resume Pick a previous conversation to continue", - " /continue Continue the active conversation, or resume one if empty", - " /undo Restore code and/or conversation to a previous point", - " /mcp Show MCP server status and available tools", - " /raw Toggle display mode for viewing or collapsing reasoning content", - " /exit Quit", - " ctrl+d twice Quit", - ].join("\n") + "\n"; - -const parsed = parseCliArgs(args); - -if ("message" in parsed) { - process.stderr.write(parsed.message + "\n\n"); - process.stdout.write(HELP_TEXT); - process.exit(1); -} +configureWindowsShell(); +void main(); -if (parsed.version) { - process.stdout.write(`${packageInfo.version || "unknown"}\n`); - process.exit(0); -} +async function main(): Promise { + const packageInfo = await getPackageJson(); + const parsed = await parseArguments(); -if (parsed.help) { - process.stdout.write(HELP_TEXT); - process.exit(0); -} + // --version and --help are handled by yargs internally (prints output as side effect) + // but with .exitProcess(false) we need to exit manually. + if (parsed.version || parsed.help) { + process.exit(0); + } -let initialPrompt = parsed.prompt; -let resumeSessionId = parsed.resume; -const projectRoot = process.cwd(); -configureWindowsShell(); + let initialPrompt = parsed.prompt; + let resumeSessionId = parsed.resume; + const projectRoot = process.cwd(); -if (!process.stdin.isTTY) { - process.stderr.write("deepcode requires an interactive terminal (TTY). " + "Re-run from a real terminal session.\n"); - process.exit(1); -} + if (!process.stdin.isTTY) { + writeStderrLine("deepcode requires an interactive terminal (TTY). Re-run from a real terminal session.\n"); + process.exit(1); + } -// Validate --resume before entering TUI -if (typeof resumeSessionId === "string") { - const projectCode = getProjectCode(projectRoot); - const indexPath = join(homedir(), ".deepcode", "projects", projectCode, "sessions-index.json"); - try { - const index = JSON.parse(readFileSync(indexPath, "utf-8")); - const found = Array.isArray(index?.entries) && index.entries.some((e: { id: string }) => e.id === resumeSessionId); - if (!found) { - process.stderr.write(`No saved session found with ID "${resumeSessionId}".\n`); + // Validate --resume before entering TUI + if (typeof resumeSessionId === "string") { + const projectCode = getProjectCode(projectRoot); + const indexPath = join(homedir(), ".deepcode", "projects", projectCode, "sessions-index.json"); + try { + const index = JSON.parse(readFileSync(indexPath, "utf-8")); + const found = + Array.isArray(index?.entries) && index.entries.some((e: { id: string }) => e.id === resumeSessionId); + if (!found) { + writeStderrLine(`No saved session found with ID "${resumeSessionId}".\n`); + process.exit(1); + } + } catch { + writeStderrLine(`No saved session found with ID "${resumeSessionId}".\n`); process.exit(1); } - } catch { - process.stderr.write(`No saved session found with ID "${resumeSessionId}".\n`); - process.exit(1); } -} - -void main(); -async function main(): Promise { const updatePromptResult = await promptForPendingUpdate(packageInfo); if (updatePromptResult.installed) { process.exit(0); @@ -122,7 +67,7 @@ async function main(): Promise { const inkInstance = render( restartRef.current?.()} @@ -132,7 +77,7 @@ async function main(): Promise { restartRef.current = () => { restarting = true; - process.stdout.write("\u001B[2J\u001B[3J\u001B[H"); + writeStdoutLine("\u001B[2J\u001B[3J\u001B[H"); inkInstance.unmount(); startApp(); }; @@ -156,20 +101,7 @@ function configureWindowsShell(): void { setShellIfWindows(); } catch (error) { const message = error instanceof Error ? error.message : String(error); - process.stderr.write(`deepcode: ${message}\n`); + writeStderrLine(`deepcode: ${message}\n`); process.exit(1); } } - -function readPackageInfo(): PackageInfo { - try { - const pkg = require("../package.json") as { name?: unknown; version?: unknown }; - return { - name: typeof pkg.name === "string" ? pkg.name : "@vegamo/deepcode-cli", - version: typeof pkg.version === "string" ? pkg.version : (CLI_VERSION ?? ""), - gitCommit: GIT_COMMIT_INFO ?? "", - }; - } catch { - return { name: "@vegamo/deepcode-cli", version: CLI_VERSION ?? "", gitCommit: GIT_COMMIT_INFO ?? "" }; - } -} diff --git a/packages/cli/src/common/update-check.ts b/packages/cli/src/common/update-check.ts index 3b82e51a..fb387fe3 100644 --- a/packages/cli/src/common/update-check.ts +++ b/packages/cli/src/common/update-check.ts @@ -6,6 +6,7 @@ import * as path from "path"; import { render, type Instance } from "ink"; import { UpdatePrompt, type UpdatePromptChoice } from "../ui"; import { killProcessTree } from "@vegamo/deepcode-core"; +import type { PackageJson } from "../utils/package"; export type PackageInfo = { name: string; @@ -29,14 +30,14 @@ const MAX_NPM_VIEW_OUTPUT_CHARS = 64 * 1024; const TENCENT_MIRROR_REGISTRY = "https://mirrors.cloud.tencent.com/npm/"; export const UPDATE_SUCCESS_MESSAGE = "🎉 Update ran successfully! Please restart Deep Code."; -export async function promptForPendingUpdate(packageInfo: PackageInfo): Promise<{ installed: boolean }> { +export async function promptForPendingUpdate(packageInfo: PackageJson): Promise<{ installed: boolean }> { const state = readUpdateState(); const pending = state.pending; if (!pending) { return { installed: false }; } - if (compareVersions(packageInfo.version, pending.latestVersion) >= 0) { + if (compareVersions(packageInfo.version!, pending.latestVersion) >= 0) { writeUpdateState({ ...state, pending: null }); return { installed: false }; } @@ -49,7 +50,7 @@ export async function promptForPendingUpdate(packageInfo: PackageInfo): Promise< const installSpec = `${pending.packageName}@${pending.latestVersion}`; const installCommand = `npm install -g ${installSpec}`; const choice = await promptUpdateChoice({ - currentVersion: packageInfo.version, + currentVersion: packageInfo.version!, latestVersion: pending.latestVersion, installCommand, }); @@ -73,7 +74,7 @@ export async function promptForPendingUpdate(packageInfo: PackageInfo): Promise< return { installed: false }; } -export async function checkForNpmUpdate(packageInfo: PackageInfo): Promise { +export async function checkForNpmUpdate(packageInfo: PackageJson): Promise { if (!packageInfo.name || !packageInfo.version) { return; } diff --git a/packages/cli/src/tests/cli-args.test.ts b/packages/cli/src/tests/cli-args.test.ts index fd97de76..fe90eeed 100644 --- a/packages/cli/src/tests/cli-args.test.ts +++ b/packages/cli/src/tests/cli-args.test.ts @@ -1,47 +1,59 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { parseCliArgs } from "../cli-args"; +import { parseArguments, isValidSessionId } from "../cli-args"; -// ── parseCliArgs: basic parsing ────────────────────────────────────────────── +// ── isValidSessionId ───────────────────────────────────────────────────────── -test("parseCliArgs returns prompt after -p", () => { - const r = parseCliArgs(["-p", "hello world"]); +test("isValidSessionId accepts valid UUID", () => { + assert.ok(isValidSessionId("0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6")); +}); + +test("isValidSessionId rejects invalid format", () => { + assert.ok(!isValidSessionId("not-a-uuid")); + assert.ok(!isValidSessionId("")); + assert.ok(!isValidSessionId("abc")); +}); + +// ── parseArguments: basic parsing ────────────────────────────────────────────── + +test("parseArguments returns prompt after -p", async () => { + const r = await parseArguments(["-p", "hello world"]); assert.ok(!("message" in r)); assert.equal(r.prompt, "hello world"); }); -test("parseCliArgs returns prompt after --prompt", () => { - const r = parseCliArgs(["--prompt", "hello world"]); +test("parseArguments returns prompt after --prompt", async () => { + const r = await parseArguments(["--prompt", "hello world"]); assert.ok(!("message" in r)); assert.equal(r.prompt, "hello world"); }); -test("parseCliArgs returns undefined prompt when -p is not present", () => { - const r = parseCliArgs(["--resume"]); +test("parseArguments returns undefined prompt when -p is not present", async () => { + const r = await parseArguments(["--resume"]); assert.ok(!("message" in r)); assert.equal(r.prompt, undefined); }); -test("parseCliArgs returns session ID after --resume", () => { - const r = parseCliArgs(["--resume", "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"]); +test("parseArguments returns session ID after --resume", async () => { + const r = await parseArguments(["--resume", "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"]); assert.ok(!("message" in r)); assert.equal(r.resume, "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"); }); -test("parseCliArgs returns true when --resume has no value", () => { - const r = parseCliArgs(["--resume"]); +test("parseArguments returns true when --resume has no value", async () => { + const r = await parseArguments(["--resume"]); assert.ok(!("message" in r)); assert.equal(r.resume, true); }); -test("parseCliArgs returns undefined resume when not present", () => { - const r = parseCliArgs(["-p", "test"]); +test("parseArguments returns undefined resume when not present", async () => { + const r = await parseArguments(["-p", "test"]); assert.ok(!("message" in r)); assert.equal(r.resume, undefined); }); -test("parseCliArgs returns defaults for empty args", () => { - const r = parseCliArgs([]); +test("parseArguments returns defaults for empty args", async () => { + const r = await parseArguments([]); assert.ok(!("message" in r)); assert.equal(r.prompt, undefined); assert.equal(r.resume, undefined); @@ -49,120 +61,151 @@ test("parseCliArgs returns defaults for empty args", () => { assert.equal(r.help, false); }); -// ── parseCliArgs: -r alias ─────────────────────────────────────────────────── +// ── parseArguments: -r alias ─────────────────────────────────────────────────── -test("parseCliArgs returns session ID after -r", () => { - const r = parseCliArgs(["-r", "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"]); +test("parseArguments returns session ID after -r", async () => { + const r = await parseArguments(["-r", "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"]); assert.ok(!("message" in r)); assert.equal(r.resume, "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"); }); -test("parseCliArgs returns true when -r has no value", () => { - const r = parseCliArgs(["-r"]); +test("parseArguments returns true when -r has no value", async () => { + const r = await parseArguments(["-r"]); assert.ok(!("message" in r)); assert.equal(r.resume, true); }); -test("parseCliArgs handles -r combined with -p", () => { - const r = parseCliArgs(["-r", "session-123", "-p", "hello"]); +test("parseArguments handles -r combined with -p", async () => { + const r = await parseArguments(["-r", "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6", "-p", "hello"]); assert.ok(!("message" in r)); - assert.equal(r.resume, "session-123"); + assert.equal(r.resume, "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"); assert.equal(r.prompt, "hello"); }); -test("parseCliArgs rejects bare -r with -p", () => { - const r = parseCliArgs(["-r", "-p", "hello"]); - assert.ok("message" in r); - assert.match(r.message, /Cannot use --resume/); -}); - -// ── parseCliArgs: --version / --help ───────────────────────────────────────── +// ── parseArguments: --version / --help ───────────────────────────────────────── -test("parseCliArgs detects --version", () => { - const r = parseCliArgs(["--version"]); +test("parseArguments detects --version", async () => { + const r = await parseArguments(["--version"]); assert.ok(!("message" in r)); assert.equal(r.version, true); assert.equal(r.help, false); }); -test("parseCliArgs detects -v", () => { - const r = parseCliArgs(["-v"]); +test("parseArguments detects -v", async () => { + const r = await parseArguments(["-v"]); assert.ok(!("message" in r)); assert.equal(r.version, true); }); -test("parseCliArgs detects --help", () => { - const r = parseCliArgs(["--help"]); +test("parseArguments detects --help", async () => { + const r = await parseArguments(["--help"]); assert.ok(!("message" in r)); assert.equal(r.help, true); assert.equal(r.version, false); }); -test("parseCliArgs detects -h", () => { - const r = parseCliArgs(["-h"]); +test("parseArguments detects -h", async () => { + const r = await parseArguments(["-h"]); assert.ok(!("message" in r)); assert.equal(r.help, true); }); -test("parseCliArgs version and help are false when not passed", () => { - const r = parseCliArgs(["-p", "hello"]); +test("parseArguments version and help are false when not passed", async () => { + const r = await parseArguments(["-p", "hello"]); assert.ok(!("message" in r)); assert.equal(r.version, false); assert.equal(r.help, false); }); -test("parseCliArgs handles -v combined with -r (both flags set)", () => { - const r = parseCliArgs(["-v", "-r", "abc"]); +test("parseArguments handles -v combined with -r (both flags set)", async () => { + const r = await parseArguments(["-v", "-r", "abc"]); assert.ok(!("message" in r)); assert.equal(r.version, true); assert.equal(r.resume, "abc"); }); -// ── parseCliArgs: combined usage ───────────────────────────────────────────── +// ── parseArguments: combined usage ───────────────────────────────────────────── -test("parseCliArgs handles --resume combined with -p", () => { - const r = parseCliArgs(["--resume", "session-123", "-p", "hello"]); +test("parseArguments handles --resume combined with -p", async () => { + const r = await parseArguments(["--resume", "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6", "-p", "hello"]); assert.ok(!("message" in r)); - assert.equal(r.resume, "session-123"); + assert.equal(r.resume, "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"); assert.equal(r.prompt, "hello"); }); -test("parseCliArgs handles -p before --resume ", () => { - const r = parseCliArgs(["-p", "hello", "--resume", "session-123"]); +test("parseArguments handles -p before --resume ", async () => { + const r = await parseArguments(["-p", "hello", "--resume", "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"]); assert.ok(!("message" in r)); - assert.equal(r.resume, "session-123"); + assert.equal(r.resume, "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"); assert.equal(r.prompt, "hello"); }); -// ── parseCliArgs: validation ───────────────────────────────────────────────── - -test("parseCliArgs rejects bare --resume with -p", () => { - const r = parseCliArgs(["--resume", "-p", "hello"]); - assert.ok("message" in r); - assert.match(r.message, /Cannot use --resume/); -}); - -test("parseCliArgs rejects -p with bare --resume (reversed order)", () => { - const r = parseCliArgs(["-p", "hello", "--resume"]); - assert.ok("message" in r); - assert.match(r.message, /Cannot use --resume/); -}); - -test("parseCliArgs rejects unknown flags in strict mode", () => { - const r = parseCliArgs(["--unknown-flag"]); - assert.ok("message" in r); - assert.match(r.message, /Unknown argument/); -}); - -test("parseCliArgs rejects empty -p value", () => { - const r = parseCliArgs(["-p", ""]); - assert.ok("message" in r); - assert.match(r.message, /non-empty/); -}); - -test("parseCliArgs --version takes precedence over --help", () => { - const r = parseCliArgs(["--version", "--help"]); +test("parseArguments --version takes precedence over --help", async () => { + const r = await parseArguments(["--version", "--help"]); assert.ok(!("message" in r)); assert.equal(r.version, true); assert.equal(r.help, true); }); + +// ── parseArguments: error cases (mock process.exit) ──────────────────────────── +// Command-level and top-level errors both call process.exit(1) via yargs .fail(). + +function withMockedExit(fn: (exitSpy: { calls: number[] }) => Promise): Promise { + const original = process.exit; + const stderrWrite = process.stderr.write; + // Suppress yargs help/error output during tests + process.stderr.write = (() => true) as typeof process.stderr.write; + const exitSpy: { calls: number[] } = { calls: [] }; + process.exit = ((code?: number) => { + exitSpy.calls.push(code ?? 0); + throw new Error(`process.exit(${code})`); + }) as typeof process.exit; + return fn(exitSpy).finally(() => { + process.exit = original; + process.stderr.write = stderrWrite; + }); +} + +test("parseArguments exits on unknown flags", async () => { + await withMockedExit(async (exitSpy) => { + try { + await parseArguments(["--unknown-flag"]); + } catch { + /* expected */ + } + assert.ok(exitSpy.calls.length >= 1); + }); +}); + +test("parseArguments exits on bare -r with -p", async () => { + await withMockedExit(async (exitSpy) => { + try { + await parseArguments(["-r", "-p", "hello"]); + } catch { + /* expected */ + } + assert.ok(exitSpy.calls.length >= 1); + }); +}); + +test("parseArguments exits on empty -p value", async () => { + await withMockedExit(async (exitSpy) => { + try { + await parseArguments(["-p", ""]); + } catch { + /* expected */ + } + assert.ok(exitSpy.calls.length >= 1); + }); +}); + +test("parseArguments exits on invalid --resume session ID", async () => { + await withMockedExit(async (exitSpy) => { + try { + await parseArguments(["--resume", "not-a-uuid"]); + } catch { + /* expected */ + } + assert.ok(exitSpy.calls.length >= 1); + }); +}); diff --git a/packages/cli/src/utils/package.ts b/packages/cli/src/utils/package.ts new file mode 100644 index 00000000..1f195294 --- /dev/null +++ b/packages/cli/src/utils/package.ts @@ -0,0 +1,29 @@ +import { readPackageUp, type PackageJson as BasePackageJson } from "read-package-up"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import { CLI_VERSION } from "../generated/git-commit"; + +export type PackageJson = BasePackageJson & { + config?: { + sandboxImageUri?: string; + }; +}; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +let packageJson: PackageJson; + +export async function getPackageJson(): Promise { + if (packageJson) { + return packageJson; + } + + const result = await readPackageUp({ cwd: __dirname }); + if (!result) { + return { name: "@vegamo/deepcode-cli", version: CLI_VERSION ?? "" }; + } + + packageJson = result.packageJson; + return packageJson; +} diff --git a/packages/cli/src/utils/stdioHelpers.ts b/packages/cli/src/utils/stdioHelpers.ts new file mode 100644 index 00000000..f0202e99 --- /dev/null +++ b/packages/cli/src/utils/stdioHelpers.ts @@ -0,0 +1,25 @@ +/** + * Writes a message to stdout with a trailing newline. + * Use for normal command output that the user expects to see. + * Avoids double newlines if the message already ends with one. + */ +export const writeStdoutLine = (message: string): void => { + process.stdout.write(message.endsWith("\n") ? message : `${message}\n`); +}; + +/** + * Writes a message to stderr with a trailing newline. + * Use for error messages in CLI commands. + * Avoids double newlines if the message already ends with one. + */ +export const writeStderrLine = (message: string): void => { + process.stderr.write(message.endsWith("\n") ? message : `${message}\n`); +}; + +/** + * Clears the terminal screen. + * Use instead of console.clear() to satisfy no-console lint rules. + */ +export const clearScreen = (): void => { + console.clear(); +}; diff --git a/packages/cli/src/utils/version.ts b/packages/cli/src/utils/version.ts new file mode 100644 index 00000000..f41a5c1f --- /dev/null +++ b/packages/cli/src/utils/version.ts @@ -0,0 +1,6 @@ +import { getPackageJson } from "./package.js"; + +export async function getCliVersion(): Promise { + const pkgJson = await getPackageJson(); + return process.env["CLI_VERSION"] || pkgJson?.version || "unknown"; +} From 12b2c09f9652dd1b9f80c7bdf520037650873692 Mon Sep 17 00:00:00 2001 From: hcyang Date: Thu, 25 Jun 2026 16:09:43 +0800 Subject: [PATCH 8/9] =?UTF-8?q?fix(cli):=20=E4=BF=AE=E5=A4=8D=20Windows=20?= =?UTF-8?q?Shell=20=E9=85=8D=E7=BD=AE=E6=97=B6=E7=9A=84=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=E8=A1=8C=E5=8F=82=E6=95=B0=E5=A4=84=E7=90=86=E9=A1=BA=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 Windows Shell 配置逻辑调整到 --version 和 --help 参数处理之后 - 避免在 Windows 无 Git Bash 环境下配置 Shell 时提前退出进程 - 确保命令行参数如 --version 和 --help 正常工作 - 添加 configureWindowsShell 函数注释说明其调用时机和作用 - 清理 cli.tsx 中的导入和调用顺序,提高代码可读性 --- packages/cli/src/cli.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index edf5fdbd..af33bc42 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -11,7 +11,6 @@ import { writeStderrLine, writeStdoutLine } from "./utils/stdioHelpers"; import { getPackageJson } from "./utils/package"; import { CLI_VERSION } from "./generated/git-commit"; -configureWindowsShell(); void main(); async function main(): Promise { @@ -24,6 +23,11 @@ async function main(): Promise { process.exit(0); } + // Configure Windows shell AFTER --version/--help handling. + // On Windows without Git Bash, setShellIfWindows() throws and calls process.exit(1). + // If called before argument parsing, --help and --version would fail on those machines. + configureWindowsShell(); + let initialPrompt = parsed.prompt; let resumeSessionId = parsed.resume; const projectRoot = process.cwd(); @@ -95,6 +99,12 @@ async function main(): Promise { startApp(); } +/** + * Configure shell environment for Windows. + * Sets NoDefaultCurrentDirectoryInExePath and resolves Git Bash path. + * Must be called after --version/--help handling to avoid blocking those + * commands on Windows machines without Git Bash installed. + */ function configureWindowsShell(): void { process.env.NoDefaultCurrentDirectoryInExePath = "1"; try { From 7c8ece94105b964b4dcc92b73942462062cfbe90 Mon Sep 17 00:00:00 2001 From: hcyang Date: Thu, 25 Jun 2026 16:22:29 +0800 Subject: [PATCH 9/9] =?UTF-8?q?refactor(cli):=20=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E6=9C=AA=E4=BD=BF=E7=94=A8=E7=9A=84=20PackageInfo=20=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E5=AE=9A=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 packages/cli/src/common/update-check.ts 文件中未使用的 PackageInfo 类型 - 减少代码冗余,提升代码可维护性 - 保持类型定义的简洁性与准确性 --- packages/cli/src/common/update-check.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/cli/src/common/update-check.ts b/packages/cli/src/common/update-check.ts index fb387fe3..2dad85f8 100644 --- a/packages/cli/src/common/update-check.ts +++ b/packages/cli/src/common/update-check.ts @@ -8,12 +8,6 @@ import { UpdatePrompt, type UpdatePromptChoice } from "../ui"; import { killProcessTree } from "@vegamo/deepcode-core"; import type { PackageJson } from "../utils/package"; -export type PackageInfo = { - name: string; - version: string; - gitCommit?: string; -}; - type UpdateState = { pending?: { currentVersion: string;