Skip to content

Commit c8d8175

Browse files
dongowuclaude
andcommitted
feat(cli): add deepcode login command to save API key
Add an interactive `deepcode login` sub-command that prompts for the DeepSeek API key and writes a ready-to-use default config to ~/.deepcode/settings.json (model=deepseek-v4-pro, base URL=api.deepseek.com, thinking enabled, reasoning effort=max). Existing custom fields are preserved when updating an existing settings file; only `env.API_KEY` is overwritten and missing defaults are filled in. Supported invocations: - `deepcode login` hidden input (Backspace/Ctrl+U/Ctrl+C aware) - `deepcode login --show` echo the key while typing - `deepcode login --api-key sk-...` / `-k` non-interactive (CI/scripts) - `echo sk-... | deepcode login` piped stdin (non-TTY fallback) `login` is dispatched in `main()` before the TUI starts and before the interactive-terminal check, so it works without a TTY. It is parsed by a dedicated `parseLoginArgs` that short-circuits ahead of yargs, keeping the existing strict TUI parser untouched — normal usage (`deepcode`, `-p`, `-r`, positional query) is unaffected (covered by regression tests). core: export `buildLoginSettings()` to merge existing settings with the default template + new API key. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ed2aa23 commit c8d8175

8 files changed

Lines changed: 515 additions & 1 deletion

File tree

packages/cli/src/cli-args.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,16 @@ export interface ParsedCliArgs {
3333
version: boolean;
3434
/** True when --help / -h was passed */
3535
help: boolean;
36+
/** Sub-command dispatched before the TUI starts (currently only "login"). */
37+
command?: "login";
38+
/** Parsed options for the `login` sub-command, present when `command === "login"`. */
39+
login?: { apiKey?: string; show: boolean; help: boolean };
3640
}
3741

