Skip to content

Commit 05de64c

Browse files
authored
Merge pull request #194 from hqwlkj/main
feat(cli): 支持通过 --resume 参数恢复会话
2 parents f279c83 + 9594f83 commit 05de64c

15 files changed

Lines changed: 870 additions & 142 deletions

File tree

package-lock.json

Lines changed: 251 additions & 16 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/cli/package.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
"scripts": {
2626
"typecheck": "tsc -p ./ --noEmit",
2727
"bundle": "node ../../scripts/esbuild.config.js",
28-
"build": "npm run typecheck && npm run bundle && node ../../scripts/copy-bundle-assets.js && node -e \"require('fs').chmodSync('dist/cli.js', 0o755)\"",
28+
"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))\"",
2929
"prepublishOnly": "npm run build",
3030
"format": "prettier --write .",
3131
"test": "node src/tests/run-tests.mjs"
@@ -37,6 +37,11 @@
3737
"ignore": "^7.0.5",
3838
"ink": "^7.0.4",
3939
"ink-gradient": "^4.0.1",
40-
"react": "^19.2.5"
40+
"react": "^19.2.5",
41+
"read-package-up": "^12.0.0",
42+
"yargs": "^18.0.0"
43+
},
44+
"devDependencies": {
45+
"@types/yargs": "^17.0.35"
4146
}
4247
}

