Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
267 changes: 251 additions & 16 deletions package-lock.json

Large diffs are not rendered by default.

9 changes: 7 additions & 2 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -37,6 +37,11 @@
"ignore": "^7.0.5",
"ink": "^7.0.4",
"ink-gradient": "^4.0.1",
"react": "^19.2.5"
"react": "^19.2.5",
"read-package-up": "^12.0.0",
"yargs": "^18.0.0"
},
"devDependencies": {
"@types/yargs": "^17.0.35"
}
}
160 changes: 160 additions & 0 deletions packages/cli/src/cli-args.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/**
* CLI argument parsing helpers.
* 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 */
prompt: string | undefined;
/**
* Resume session identifier:
* - `undefined` — --resume was not used
* - `true` — --resume was used without a session ID (show picker)
* - `string` — --resume <sessionId> was used
*/
resume: string | true | undefined;
/** True when --version / -v was passed */
version: boolean;
/** True when --help / -h was passed */
help: boolean;
}

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 <sessionId> -p <prompt> to resume a session and send a prompt.";
}
// validate --resume <sessionId> 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 <prompt>", "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.
*
* 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 async function parseArguments(argv?: string[]): Promise<ParsedCliArgs> {
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<string, unknown>;

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 {
prompt: parsed.prompt as string | undefined,
resume,
version: parsed.version === true,
help: parsed.help === true,
};
}
142 changes: 58 additions & 84 deletions packages/cli/src/cli.tsx
Original file line number Diff line number Diff line change
@@ -1,83 +1,60 @@
import React from "react";
import { render } from "ink";
import { setShellIfWindows } from "@vegamo/deepcode-core";
import { checkForNpmUpdate, promptForPendingUpdate, type PackageInfo } from "./common/update-check";
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 } from "./common/update-check";
import { AppContainer } from "./ui";
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 = process.argv.slice(2);
const packageInfo = readPackageInfo();

if (args.includes("--version") || args.includes("-v")) {
process.stdout.write(`${packageInfo.version || "unknown"}\n`);
process.exit(0);
}
void main();

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 <prompt> Launch with a pre-filled prompt",
" deepcode --prompt <prompt> Same as -p",
" 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"
);
process.exit(0);
}
async function main(): Promise<void> {
const packageInfo = await getPackageJson();
const parsed = await parseArguments();

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];
// --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);
}
return undefined;
}

let initialPrompt = extractInitialPrompt(args);
const projectRoot = process.cwd();
configureWindowsShell();
// 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();

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

void main();
if (!process.stdin.isTTY) {
writeStderrLine("deepcode requires an interactive terminal (TTY). Re-run from a real terminal session.\n");
process.exit(1);
}

// Validate --resume <sessionId> 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);
}
}

async function main(): Promise<void> {
const updatePromptResult = await promptForPendingUpdate(packageInfo);
if (updatePromptResult.installed) {
process.exit(0);
Expand All @@ -89,19 +66,22 @@ async function main(): Promise<void> {
let restarting = false;
const appInitialPrompt = initialPrompt;
initialPrompt = undefined;
const appResumeSessionId = resumeSessionId;
resumeSessionId = undefined;
const inkInstance = render(
<AppContainer
projectRoot={projectRoot}
version={packageInfo.version}
version={packageInfo?.version ?? CLI_VERSION}
initialPrompt={appInitialPrompt}
resumeSessionId={appResumeSessionId}
onRestart={() => restartRef.current?.()}
/>,
{ exitOnCtrlC: false }
);

restartRef.current = () => {
restarting = true;
process.stdout.write("\u001B[2J\u001B[3J\u001B[H");
writeStdoutLine("\u001B[2J\u001B[3J\u001B[H");
inkInstance.unmount();
startApp();
};
Expand All @@ -119,25 +99,19 @@ async function main(): Promise<void> {
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 {
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 : "",
};
} catch {
return { name: "@vegamo/deepcode-cli", version: "" };
}
}
Loading
Loading