3842
const EPILOG = [
43+
"Commands:",
44+
" login Save your DeepSeek API key to ~/.deepcode/settings.json",
45+
"",
3946
"Configuration:",
4047
" ~/.deepcode/settings.json User-level API key, model, base URL",
4148
" ./.deepcode/settings.json Project-level settings",
@@ -125,6 +132,55 @@ async function configureYargs(argv?: string[]) {
125132
return yargsInstance;
126133
}
127134

135+
/**
136+
* Parse `deepcode login` options. Only a small, fixed set of flags is
137+
* accepted; anything else is rejected with a usage hint and `process.exit(1)`.
138+
*
139+
* Supported:
140+
* --api-key <key> | -k <key> | --api-key=<key> Non-interactive key
141+
* --show Echo the key while typing
142+
* -h | --help Show login help
143+
*/
144+
function parseLoginArgs(args: string[]): ParsedCliArgs {
145+
let apiKey: string | undefined;
146+
let show = false;
147+
let help = false;
148+
149+
for (let i = 0; i < args.length; i++) {
150+
const arg = args[i];
151+
152+
if (arg === "-h" || arg === "--help") {
153+
help = true;
154+
} else if (arg === "--show") {
155+
show = true;
156+
} else if (arg === "--api-key" || arg === "-k") {
157+
const value = args[i + 1];
158+
if (value === undefined || value.startsWith("-")) {
159+
writeStderrLine(`${arg} requires a value.`);
160+
writeStderrLine("Run `deepcode login --help` for usage.");
161+
process.exit(1);
162+
}
163+
apiKey = value;
164+
i++;
165+
} else if (arg.startsWith("--api-key=")) {
166+
apiKey = arg.slice("--api-key=".length);
167+
} else {
168+
writeStderrLine(`Unknown option: ${arg}`);
169+
writeStderrLine("Run `deepcode login --help` for usage.");
170+
process.exit(1);
171+
}
172+
}
173+
174+
return {
175+
prompt: undefined,
176+
resume: undefined,
177+
version: false,
178+
help: false,
179+
command: "login",
180+
login: { apiKey: apiKey?.trim() || undefined, show, help },
181+
};
182+
}
183+
128184
/**
129185
* Parse CLI arguments with validation.
130186
*
@@ -133,6 +189,14 @@ async function configureYargs(argv?: string[]) {
133189
* valid `ParsedCliArgs` or terminates the process.
134190
*/
135191
export async function parseArguments(argv?: string[]): Promise<ParsedCliArgs> {
192+
const rawArgv = argv ?? hideBin(process.argv);
193+
194+
// `login` is dispatched before yargs so it never enters the TUI parser
195+
// (which is strict and would reject unknown sub-commands).
196+
if (rawArgv[0] === "login") {
197+
return parseLoginArgs(rawArgv.slice(1));
198+
}
199+
136200
const y = (await configureYargs(argv)).exitProcess(false).fail((msg, _err, yargs) => {
137201
writeStderrLine(msg || _err?.message || "Unknown error");
138202
yargs.showHelp();

packages/cli/src/cli.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { setShellIfWindows, getProjectCode } from "@vegamo/deepcode-core";
77
import { checkForNpmUpdate, promptForPendingUpdate } from "./common/update-check";
88
import { AppContainer } from "./ui";
99
import { parseArguments } from "./cli-args";
10+
import { runLogin, printLoginHelp } from "./commands/login";
1011
import { writeStderrLine, writeStdoutLine } from "./utils/stdio-helpers";
1112
import { getPackageJson } from "./utils/package";
1213
import { CLI_VERSION } from "./generated/git-commit";
@@ -23,6 +24,18 @@ async function main(): Promise<void> {
2324
process.exit(0);
2425
}
2526

27+
// `login` is a standalone sub-command: it never starts the TUI and works
28+
// without a TTY (so keys can be piped), so dispatch it before the
29+
// interactive-terminal check and Windows shell configuration below.
30+
if (parsed.command === "login") {
31+
if (parsed.login?.help) {
32+
printLoginHelp();
33+
process.exit(0);
34+
}
35+
await runLogin({ apiKey: parsed.login?.apiKey, show: parsed.login?.show ?? false });
36+
process.exit(0);
37+
}
38+
2639
// Configure Windows shell AFTER --version/--help handling.
2740
// On Windows without Git Bash, setShellIfWindows() throws and calls process.exit(1).
2841
// If called before argument parsing, --help and --version would fail on those machines.

packages/cli/src/commands/login.ts

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
/**
2+
* `deepcode login` — interactively save your DeepSeek API key to
3+
* ~/.deepcode/settings.json, writing a ready-to-use default config.
4+
*
5+
* This is a plain command-line script (no Ink/TUI): it reads the key from a
6+
* hidden prompt, merges it with the existing user settings, and writes the
7+
* result back. Run before the TUI is started in `cli.tsx`.
8+
*/
9+
10+
import * as os from "node:os";
11+
import { createInterface } from "node:readline";
12+
import {
13+
buildLoginSettings,
14+
readSettings,
15+
writeSettings,
16+
getUserSettingsPath,
17+
DEFAULT_MODEL,
18+
DEFAULT_BASE_URL,
19+
} from "@vegamo/deepcode-core";
20+
import { writeStdout, writeStdoutLine, writeStderrLine } from "../utils/stdio-helpers";
21+
22+
export interface LoginOptions {
23+
/** Non-interactive API key; skips the prompt entirely (for CI/scripts). */
24+
apiKey?: string;
25+
/** Echo the key as it is typed instead of masking each char with `*`. */
26+
show: boolean;
27+
}
28+
29+
/** Render an absolute path with the home directory collapsed to `~`. */
30+
function formatHomePath(filePath: string): string {
31+
const home = os.homedir();
32+
return filePath.startsWith(home) ? `~${filePath.slice(home.length)}` : filePath;
33+
}
34+
35+
/** Toggle stdin raw mode, tolerating non-TTY streams where it is unavailable. */
36+
function setStdinRawMode(mode: boolean): void {
37+
const stdin = process.stdin as NodeJS.ReadStream & {
38+
setRawMode?: (mode: boolean) => void;
39+
};
40+
stdin.setRawMode?.(mode);
41+
}
42+
43+
/**
44+
* Read a single line from stdin with no echo, masking each typed character
45+
* with `*`. Handles Backspace, Ctrl+U (clear line) and Ctrl+C (cancel).
46+
* Falls back to a plain readline prompt when stdin is not a TTY (e.g. piped),
47+
* where echo is naturally absent.
48+
*/
49+
function readHiddenLine(prompt: string): Promise<string | undefined> {
50+
return new Promise((resolve) => {
51+
const stdin = process.stdin;
52+
const stdout = process.stdout;
53+
54+
if (!stdin.isTTY) {
55+
// Piped stdin: don't echo the prompt (there is no one to read it),
56+
// just consume one line.
57+
const rl = createInterface({ input: stdin });
58+
rl.question(prompt, (answer) => {
59+
rl.close();
60+
resolve(answer);
61+
});
62+
rl.on("SIGINT", () => {
63+
rl.close();
64+
resolve(undefined);
65+
});
66+
return;
67+
}
68+
69+
stdout.write(prompt);
70+
setStdinRawMode(true);
71+
let value = "";
72+
73+
const onData = (buffer: Buffer): void => {
74+
for (const byte of buffer) {
75+
// Ctrl+C → cancel
76+
if (byte === 0x03) {
77+
cleanup();
78+
stdout.write("\r\n");
79+
resolve(undefined);
80+
return;
81+
}
82+
// Enter → submit
83+
if (byte === 0x0d || byte === 0x0a) {
84+
cleanup();
85+
stdout.write("\r\n");
86+
resolve(value);
87+
return;
88+
}
89+
// Backspace / Ctrl+H
90+
if (byte === 0x7f || byte === 0x08) {
91+
if (value.length > 0) {
92+
value = value.slice(0, -1);
93+
stdout.write("\b \b");
94+
}
95+
continue;
96+
}
97+
// Ctrl+U → clear the whole line
98+
if (byte === 0x15) {
99+
while (value.length > 0) {
100+
value = value.slice(0, -1);
101+
stdout.write("\b \b");
102+
}
103+
continue;
104+
}
105+
// Printable ASCII
106+
if (byte >= 0x20 && byte <= 0x7e) {
107+
value += String.fromCharCode(byte);
108+
stdout.write("*");
109+
}
110+
}
111+
};
112+
113+
const cleanup = (): void => {
114+
stdin.removeListener("data", onData);
115+
setStdinRawMode(false);
116+
};
117+
118+
stdin.on("data", onData);
119+
});
120+
}
121+
122+
/** Read a single line with normal echo (for `--show` or piped stdin). */
123+
function readVisibleLine(prompt: string): Promise<string | undefined> {
124+
return new Promise((resolve) => {
125+
const rl = createInterface({ input: process.stdin, output: process.stdout });
126+
rl.question(prompt, (answer) => {
127+
rl.close();
128+
resolve(answer);
129+
});
130+
rl.on("SIGINT", () => {
131+
rl.close();
132+
resolve(undefined);
133+
});
134+
});
135+
}
136+
137+
async function promptForApiKey(show: boolean): Promise<string | undefined> {
138+
writeStdoutLine("Deep Code — Login");
139+
writeStdoutLine(`Your API key is stored in ${formatHomePath(getUserSettingsPath())}`);
140+
return show ? readVisibleLine("API key: ") : readHiddenLine("API key (hidden): ");
141+
}
142+
143+
export async function runLogin(opts: LoginOptions): Promise<void> {
144+
let apiKey = opts.apiKey?.trim();
145+
146+
if (!apiKey) {
147+
const entered = await promptForApiKey(opts.show);
148+
apiKey = entered?.trim();
149+
}
150+
151+
if (!apiKey) {
152+
writeStderrLine("Login cancelled: no API key provided.");
153+
process.exitCode = 1;
154+
return;
155+
}
156+
157+
const existing = readSettings();
158+
const next = buildLoginSettings(existing, apiKey);
159+
writeSettings(next);
160+
161+
writeStdoutLine(`✓ API key saved to ${formatHomePath(getUserSettingsPath())}`);
162+
writeStdoutLine(` model: ${next.env?.MODEL ?? DEFAULT_MODEL}`);
163+
writeStdoutLine(` base URL: ${next.env?.BASE_URL ?? DEFAULT_BASE_URL}`);
164+
writeStdoutLine(` Run \`deepcode\` to start.`);
165+
}
166+
167+
export function printLoginHelp(): void {
168+
const settingsPath = formatHomePath(getUserSettingsPath());
169+
writeStdout(`Usage: deepcode login [options]
170+
171+
Save your DeepSeek API key to ${settingsPath}.
172+
173+
Interactively prompts for the API key (hidden by default) and writes a
174+
ready-to-use default config: model=deepseek-v4-pro, base URL=api.deepseek.com,
175+
thinking enabled, reasoning effort=max. Existing custom fields are preserved.
176+
177+
Options:
178+
--api-key <key> Provide the key non-interactively (no prompt, for CI/scripts)
179+
-k <key> Alias for --api-key
180+
--show Show the key as you type it instead of masking with *
181+
-h, --help Show this help
182+
183+
Examples:
184+
deepcode login Prompt for the key (hidden)
185+
deepcode login --show Prompt and show the key while typing
186+
deepcode login --api-key sk-xxx Write the key without prompting
187+
echo sk-xxx | deepcode login Pipe the key via stdin
188+
`);
189+
}

0 commit comments

Comments
 (0)