packages/cli/src/cli-args.ts

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/**
2+
* CLI argument parsing helpers.
3+
* Uses yargs for robust argument parsing and validation.
4+
*/
5+
6+
import type { Argv } from "yargs";
7+
import Yargs from "yargs";
8+
import { getCliVersion } from "./utils/version";
9+
import { writeStderrLine } from "./utils/stdioHelpers";
10+
import { hideBin } from "yargs/helpers";
11+
12+
// UUID v4 regex pattern for validation
13+
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;
14+
15+
/**
16+
* Validates if a string is a valid session ID format.
17+
*/
18+
export function isValidSessionId(value: string): boolean {
19+
return SESSION_ID_REGEX.test(value);
20+
}
21+
22+
export interface ParsedCliArgs {
23+
/** Prompt text from -p / --prompt */
24+
prompt: string | undefined;
25+
/**
26+
* Resume session identifier:
27+
* - `undefined` — --resume was not used
28+
* - `true` — --resume was used without a session ID (show picker)
29+
* - `string` — --resume <sessionId> was used
30+
*/
31+
resume: string | true | undefined;
32+
/** True when --version / -v was passed */
33+
version: boolean;
34+
/** True when --help / -h was passed */
35+
help: boolean;
36+
}
37+
38+
const EPILOG = [
39+
"Configuration:",
40+
" ~/.deepcode/settings.json User-level API key, model, base URL",
41+
" ./.deepcode/settings.json Project-level settings",
42+
" ./.deepcode/skills/*/SKILL.md Project-level native skills",
43+
" ./.agents/skills/*/SKILL.md Project-level interoperable skills",
44+
" ~/.deepcode/skills/*/SKILL.md User-level native skills",
45+
" ~/.agents/skills/*/SKILL.md User-level interoperable skills",
46+
"",
47+
"Inside the TUI:",
48+
" enter Send the prompt",
49+
" shift+enter Insert a newline",
50+
" home/end Move within the current line",
51+
" alt+left/right Move by word",
52+
" ctrl+w Delete the previous word",
53+
" ctrl+v Paste an image from the clipboard",
54+
" ctrl+x Clear pasted images",
55+
" esc Interrupt the current model turn",
56+
" / Open the skills/commands menu",
57+
" /skills List available skills",
58+
" /model Select model, thinking mode and effort control",
59+
" /new Start a fresh conversation",
60+
" /init Initialize an AGENTS.md file with instructions for LLM",
61+
" /resume Pick a previous conversation to continue",
62+
" /continue Continue the active conversation, or resume one if empty",
63+
" /undo Restore code and/or conversation to a previous point",
64+
" /mcp Show MCP server status and available tools",
65+
" /raw Toggle display mode for viewing or collapsing reasoning content",
66+
" /exit Quit",
67+
" ctrl+d twice Quit",
68+
].join("\n");
69+
70+
async function configureYargs(argv?: string[]) {
71+
const rawArgv = argv ?? hideBin(process.argv);
72+
const yargsInstance = Yargs(rawArgv)
73+
.locale("en")
74+
.scriptName("deepcode")
75+
.usage(
76+
"Usage: $0 [options] [command]\n\nDeep Code - Launch an interactive CLI, use -p/--prompt for non-interactive mode"
77+
)
78+
.command("$0 [query..]", "Launch Deep Code CLI", (yargsInstance: Argv) =>
79+
yargsInstance
80+
.option("prompt", {
81+
alias: "p",
82+
type: "string",
83+
describe: "Submit a prompt on launch",
84+
})
85+
.option("resume", {
86+
alias: "r",
87+
type: "string",
88+
describe: "Resume a specific session by its ID. Use without an ID to show session picker.",
89+
})
90+
.check((argv: { [x: string]: unknown }) => {
91+
const query = argv["query"] as string | string[] | undefined;
92+
const hasPositionalQuery = Array.isArray(query) ? query.length > 0 : !!query;
93+
94+
if (argv["prompt"] && hasPositionalQuery) {
95+
return "Cannot use both a positional prompt and the --prompt (-p) flag together";
96+
}
97+
// bare --resume conflicts with --prompt
98+
if (argv["resume"] === "" && argv["prompt"]) {
99+
return "Cannot use --resume without a session ID together with --prompt.\nUse --resume <sessionId> -p <prompt> to resume a session and send a prompt.";
100+
}
101+
// validate --resume <sessionId> format if provided
102+
if (argv["resume"] && argv["resume"] !== "" && !isValidSessionId(argv["resume"] as string)) {
103+
return `Invalid session ID: "${argv["resume"]}". Must be a valid UUID (e.g., "123e4567-e89b-12d3-a456-426614174000").`;
104+
}
105+
// empty prompt is meaningless
106+
if (argv["prompt"] === "") {
107+
return "--prompt / -p requires a non-empty value.";
108+
}
109+
return true;
110+
})
111+
)
112+
.example("deepcode", "Launch the interactive TUI in the current directory")
113+
.example("deepcode -p <prompt>", "Launch with a pre-filled prompt")
114+
.example("deepcode -r, --resume [sessionId]", "Resume a session or show session picker")
115+
.epilog(EPILOG)
116+
.strict()
117+
.demandCommand(0, 0)
118+
.wrap(Math.min(process.stdout.columns || 80, 120));
119+
yargsInstance
120+
.version(await getCliVersion())
121+
.alias("v", "version")
122+
.help()
123+
.alias("h", "help");
124+
yargsInstance.wrap(yargsInstance.terminalWidth());
125+
return yargsInstance;
126+
}
127+
128+
/**
129+
* Parse CLI arguments with validation.
130+
*
131+
* On validation failure the `.fail()` handler prints the error, shows help,
132+
* and calls `process.exit(1)`, so this function always either returns a
133+
* valid `ParsedCliArgs` or terminates the process.
134+
*/
135+
export async function parseArguments(argv?: string[]): Promise<ParsedCliArgs> {
136+
const y = (await configureYargs(argv)).exitProcess(false).fail((msg, _err, yargs) => {
137+
writeStderrLine(msg || _err?.message || "Unknown error");
138+
yargs.showHelp();
139+
process.exit(1);
140+
});
141+
142+
const parsed = y.parseSync() as Record<string, unknown>;
143+
144+
const resumeRaw = parsed.resume as string | undefined;
145+
let resume: ParsedCliArgs["resume"];
146+
if (resumeRaw === undefined) {
147+
resume = undefined;
148+
} else if (resumeRaw === "") {
149+
resume = true;
150+
} else {
151+
resume = resumeRaw;
152+
}
153+
154+
return {
155+
prompt: parsed.prompt as string | undefined,
156+
resume,
157+
version: parsed.version === true,
158+
help: parsed.help === true,
159+
};
160+
}

packages/cli/src/cli.tsx

