Skip to content

Commit 5fec5f1

Browse files
icehaunterclaude
andauthored
fix(electric-ax): friendly prompt when ANTHROPIC_API_KEY is missing (#4228)
## Summary - `agents quickstart` and `agents start-builtin` no longer fail with a bare `Fatal: ANTHROPIC_API_KEY is required` when the key is missing. They now show a short explainer (LLM provider key, never leaves the local machine, single-provider preview) and prompt the developer to either set up `.env` themselves or paste the key once so the CLI writes `.env` for them. - New `ensureAnthropicApiKey` helper in `packages/electric-ax/src/prompt-api-key.ts` that wraps the existing `resolveAnthropicApiKey`. Non-TTY runs (CI, pipes) still fail fast with the original error so scripts don't deadlock on a prompt. - Pasted keys are written to `.env` safely: replaces an empty `ANTHROPIC_API_KEY=` line if present, otherwise appends. Existing keys are left untouched. ## Test plan - [x] `pnpm test` in `packages/electric-ax` (68 tests, including 8 new ones for the prompt module) - [x] `pnpm typecheck` and `pnpm stylecheck` clean - [x] `pnpm build` clean - [ ] Manual smoke test: run `npx electric-ax agents quickstart` in a TTY with no key set and walk through both option 1 (manual) and option 2 (paste). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 119731e commit 5fec5f1

5 files changed

Lines changed: 320 additions & 3 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"electric-ax": patch
3+
---
4+
5+
Replace the abrupt `ANTHROPIC_API_KEY is required` fatal error in `agents quickstart` and `agents start-builtin` with a friendly interactive prompt that explains how the key is used (it never leaves the local machine) and lets the user choose between setting up `.env` manually or pasting the key once to have the CLI write `.env` for them. Non-interactive runs still fail fast with the original error.

‎packages/agents/skills/quickstart/scaffold-ui/index.html‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<!DOCTYPE html>
1+
<!doctype html>
22
<html lang="en">
33
<head>
44
<meta charset="UTF-8" />

‎packages/electric-ax/src/index.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { basename, resolve as resolvePath } from 'node:path'
66
import { fileURLToPath } from 'node:url'
77
import { Command } from 'commander'
88
import { installCompletions, setupCompletions } from './completions.js'
9-
import { resolveAnthropicApiKey } from './env.js'
9+
import { ensureAnthropicApiKey } from './prompt-api-key.js'
1010
import type {
1111
ElectricAgentsEntityRow,
1212
ElectricAgentsEntityType,
@@ -504,6 +504,7 @@ export function createElectricCliHandlers(
504504
return started
505505
},
506506
startBuiltin: async (options) => {
507+
options.anthropicApiKey = await ensureAnthropicApiKey(options)
507508
const { startBuiltinAgentsServer } = await loadStartModule()
508509
return startBuiltinAgentsServer(options, {
509510
agentServerUrl: env.electricAgentsUrl,
@@ -516,7 +517,7 @@ export function createElectricCliHandlers(
516517
return stopped
517518
},
518519
quickstart: async (options) => {
519-
resolveAnthropicApiKey(options)
520+
options.anthropicApiKey = await ensureAnthropicApiKey(options)
520521
const { startBuiltinAgentsServer, startElectricAgentsDevEnvironment } =
521522
await loadStartModule()
522523
const started = await startElectricAgentsDevEnvironment()
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
2+
import { resolve as resolvePath } from 'node:path'
3+
import { createInterface } from 'node:readline/promises'
4+
import { resolveAnthropicApiKey } from './env.js'
5+
6+
interface AnthropicApiKeyOptions {
7+
anthropicApiKey?: string
8+
}
9+
10+
export interface PromptIO {
11+
input: NodeJS.ReadableStream
12+
output: NodeJS.WritableStream
13+
isTTY: boolean
14+
cwd: string
15+
exit: (code: number) => never
16+
}
17+
18+
const FRIENDLY_INTRO = [
19+
``,
20+
`The Electric Agents quickstart requires setting an API key to connect to an LLM provider.`,
21+
``,
22+
`Currently (in this initial developer preview release) the key must be an ANTHROPIC_API_KEY.`,
23+
`Support for other LLM providers is coming very soon.`,
24+
``,
25+
`Note that your API key never leaves your local computer. It's used by the agent entities`,
26+
`installed by default in the Electric Agents runtime and by the example agents included`,
27+
`in the quickstart template.`,
28+
``,
29+
`Would you like to:`,
30+
``,
31+
` 1. manually setup a .env file with ANTHROPIC_API_KEY=...`,
32+
` 2. paste your api key into a prompt and we'll set it up for you`,
33+
``,
34+
].join(`\n`)
35+
36+
const MANUAL_SETUP_INSTRUCTIONS = [
37+
``,
38+
`No problem. To finish setup:`,
39+
``,
40+
` 1. Get a key from https://console.anthropic.com/settings/keys`,
41+
` 2. Add a line to your .env file:`,
42+
` ANTHROPIC_API_KEY=sk-ant-...`,
43+
` 3. Re-run the command.`,
44+
``,
45+
].join(`\n`)
46+
47+
function defaultPromptIO(): PromptIO {
48+
return {
49+
input: process.stdin,
50+
output: process.stdout,
51+
isTTY: Boolean(process.stdin.isTTY && process.stdout.isTTY),
52+
cwd: process.cwd(),
53+
exit: process.exit.bind(process) as (code: number) => never,
54+
}
55+
}
56+
57+
export async function ensureAnthropicApiKey(
58+
options: AnthropicApiKeyOptions,
59+
io: PromptIO = defaultPromptIO()
60+
): Promise<string> {
61+
try {
62+
return resolveAnthropicApiKey(options)
63+
} catch (error) {
64+
if (!io.isTTY) {
65+
throw error
66+
}
67+
}
68+
69+
io.output.write(FRIENDLY_INTRO)
70+
71+
const rl = createInterface({ input: io.input, output: io.output })
72+
try {
73+
const choice = (await rl.question(`Enter 1/2: `)).trim()
74+
75+
if (choice === `1`) {
76+
io.output.write(MANUAL_SETUP_INSTRUCTIONS)
77+
io.exit(0)
78+
throw new Error(`unreachable`)
79+
}
80+
81+
if (choice === `2`) {
82+
const key = (await rl.question(`Paste your ANTHROPIC_API_KEY: `)).trim()
83+
if (!key) {
84+
throw new Error(
85+
`No API key provided. Re-run the command and try again.`
86+
)
87+
}
88+
const envPath = writeApiKeyToDotEnv(key, io.cwd)
89+
process.env.ANTHROPIC_API_KEY = key
90+
io.output.write(`\nSaved ANTHROPIC_API_KEY to ${envPath}\n\n`)
91+
return key
92+
}
93+
94+
throw new Error(
95+
`Unrecognized choice ${JSON.stringify(choice)}. Please re-run the command and enter 1 or 2.`
96+
)
97+
} finally {
98+
rl.close()
99+
}
100+
}
101+
102+
function writeApiKeyToDotEnv(key: string, cwd: string): string {
103+
const envPath = resolvePath(cwd, `.env`)
104+
const newLine = `ANTHROPIC_API_KEY=${key}`
105+
106+
if (!existsSync(envPath)) {
107+
writeFileSync(envPath, `${newLine}\n`, `utf8`)
108+
return envPath
109+
}
110+
111+
const existing = readFileSync(envPath, `utf8`)
112+
const replaced = existing.replace(/^ANTHROPIC_API_KEY=.*$/m, newLine)
113+
if (replaced !== existing) {
114+
writeFileSync(envPath, replaced, `utf8`)
115+
return envPath
116+
}
117+
118+
const separator = existing.length === 0 || existing.endsWith(`\n`) ? `` : `\n`
119+
writeFileSync(envPath, `${existing}${separator}${newLine}\n`, `utf8`)
120+
return envPath
121+
}
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import { afterEach, describe, expect, it } from 'vitest'
2+
import {
3+
mkdtempSync,
4+
readFileSync,
5+
rmSync,
6+
writeFileSync,
7+
existsSync,
8+
} from 'node:fs'
9+
import { tmpdir } from 'node:os'
10+
import { join } from 'node:path'
11+
import { PassThrough } from 'node:stream'
12+
import { ensureAnthropicApiKey, type PromptIO } from '../src/prompt-api-key'
13+
14+
interface FakeIO {
15+
io: PromptIO
16+
output: () => string
17+
cwd: string
18+
exitCalls: Array<number>
19+
cleanup: () => void
20+
}
21+
22+
function createFakeIO({
23+
inputLines,
24+
isTTY = true,
25+
}: {
26+
inputLines: Array<string>
27+
isTTY?: boolean
28+
}): FakeIO {
29+
const cwd = mkdtempSync(join(tmpdir(), `electric-ax-prompt-`))
30+
const input = new PassThrough()
31+
const output = new PassThrough()
32+
let outputBuffer = ``
33+
let promptsSeen = 0
34+
let linesFed = 0
35+
const queue = [...inputLines]
36+
37+
output.on(`data`, (chunk: Buffer) => {
38+
const text = chunk.toString(`utf8`)
39+
outputBuffer += text
40+
for (const _match of text.matchAll(/: $/gm)) {
41+
void _match
42+
promptsSeen += 1
43+
}
44+
while (linesFed < promptsSeen && linesFed < queue.length) {
45+
const line = queue[linesFed]!
46+
linesFed += 1
47+
setImmediate(() => input.write(`${line}\n`))
48+
}
49+
})
50+
51+
const exitCalls: Array<number> = []
52+
const exit = ((code: number) => {
53+
exitCalls.push(code)
54+
throw new Error(`__exit__:${code}`)
55+
}) as (code: number) => never
56+
57+
return {
58+
io: {
59+
input,
60+
output,
61+
isTTY,
62+
cwd,
63+
exit,
64+
},
65+
output: () => outputBuffer,
66+
cwd,
67+
exitCalls,
68+
cleanup: () => rmSync(cwd, { recursive: true, force: true }),
69+
}
70+
}
71+
72+
describe(`ensureAnthropicApiKey`, () => {
73+
let cleanups: Array<() => void> = []
74+
const originalApiKey = process.env.ANTHROPIC_API_KEY
75+
76+
afterEach(() => {
77+
for (const cleanup of cleanups) cleanup()
78+
cleanups = []
79+
if (originalApiKey === undefined) {
80+
delete process.env.ANTHROPIC_API_KEY
81+
} else {
82+
process.env.ANTHROPIC_API_KEY = originalApiKey
83+
}
84+
})
85+
86+
it(`returns the explicit option key without prompting`, async () => {
87+
const fake = createFakeIO({ inputLines: [] })
88+
cleanups.push(fake.cleanup)
89+
90+
const key = await ensureAnthropicApiKey(
91+
{ anthropicApiKey: `sk-ant-explicit` },
92+
fake.io
93+
)
94+
95+
expect(key).toBe(`sk-ant-explicit`)
96+
expect(fake.output()).toBe(``)
97+
})
98+
99+
it(`rethrows the original error when stdin is not a TTY`, async () => {
100+
delete process.env.ANTHROPIC_API_KEY
101+
const fake = createFakeIO({ inputLines: [], isTTY: false })
102+
cleanups.push(fake.cleanup)
103+
104+
await expect(ensureAnthropicApiKey({}, fake.io)).rejects.toThrow(
105+
/ANTHROPIC_API_KEY/
106+
)
107+
expect(fake.output()).toBe(``)
108+
})
109+
110+
it(`exits cleanly when the user picks manual setup (1)`, async () => {
111+
delete process.env.ANTHROPIC_API_KEY
112+
const fake = createFakeIO({ inputLines: [`1`] })
113+
cleanups.push(fake.cleanup)
114+
115+
await expect(ensureAnthropicApiKey({}, fake.io)).rejects.toThrow(
116+
`__exit__:0`
117+
)
118+
119+
expect(fake.exitCalls).toEqual([0])
120+
expect(fake.output()).toContain(`never leaves your local computer`)
121+
expect(fake.output()).toContain(`Get a key from`)
122+
expect(existsSync(join(fake.cwd, `.env`))).toBe(false)
123+
})
124+
125+
it(`writes the pasted key to a new .env when the user picks 2`, async () => {
126+
delete process.env.ANTHROPIC_API_KEY
127+
const fake = createFakeIO({ inputLines: [`2`, `sk-ant-pasted`] })
128+
cleanups.push(fake.cleanup)
129+
130+
const key = await ensureAnthropicApiKey({}, fake.io)
131+
132+
expect(key).toBe(`sk-ant-pasted`)
133+
expect(process.env.ANTHROPIC_API_KEY).toBe(`sk-ant-pasted`)
134+
135+
const envContent = readFileSync(join(fake.cwd, `.env`), `utf8`)
136+
expect(envContent).toBe(`ANTHROPIC_API_KEY=sk-ant-pasted\n`)
137+
})
138+
139+
it(`appends the key to an existing .env without an ANTHROPIC_API_KEY entry`, async () => {
140+
delete process.env.ANTHROPIC_API_KEY
141+
const fake = createFakeIO({ inputLines: [`2`, `sk-ant-new`] })
142+
cleanups.push(fake.cleanup)
143+
144+
writeFileSync(join(fake.cwd, `.env`), `OTHER=value\n`, `utf8`)
145+
146+
await ensureAnthropicApiKey({}, fake.io)
147+
148+
const envContent = readFileSync(join(fake.cwd, `.env`), `utf8`)
149+
expect(envContent).toBe(`OTHER=value\nANTHROPIC_API_KEY=sk-ant-new\n`)
150+
})
151+
152+
it(`replaces an existing empty ANTHROPIC_API_KEY line in .env`, async () => {
153+
delete process.env.ANTHROPIC_API_KEY
154+
const fake = createFakeIO({ inputLines: [`2`, `sk-ant-fresh`] })
155+
cleanups.push(fake.cleanup)
156+
157+
writeFileSync(
158+
join(fake.cwd, `.env`),
159+
`OTHER=value\nANTHROPIC_API_KEY=\nANOTHER=foo\n`,
160+
`utf8`
161+
)
162+
163+
await ensureAnthropicApiKey({}, fake.io)
164+
165+
const envContent = readFileSync(join(fake.cwd, `.env`), `utf8`)
166+
expect(envContent).toBe(
167+
`OTHER=value\nANTHROPIC_API_KEY=sk-ant-fresh\nANOTHER=foo\n`
168+
)
169+
})
170+
171+
it(`rejects an empty pasted key`, async () => {
172+
delete process.env.ANTHROPIC_API_KEY
173+
const fake = createFakeIO({ inputLines: [`2`, ``] })
174+
cleanups.push(fake.cleanup)
175+
176+
await expect(ensureAnthropicApiKey({}, fake.io)).rejects.toThrow(
177+
/No API key provided/
178+
)
179+
})
180+
181+
it(`rejects an unrecognized choice`, async () => {
182+
delete process.env.ANTHROPIC_API_KEY
183+
const fake = createFakeIO({ inputLines: [`bogus`] })
184+
cleanups.push(fake.cleanup)
185+
186+
await expect(ensureAnthropicApiKey({}, fake.io)).rejects.toThrow(
187+
/Unrecognized choice/
188+
)
189+
})
190+
})

0 commit comments

Comments
 (0)