Skip to content

Commit 11add3e

Browse files
committed
refactor: move API key prompt into main app lifecycle
Allow the agent to start without an LLM client and prompt for an API key within the app, rather than blocking startup. This enables deferred configuration and better error handling for invalid keys.
1 parent 90e30e3 commit 11add3e

7 files changed

Lines changed: 106 additions & 87 deletions

File tree

src/adapters/llm/anthropic.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,16 @@ export function anthropicFromEnv(
66
env: Record<string, string | undefined>,
77
systemPrompt?: string,
88
): AnthropicLlmClient | null {
9-
if (!env.ANTHROPIC_API_KEY) {
9+
const apiKey = env.ANTHROPIC_API_KEY;
10+
if (!apiKey) return null;
11+
12+
// A custom base URL means a proxy or gateway, whose keys use their own format.
13+
if (!env.ANTHROPIC_BASE_URL && !apiKey.startsWith("sk-ant-")) {
1014
return null;
1115
}
16+
1217
return new AnthropicLlmClient(
13-
new Anthropic({ apiKey: env.ANTHROPIC_API_KEY }),
18+
new Anthropic({ apiKey }),
1419
env.MODEL ?? "claude-sonnet-4-6",
1520
systemPrompt,
1621
);

src/agent/agent.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,18 @@ export class Agent {
99
private messages: Message[] = [];
1010

1111
constructor(
12-
private readonly client: LlmClient,
12+
private client: LlmClient | null,
1313
private readonly registry: ToolRegistry,
1414
private readonly session: Session,
1515
private readonly notices: string[] = [],
1616
) {}
1717

18-
modelInfo(): ModelInfo {
18+
setClient(client: LlmClient): void {
19+
this.client = client;
20+
}
21+
22+
modelInfo(): ModelInfo | null {
23+
if (!this.client) return null;
1924
return { provider: this.client.provider, model: this.client.model };
2025
}
2126

@@ -37,6 +42,10 @@ export class Agent {
3742
}
3843

3944
async *turn(input: string, signal?: AbortSignal): AsyncGenerator<AgentEvent> {
45+
if (!this.client) {
46+
throw new Error("No LLM client is configured.");
47+
}
48+
4049
const userMessage: Message = {
4150
role: "user",
4251
content: [{ type: "text", text: input }],

src/agent/factory.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ type CreateAgentOptions = {
1414
};
1515

1616
export async function createAgent(
17-
llmClient: LlmClient,
17+
llmClient: LlmClient | null,
1818
options: CreateAgentOptions = {},
1919
): Promise<Agent> {
2020
const toolDir = await createToolDir(options.toolDir);

src/index.ts

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

1110
await initColorScheme();
1211

1312
const agent =
1413
process.env.DEMO === "1"
1514
? await createDemoAgent()
16-
: await createAgent(await resolveLlmClientInteractive());
15+
: await createAgent(resolveLlmClient(systemPrompt));
1716

18-
const { waitUntilExit } = renderFullscreen(createElement(App, { agent }));
17+
const { waitUntilExit } = renderFullscreen(createElement(App, { agent, attachApiKey }));
1918
await waitUntilExit();
2019

21-
async function resolveLlmClientInteractive() {
22-
const configured = resolveLlmClient(systemPrompt);
23-
if (configured) return configured;
24-
25-
const key = await promptForApiKey();
26-
process.env.ANTHROPIC_API_KEY = key;
27-
28-
const client = resolveLlmClient(systemPrompt);
29-
if (client) return client;
30-
31-
console.error("Error: could not initialise an LLM client from the provided key.");
32-
process.exit(1);
33-
}
34-
35-
function promptForApiKey(): Promise<string> {
36-
return new Promise((resolve) => {
37-
let submitted = false;
38-
const { unmount, waitUntilExit } = renderFullscreen(
39-
createElement(ApiKeyPrompt, {
40-
onSubmit: (key: string) => {
41-
submitted = true;
42-
unmount();
43-
resolve(key);
44-
},
45-
}),
46-
);
47-
void waitUntilExit().then(() => {
48-
if (!submitted) {
49-
process.exit(0);
50-
}
51-
});
52-
});
20+
function attachApiKey(apiKey: string) {
21+
const client = resolveLlmClient(systemPrompt, { ...process.env, ANTHROPIC_API_KEY: apiKey });
22+
if (!client) return null;
23+
agent.setClient(client);
24+
return agent.modelInfo();
5325
}
5426

5527
/*

src/tui/App.tsx

Lines changed: 55 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@ import { defaultTheme, extendTheme, Spinner, TextInput, ThemeProvider } from "@i
33
import { Box, Text, useApp, useInput, useStdout } from "ink";
44
import { ScrollView, type ScrollViewRef } from "ink-scroll-view";
55
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
6-
import type { Agent } from "#/agent";
6+
import type { Agent, ModelInfo } from "#/agent";
77
import { isLightScheme } from "./color-scheme.ts";
88
import { type Command, parseCommand } from "./commands.ts";
9+
import { ApiKeyPrompt } from "./components/ApiKeyPrompt.tsx";
910
import { AssistantMessage } from "./components/AssistantMessage.tsx";
1011
import { Banner } from "./components/Banner.tsx";
1112
import { ErrorMessage } from "./components/ErrorMessage.tsx";
@@ -37,9 +38,10 @@ type OverlayState = {
3738

3839
type AppProps = {
3940
agent: Agent;
41+
attachApiKey: (apiKey: string) => ModelInfo | null;
4042
};
4143

42-
export function App({ agent }: AppProps) {
44+
export function App({ agent, attachApiKey }: AppProps) {
4345
const { exit } = useApp();
4446
const { stdout } = useStdout();
4547
const [rows, setRows] = useState(stdout?.rows ?? 24);
@@ -51,6 +53,11 @@ export function App({ agent }: AppProps) {
5153
const [elapsedMs, setElapsedMs] = useState<number | null>(null);
5254
const [working, setWorking] = useState(false);
5355

56+
const [modelInfo, setModelInfo] = useState(agent.modelInfo());
57+
const [apiKeyError, setApiKeyError] = useState<string>();
58+
const [apiKeyAttempt, setApiKeyAttempt] = useState(0);
59+
const needsApiKey = modelInfo === null;
60+
5461
const scrollRef = useRef<ScrollViewRef>(null);
5562
const [scrollOffset, setScrollOffset] = useState(0);
5663
const [contentHeight, setContentHeight] = useState(0);
@@ -126,24 +133,41 @@ export function App({ agent }: AppProps) {
126133
case "tools_list":
127134
setOverlay(toolsOverlay());
128135
return;
136+
129137
case "tools_remove":
130138
await agent.removeTool(command.name);
131139
appendSystemMessage(`Removed tool '${command.name}'`);
132140
setOverlay((prev) => (prev?.kind === "tools" ? toolsOverlay() : prev));
133141
return;
142+
134143
case "clear":
135144
await agent.clear();
136145
setTranscript([]);
137146
setShowIntro(true);
138147
setOverlay(null);
139148
setElapsedMs(null);
140149
return;
150+
141151
case "exit":
142152
exit();
143153
return;
144154
}
145155
};
146156

157+
const handleApiKeySubmit = (value: string) => {
158+
const apiKey = value.trim();
159+
if (apiKey === "") return;
160+
161+
const modelInfo = attachApiKey(apiKey);
162+
if (!modelInfo) {
163+
setApiKeyError("Could not initialise an LLM client from that key.");
164+
setApiKeyAttempt((attempt) => attempt + 1);
165+
return;
166+
}
167+
168+
setModelInfo(modelInfo);
169+
};
170+
147171
const handleSubmit = async (input: string) => {
148172
if (input.trim() === "") return;
149173
setTextInputKey((k) => k + 1);
@@ -208,7 +232,15 @@ export function App({ agent }: AppProps) {
208232
>
209233
<Box key="header" flexDirection="column" paddingTop={1} gap={1}>
210234
<Banner />
211-
{showIntro && <IntroMessage />}
235+
{needsApiKey ? (
236+
<ApiKeyPrompt
237+
key={apiKeyAttempt}
238+
error={apiKeyError}
239+
onSubmit={handleApiKeySubmit}
240+
/>
241+
) : (
242+
showIntro && <IntroMessage />
243+
)}
212244
</Box>
213245
{transcript.map((item) => (
214246
<Box key={`${item.kind}-${item.id}`} marginTop={1}>
@@ -248,25 +280,27 @@ export function App({ agent }: AppProps) {
248280
)}
249281
</Box>
250282
{overlay && <Overlay title={overlay.title}>{overlay.content}</Overlay>}
251-
<Box
252-
paddingX={1}
253-
borderStyle="round"
254-
borderColor={theme.accent}
255-
borderDimColor={!isLightScheme()}
256-
>
257-
<Text color={theme.accent} bold>
258-
{" "}
259-
</Text>
260-
<Box flexGrow={1}>
261-
<TextInput
262-
key={textInputKey}
263-
isDisabled={working}
264-
placeholder="Type a message (Ctrl+C or /exit to quit)"
265-
onSubmit={handleSubmit}
266-
/>
283+
{!needsApiKey && (
284+
<Box
285+
paddingX={1}
286+
borderStyle="round"
287+
borderColor={theme.accent}
288+
borderDimColor={!isLightScheme()}
289+
>
290+
<Text color={theme.accent} bold>
291+
{" "}
292+
</Text>
293+
<Box flexGrow={1}>
294+
<TextInput
295+
key={textInputKey}
296+
isDisabled={working}
297+
placeholder="Type a message (Ctrl+C or /exit to quit)"
298+
onSubmit={handleSubmit}
299+
/>
300+
</Box>
267301
</Box>
268-
</Box>
269-
<StatusBar {...agent.modelInfo()} />
302+
)}
303+
{modelInfo && <StatusBar {...modelInfo} />}
270304
</Box>
271305
</Box>
272306
</ThemeProvider>
Lines changed: 10 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,22 @@
11
import { PasswordInput } from "@inkjs/ui";
22
import { Box, Text } from "ink";
3-
import pkg from "../../../package.json" with { type: "json" };
43
import { isLightScheme } from "../color-scheme.ts";
54
import { theme } from "../theme.ts";
5+
import { ErrorMessage } from "./ErrorMessage.tsx";
66

77
type ApiKeyPromptProps = {
8-
onSubmit: (key: string) => void;
8+
error?: string;
9+
onSubmit: (apiKey: string) => void;
910
};
1011

11-
export function ApiKeyPrompt({ onSubmit }: ApiKeyPromptProps) {
12-
const handleSubmit = (value: string) => {
13-
const key = value.trim();
14-
if (key === "") return;
15-
onSubmit(key);
16-
};
17-
12+
export function ApiKeyPrompt({ error, onSubmit }: ApiKeyPromptProps) {
1813
return (
1914
<Box flexDirection="column" gap={1}>
20-
<Box flexDirection="column" paddingX={1} gap={1}>
21-
<Text>
22-
<Text bold color={theme.accent}>
23-
Agent Toolsmith
24-
</Text>
25-
<Text dimColor> v{pkg.version}</Text>
26-
</Text>
15+
<Box>
2716
<Text>
28-
<Text dimColor>
29-
No LLM provider is configured. Paste an Anthropic API key to continue, or press{" "}
30-
</Text>
31-
<Text color={theme.accent}>Ctrl+C</Text>
32-
<Text dimColor> to quit.</Text>
17+
<Text dimColor>No LLM provider is configured. Paste an </Text>
18+
<Text color={theme.accent}>Anthropic API key</Text>
19+
<Text dimColor> to continue, or press Ctrl+C to quit.</Text>
3320
</Text>
3421
</Box>
3522
<Box
@@ -38,10 +25,9 @@ export function ApiKeyPrompt({ onSubmit }: ApiKeyPromptProps) {
3825
borderColor={theme.accent}
3926
borderDimColor={!isLightScheme()}
4027
>
41-
<Box flexGrow={1}>
42-
<PasswordInput placeholder="sk-ant-..." onSubmit={handleSubmit} />
43-
</Box>
28+
<PasswordInput placeholder="sk-ant-..." onSubmit={onSubmit} />
4429
</Box>
30+
{error && <ErrorMessage content={error} />}
4531
</Box>
4632
);
4733
}

tests/adapters/llm/resolve.test.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,15 @@ import { resolveLlmClient } from "#/adapters/llm/index.ts";
33

44
describe("LLM client resolution", () => {
55
test("returns an Anthropic client when ANTHROPIC_API_KEY is set", () => {
6-
const client = resolveLlmClient(undefined, { ANTHROPIC_API_KEY: "test-key" });
6+
const client = resolveLlmClient(undefined, { ANTHROPIC_API_KEY: "sk-ant-test" });
77

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

1212
test("honours the MODEL override", () => {
1313
const client = resolveLlmClient(undefined, {
14-
ANTHROPIC_API_KEY: "test-key",
14+
ANTHROPIC_API_KEY: "sk-ant-test",
1515
MODEL: "claude-opus-4-8",
1616
});
1717

@@ -21,4 +21,17 @@ describe("LLM client resolution", () => {
2121
test("returns null when no provider is configured", () => {
2222
expect(resolveLlmClient(undefined, {})).toBeNull();
2323
});
24+
25+
test("returns null for a key that is not an Anthropic key", () => {
26+
expect(resolveLlmClient(undefined, { ANTHROPIC_API_KEY: "not-a-key" })).toBeNull();
27+
});
28+
29+
test("accepts an unprefixed key when a custom base URL is set", () => {
30+
const client = resolveLlmClient(undefined, {
31+
ANTHROPIC_API_KEY: "gateway-token",
32+
ANTHROPIC_BASE_URL: "https://gateway.internal/v1",
33+
});
34+
35+
expect(client?.provider).toBe("anthropic");
36+
});
2437
});

0 commit comments

Comments
 (0)