Lines changed: 58 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -1,83 +1,60 @@
11
import React from "react";
22
import { render } from "ink";
3-
import { setShellIfWindows } from "@vegamo/deepcode-core";
4-
import { checkForNpmUpdate, promptForPendingUpdate, type PackageInfo } from "./common/update-check";
3+
import { readFileSync } from "node:fs";
4+
import { join } from "node:path";
5+
import { homedir } from "node:os";
6+
import { setShellIfWindows, getProjectCode } from "@vegamo/deepcode-core";
7+
import { checkForNpmUpdate, promptForPendingUpdate } from "./common/update-check";
58
import { AppContainer } from "./ui";
9+
import { parseArguments } from "./cli-args";
10+
import { writeStderrLine, writeStdoutLine } from "./utils/stdioHelpers";
11+
import { getPackageJson } from "./utils/package";
12+
import { CLI_VERSION } from "./generated/git-commit";
613

7-
const args = process.argv.slice(2);
8-
const packageInfo = readPackageInfo();
9-
10-
if (args.includes("--version") || args.includes("-v")) {
11-
process.stdout.write(`${packageInfo.version || "unknown"}\n`);
12-
process.exit(0);
13-
}
14+
void main();
1415

15-
if (args.includes("--help") || args.includes("-h")) {
16-
process.stdout.write(
17-
[
18-
"deepcode - Deep Code CLI",
19-
"",
20-
"Usage:",
21-
" deepcode Launch the interactive TUI in the current directory",
22-
" deepcode -p <prompt> Launch with a pre-filled prompt",
23-
" deepcode --prompt <prompt> Same as -p",
24-
" deepcode --version Print the version",
25-
" deepcode --help Show this help",
26-
"",
27-
"Configuration:",
28-
" ~/.deepcode/settings.json User-level API key, model, base URL",
29-
" ./.deepcode/settings.json Project-level settings",
30-
" ./.deepcode/skills/*/SKILL.md Project-level native skills",
31-
" ./.agents/skills/*/SKILL.md Project-level interoperable skills",
32-
" ~/.deepcode/skills/*/SKILL.md User-level native skills",
33-
" ~/.agents/skills/*/SKILL.md User-level interoperable skills",
34-
"",
35-
"Inside the TUI:",
36-
" enter Send the prompt",
37-
" shift+enter Insert a newline",
38-
" home/end Move within the current line",
39-
" alt+left/right Move by word",
40-
" ctrl+w Delete the previous word",
41-
" ctrl+v Paste an image from the clipboard",
42-
" ctrl+x Clear pasted images",
43-
" esc Interrupt the current model turn",
44-
" / Open the skills/commands menu",
45-
" /skills List available skills",
46-
" /model Select model, thinking mode and effort control",
47-
" /new Start a fresh conversation",
48-
" /init Initialize an AGENTS.md file with instructions for LLM",
49-
" /resume Pick a previous conversation to continue",
50-
" /continue Continue the active conversation, or resume one if empty",
51-
" /undo Restore code and/or conversation to a previous point",
52-
" /mcp Show MCP server status and available tools",
53-
" /raw Toggle display mode for viewing or collapsing reasoning content",
54-
" /exit Quit",
55-
" ctrl+d twice Quit",
56-
].join("\n") + "\n"
57-
);
58-
process.exit(0);
59-
}
16+
async function main(): Promise<void> {
17+
const packageInfo = await getPackageJson();
18+
const parsed = await parseArguments();
6019

61-
function extractInitialPrompt(args: string[]): string | undefined {
62-
const promptIndex = args.findIndex((arg) => arg === "-p" || arg === "--prompt");
63-
if (promptIndex !== -1 && promptIndex + 1 < args.length) {
64-
return args[promptIndex + 1];
20+
// --version and --help are handled by yargs internally (prints output as side effect)
21+
// but with .exitProcess(false) we need to exit manually.
22+
if (parsed.version || parsed.help) {
23+
process.exit(0);
6524
}
66-
return undefined;
67-
}
6825

69-
let initialPrompt = extractInitialPrompt(args);
70-
const projectRoot = process.cwd();
71-
configureWindowsShell();
26+
// Configure Windows shell AFTER --version/--help handling.
27+
// On Windows without Git Bash, setShellIfWindows() throws and calls process.exit(1).
28+
// If called before argument parsing, --help and --version would fail on those machines.
29+
configureWindowsShell();
7230

73-
if (!process.stdin.isTTY) {
74-
process.stderr.write("deepcode requires an interactive terminal (TTY). " + "Re-run from a real terminal session.\n");
75-
process.exit(1);
76-
}
31+
let initialPrompt = parsed.prompt;
32+
let resumeSessionId = parsed.resume;
33+
const projectRoot = process.cwd();
7734

78-
void main();
35+
if (!process.stdin.isTTY) {
36+
writeStderrLine("deepcode requires an interactive terminal (TTY). Re-run from a real terminal session.\n");
37+
process.exit(1);
38+
}
39+
40+
// Validate --resume <sessionId> before entering TUI
41+
if (typeof resumeSessionId === "string") {
42+
const projectCode = getProjectCode(projectRoot);
43+
const indexPath = join(homedir(), ".deepcode", "projects", projectCode, "sessions-index.json");
44+
try {
45+
const index = JSON.parse(readFileSync(indexPath, "utf-8"));
46+
const found =
47+
Array.isArray(index?.entries) && index.entries.some((e: { id: string }) => e.id === resumeSessionId);
48+
if (!found) {
49+
writeStderrLine(`No saved session found with ID "${resumeSessionId}".\n`);
50+
process.exit(1);
51+
}
52+
} catch {
53+
writeStderrLine(`No saved session found with ID "${resumeSessionId}".\n`);
54+
process.exit(1);
55+
}
56+
}
7957

80-
async function main(): Promise<void> {
8158
const updatePromptResult = await promptForPendingUpdate(packageInfo);
8259
if (updatePromptResult.installed) {
8360
process.exit(0);
@@ -89,19 +66,22 @@ async function main(): Promise<void> {
8966
let restarting = false;
9067
const appInitialPrompt = initialPrompt;
9168
initialPrompt = undefined;
69+
const appResumeSessionId = resumeSessionId;
70+
resumeSessionId = undefined;
9271
const inkInstance = render(
9372
<AppContainer
9473
projectRoot={projectRoot}
95-
version={packageInfo.version}
74+
version={packageInfo?.version ?? CLI_VERSION}
9675
initialPrompt={appInitialPrompt}
76+
resumeSessionId={appResumeSessionId}
9777
onRestart={() => restartRef.current?.()}
9878
/>,
9979
{ exitOnCtrlC: false }
10080
);
10181

10282
restartRef.current = () => {
10383
restarting = true;
104-
process.stdout.write("\u001B[2J\u001B[3J\u001B[H");
84+
writeStdoutLine("\u001B[2J\u001B[3J\u001B[H");
10585
inkInstance.unmount();
10686
startApp();
10787
};
@@ -119,25 +99,19 @@ async function main(): Promise<void> {
11999
startApp();
120100
}
121101

102+
/**
103+
* Configure shell environment for Windows.
104+
* Sets NoDefaultCurrentDirectoryInExePath and resolves Git Bash path.
105+
* Must be called after --version/--help handling to avoid blocking those
106+
* commands on Windows machines without Git Bash installed.
107+
*/
122108
function configureWindowsShell(): void {
123109
process.env.NoDefaultCurrentDirectoryInExePath = "1";
124110
try {
125111
setShellIfWindows();
126112
} catch (error) {
127113
const message = error instanceof Error ? error.message : String(error);
128-
process.stderr.write(`deepcode: ${message}\n`);
114+
writeStderrLine(`deepcode: ${message}\n`);
129115
process.exit(1);
130116
}
131117
}
132-
133-
function readPackageInfo(): PackageInfo {
134-
try {
135-
const pkg = require("../package.json") as { name?: unknown; version?: unknown };
136-
return {
137-
name: typeof pkg.name === "string" ? pkg.name : "@vegamo/deepcode-cli",
138-
version: typeof pkg.version === "string" ? pkg.version : "",
139-
};
140-
} catch {
141-
return { name: "@vegamo/deepcode-cli", version: "" };
142-
}
143-
}

0 commit comments

Comments
 (0)