Skip to content
Open
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
64 changes: 64 additions & 0 deletions packages/cli/src/cli-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,16 @@ export interface ParsedCliArgs {
version: boolean;
/** True when --help / -h was passed */
help: boolean;
/** Sub-command dispatched before the TUI starts (currently only "login"). */
command?: "login";
/** Parsed options for the `login` sub-command, present when `command === "login"`. */
login?: { apiKey?: string; show: boolean; help: boolean };
}

const EPILOG = [
"Commands:",
" login Save your DeepSeek API key to ~/.deepcode/settings.json",
"",
"Configuration:",
" ~/.deepcode/settings.json User-level API key, model, base URL",
" ./.deepcode/settings.json Project-level settings",
Expand Down Expand Up @@ -125,6 +132,55 @@ async function configureYargs(argv?: string[]) {
return yargsInstance;
}

/**
* Parse `deepcode login` options. Only a small, fixed set of flags is
* accepted; anything else is rejected with a usage hint and `process.exit(1)`.
*
* Supported:
* --api-key <key> | -k <key> | --api-key=<key> Non-interactive key
* --show Echo the key while typing
* -h | --help Show login help
*/
function parseLoginArgs(args: string[]): ParsedCliArgs {
let apiKey: string | undefined;
let show = false;
let help = false;

for (let i = 0; i < args.length; i++) {
const arg = args[i];

if (arg === "-h" || arg === "--help") {
help = true;
} else if (arg === "--show") {
show = true;
} else if (arg === "--api-key" || arg === "-k") {
const value = args[i + 1];
if (value === undefined || value.startsWith("-")) {
writeStderrLine(`${arg} requires a value.`);
writeStderrLine("Run `deepcode login --help` for usage.");
process.exit(1);
}
apiKey = value;
i++;
} else if (arg.startsWith("--api-key=")) {
apiKey = arg.slice("--api-key=".length);
} else {
writeStderrLine(`Unknown option: ${arg}`);
writeStderrLine("Run `deepcode login --help` for usage.");
process.exit(1);
}
}

return {
prompt: undefined,
resume: undefined,
version: false,
help: false,
command: "login",
login: { apiKey: apiKey?.trim() || undefined, show, help },
};
}

/**
* Parse CLI arguments with validation.
*
Expand All @@ -133,6 +189,14 @@ async function configureYargs(argv?: string[]) {
* valid `ParsedCliArgs` or terminates the process.
*/
export async function parseArguments(argv?: string[]): Promise<ParsedCliArgs> {
const rawArgv = argv ?? hideBin(process.argv);

// `login` is dispatched before yargs so it never enters the TUI parser
// (which is strict and would reject unknown sub-commands).
if (rawArgv[0] === "login") {
return parseLoginArgs(rawArgv.slice(1));
}

const y = (await configureYargs(argv)).exitProcess(false).fail((msg, _err, yargs) => {
writeStderrLine(msg || _err?.message || "Unknown error");
yargs.showHelp();
Expand Down
13 changes: 13 additions & 0 deletions packages/cli/src/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { setShellIfWindows, getProjectCode } from "@vegamo/deepcode-core";
import { checkForNpmUpdate, promptForPendingUpdate } from "./common/update-check";
import { AppContainer } from "./ui";
import { parseArguments } from "./cli-args";
import { runLogin, printLoginHelp } from "./commands/login";
import { writeStderrLine, writeStdoutLine } from "./utils/stdio-helpers";
import { getPackageJson } from "./utils/package";
import { CLI_VERSION } from "./generated/git-commit";
Expand All @@ -23,6 +24,18 @@ async function main(): Promise<void> {
process.exit(0);
}

// `login` is a standalone sub-command: it never starts the TUI and works
// without a TTY (so keys can be piped), so dispatch it before the
// interactive-terminal check and Windows shell configuration below.
if (parsed.command === "login") {
if (parsed.login?.help) {
printLoginHelp();
process.exit(0);
}
await runLogin({ apiKey: parsed.login?.apiKey, show: parsed.login?.show ?? false });
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.
Expand Down
189 changes: 189 additions & 0 deletions packages/cli/src/commands/login.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
/**
* `deepcode login` — interactively save your DeepSeek API key to
* ~/.deepcode/settings.json, writing a ready-to-use default config.
*
* This is a plain command-line script (no Ink/TUI): it reads the key from a
* hidden prompt, merges it with the existing user settings, and writes the
* result back. Run before the TUI is started in `cli.tsx`.
*/

import * as os from "node:os";
import { createInterface } from "node:readline";
import {
buildLoginSettings,
readSettings,
writeSettings,
getUserSettingsPath,
DEFAULT_MODEL,
DEFAULT_BASE_URL,
} from "@vegamo/deepcode-core";
import { writeStdout, writeStdoutLine, writeStderrLine } from "../utils/stdio-helpers";

export interface LoginOptions {
/** Non-interactive API key; skips the prompt entirely (for CI/scripts). */
apiKey?: string;
/** Echo the key as it is typed instead of masking each char with `*`. */
show: boolean;
}

/** Render an absolute path with the home directory collapsed to `~`. */
function formatHomePath(filePath: string): string {
const home = os.homedir();
return filePath.startsWith(home) ? `~${filePath.slice(home.length)}` : filePath;
}

/** Toggle stdin raw mode, tolerating non-TTY streams where it is unavailable. */
function setStdinRawMode(mode: boolean): void {
const stdin = process.stdin as NodeJS.ReadStream & {
setRawMode?: (mode: boolean) => void;
};
stdin.setRawMode?.(mode);
}

/**
* Read a single line from stdin with no echo, masking each typed character
* with `*`. Handles Backspace, Ctrl+U (clear line) and Ctrl+C (cancel).
* Falls back to a plain readline prompt when stdin is not a TTY (e.g. piped),
* where echo is naturally absent.
*/
function readHiddenLine(prompt: string): Promise<string | undefined> {
return new Promise((resolve) => {
const stdin = process.stdin;
const stdout = process.stdout;

if (!stdin.isTTY) {
// Piped stdin: don't echo the prompt (there is no one to read it),
// just consume one line.
const rl = createInterface({ input: stdin });
rl.question(prompt, (answer) => {
rl.close();
resolve(answer);
});
rl.on("SIGINT", () => {
rl.close();
resolve(undefined);
});
return;
}

stdout.write(prompt);
setStdinRawMode(true);
let value = "";

const onData = (buffer: Buffer): void => {
for (const byte of buffer) {
// Ctrl+C → cancel
if (byte === 0x03) {
cleanup();
stdout.write("\r\n");
resolve(undefined);
return;
}
// Enter → submit
if (byte === 0x0d || byte === 0x0a) {
cleanup();
stdout.write("\r\n");
resolve(value);
return;
}
// Backspace / Ctrl+H
if (byte === 0x7f || byte === 0x08) {
if (value.length > 0) {
value = value.slice(0, -1);
stdout.write("\b \b");
}
continue;
}
// Ctrl+U → clear the whole line
if (byte === 0x15) {
while (value.length > 0) {
value = value.slice(0, -1);
stdout.write("\b \b");
}
continue;
}
// Printable ASCII
if (byte >= 0x20 && byte <= 0x7e) {
value += String.fromCharCode(byte);
stdout.write("*");
}
}
};

const cleanup = (): void => {
stdin.removeListener("data", onData);
setStdinRawMode(false);
};

stdin.on("data", onData);
});
}

/** Read a single line with normal echo (for `--show` or piped stdin). */
function readVisibleLine(prompt: string): Promise<string | undefined> {
return new Promise((resolve) => {
const rl = createInterface({ input: process.stdin, output: process.stdout });
rl.question(prompt, (answer) => {
rl.close();
resolve(answer);
});
rl.on("SIGINT", () => {
rl.close();
resolve(undefined);
});
});
}

async function promptForApiKey(show: boolean): Promise<string | undefined> {
writeStdoutLine("Deep Code — Login");
writeStdoutLine(`Your API key is stored in ${formatHomePath(getUserSettingsPath())}`);
return show ? readVisibleLine("API key: ") : readHiddenLine("API key (hidden): ");
}

export async function runLogin(opts: LoginOptions): Promise<void> {
let apiKey = opts.apiKey?.trim();

if (!apiKey) {
const entered = await promptForApiKey(opts.show);
apiKey = entered?.trim();
}

if (!apiKey) {
writeStderrLine("Login cancelled: no API key provided.");
process.exitCode = 1;
return;
}

const existing = readSettings();
const next = buildLoginSettings(existing, apiKey);
writeSettings(next);

writeStdoutLine(`✓ API key saved to ${formatHomePath(getUserSettingsPath())}`);
writeStdoutLine(` model: ${next.env?.MODEL ?? DEFAULT_MODEL}`);
writeStdoutLine(` base URL: ${next.env?.BASE_URL ?? DEFAULT_BASE_URL}`);
writeStdoutLine(` Run \`deepcode\` to start.`);
}

export function printLoginHelp(): void {
const settingsPath = formatHomePath(getUserSettingsPath());
writeStdout(`Usage: deepcode login [options]

Save your DeepSeek API key to ${settingsPath}.

Interactively prompts for the API key (hidden by default) and writes a
ready-to-use default config: model=deepseek-v4-pro, base URL=api.deepseek.com,
thinking enabled, reasoning effort=max. Existing custom fields are preserved.

Options:
--api-key <key> Provide the key non-interactively (no prompt, for CI/scripts)
-k <key> Alias for --api-key
--show Show the key as you type it instead of masking with *
-h, --help Show this help

Examples:
deepcode login Prompt for the key (hidden)
deepcode login --show Prompt and show the key while typing
deepcode login --api-key sk-xxx Write the key without prompting
echo sk-xxx | deepcode login Pipe the key via stdin
`);
}
Loading