Skip to content

Commit 65bed53

Browse files
committed
feat: add interactive API key prompt for missing LLM credentials
1 parent 93d47bf commit 65bed53

6 files changed

Lines changed: 114 additions & 21 deletions

File tree

README.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,13 +64,18 @@ Install dependencies:
6464
bun install
6565
```
6666

67-
Set your API key and start an interactive chat:
67+
Start an interactive chat:
6868

6969
```sh
70-
export ANTHROPIC_API_KEY="sk-ant-..."
7170
bun run src/index.ts
7271
```
7372

73+
The agent prompts for your Anthropic API key on launch if it isn't already set. To skip the prompt, set it in your environment beforehand:
74+
75+
```sh
76+
export ANTHROPIC_API_KEY="sk-ant-..."
77+
```
78+
7479
### Standalone binary
7580

7681
Compile a self-contained executable (bundles the Bun runtime and all dependencies):

src/adapters/llm/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@ type LlmFromEnv = (env: Env, systemPrompt?: string) => LlmClient | null;
66

77
const adapters: LlmFromEnv[] = [anthropicFromEnv];
88

9-
export function resolveLlmClient(systemPrompt?: string, env: Env = process.env): LlmClient {
9+
// Returns the first provider whose keys are present in the environment.
10+
export function resolveLlmClient(systemPrompt?: string, env: Env = process.env): LlmClient | null {
1011
for (const fromEnv of adapters) {
1112
const client = fromEnv(env, systemPrompt);
1213
if (client) return client;
1314
}
14-
throw new Error("No LLM provider configured. Hint: set `ANTHROPIC_API_KEY`");
15+
return null;
1516
}

src/index.ts

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { createDemoAgent } from "./demo";
77
import systemPrompt from "./prompt.md";
88
import { App } from "./tui/App.tsx";
99
import { initColorScheme } from "./tui/color-scheme.ts";
10+
import { ApiKeyPrompt } from "./tui/components/ApiKeyPrompt.tsx";
1011

1112
await initColorScheme();
1213

@@ -21,7 +22,9 @@ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
2122
}
2223

2324
const agent =
24-
process.env.DEMO === "1" ? await createDemoAgent() : await createAgent(resolveLlmClientOrExit());
25+
process.env.DEMO === "1"
26+
? await createDemoAgent()
27+
: await createAgent(await resolveLlmClientInteractive());
2528

2629
const { waitUntilExit } = render(createElement(App, { agent }), { exitOnCtrlC: true });
2730
try {
@@ -30,24 +33,60 @@ try {
3033
leaveAltScreen();
3134
}
3235

33-
function resolveLlmClientOrExit() {
34-
try {
35-
return resolveLlmClient(systemPrompt);
36-
} catch (error) {
37-
leaveAltScreen();
38-
console.error(`Error: ${error instanceof Error ? error.message : error}`);
39-
process.exit(1);
40-
}
36+
async function resolveLlmClientInteractive() {
37+
const configured = resolveLlmClient(systemPrompt);
38+
if (configured) return configured;
39+
40+
const key = await promptForApiKey("ANTHROPIC_API_KEY");
41+
process.env.ANTHROPIC_API_KEY = key;
42+
43+
clearScreen();
44+
45+
const client = resolveLlmClient(systemPrompt);
46+
if (client) return client;
47+
48+
leaveAltScreen();
49+
console.error("Error: could not initialise an LLM client from the provided key.");
50+
process.exit(1);
51+
}
52+
53+
function promptForApiKey(envVarName: string): Promise<string> {
54+
return new Promise((resolve) => {
55+
let submitted = false;
56+
const { unmount, waitUntilExit } = render(
57+
createElement(ApiKeyPrompt, {
58+
envVarName,
59+
onSubmit: (key: string) => {
60+
submitted = true;
61+
unmount();
62+
resolve(key);
63+
},
64+
}),
65+
{ exitOnCtrlC: true },
66+
);
67+
void waitUntilExit().then(() => {
68+
if (!submitted) {
69+
leaveAltScreen();
70+
process.exit(0);
71+
}
72+
});
73+
});
4174
}
4275

4376
function enterAltScreen() {
4477
if (!process.stdout.isTTY) return;
45-
writeSync(process.stdout.fd, "\x1b[?1049h\x1b[2J\x1b[H");
78+
writeSync(process.stdout.fd, "\x1b[?1049h");
4679
altScreenActive = true;
80+
clearScreen();
4781
}
4882

4983
function leaveAltScreen() {
5084
if (!altScreenActive) return;
5185
writeSync(process.stdout.fd, "\x1b[?1049l\x1b[?25h");
5286
altScreenActive = false;
5387
}
88+
89+
function clearScreen() {
90+
if (!process.stdout.isTTY) return;
91+
writeSync(process.stdout.fd, "\x1b[2J\x1b[H");
92+
}

src/tui/color-scheme.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ function detectColorScheme(): Promise<ColorScheme> {
3838
return new Promise<ColorScheme>((resolve) => {
3939
const timeout = setTimeout(() => {
4040
restore();
41-
resolve("dark");
41+
settle("dark");
4242
}, 200);
4343

4444
let response = "";
@@ -48,7 +48,7 @@ function detectColorScheme(): Promise<ColorScheme> {
4848
if (!DA1_RESPONSE.test(response)) return;
4949

5050
restore();
51-
resolve(classify(response));
51+
settle(classify(response));
5252
}
5353

5454
function restore() {
@@ -58,6 +58,10 @@ function detectColorScheme(): Promise<ColorScheme> {
5858
process.stdin.pause();
5959
}
6060

61+
function settle(scheme: ColorScheme) {
62+
setTimeout(() => resolve(scheme), 0);
63+
}
64+
6165
process.stdin.setRawMode(true);
6266
process.stdin.resume();
6367
process.stdin.on("data", onData);
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { PasswordInput } from "@inkjs/ui";
2+
import { Box, Text } from "ink";
3+
import { isLightScheme } from "../color-scheme.ts";
4+
import { theme } from "../theme.ts";
5+
6+
type ApiKeyPromptProps = {
7+
envVarName: string;
8+
onSubmit: (key: string) => void;
9+
};
10+
11+
export function ApiKeyPrompt({ envVarName, onSubmit }: ApiKeyPromptProps) {
12+
const handleSubmit = (value: string) => {
13+
const key = value.trim();
14+
if (key === "") return;
15+
onSubmit(key);
16+
};
17+
18+
return (
19+
<Box flexDirection="column" gap={1}>
20+
<Box paddingX={1}>
21+
<Text>
22+
<Text dimColor>
23+
No LLM provider is configured. Paste an Anthropic API key to continue, or press{" "}
24+
</Text>
25+
<Text color={theme.accent}>Ctrl+C</Text>
26+
<Text dimColor> to quit.</Text>
27+
</Text>
28+
</Box>
29+
<Box
30+
paddingX={1}
31+
borderStyle="round"
32+
borderColor={theme.accent}
33+
borderDimColor={!isLightScheme()}
34+
>
35+
<Text color={theme.accent} bold>
36+
{`${envVarName} `}
37+
</Text>
38+
<Box flexGrow={1}>
39+
<PasswordInput placeholder="sk-ant-..." onSubmit={handleSubmit} />
40+
</Box>
41+
</Box>
42+
</Box>
43+
);
44+
}

tests/adapters/llm/resolve.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ describe("LLM client resolution", () => {
55
test("returns an Anthropic client when ANTHROPIC_API_KEY is set", () => {
66
const client = resolveLlmClient(undefined, { ANTHROPIC_API_KEY: "test-key" });
77

8-
expect(client.provider).toBe("anthropic");
9-
expect(client.model).toBe("claude-sonnet-4-6");
8+
expect(client?.provider).toBe("anthropic");
9+
expect(client?.model).toBe("claude-sonnet-4-6");
1010
});
1111

1212
test("honours the MODEL override", () => {
@@ -15,10 +15,10 @@ describe("LLM client resolution", () => {
1515
MODEL: "claude-opus-4-8",
1616
});
1717

18-
expect(client.model).toBe("claude-opus-4-8");
18+
expect(client?.model).toBe("claude-opus-4-8");
1919
});
2020

21-
test("throws an error when no provider is configured", () => {
22-
expect(() => resolveLlmClient(undefined, {})).toThrow("No LLM provider configured");
21+
test("returns null when no provider is configured", () => {
22+
expect(resolveLlmClient(undefined, {})).toBeNull();
2323
});
2424
});

0 commit comments

Comments
 (0)