@kidsinai/kids-client 0.0.3 → 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/core/env-reload.ts +26 -0
- package/src/core/env.ts +4 -1
- package/src/core/opencode-installer.ts +82 -0
- package/src/core/setup.ts +62 -5
- package/src/index.tsx +231 -193
- package/src/render/ink/App.tsx +3 -1
- package/src/render/ink/screens/SetupScreen.tsx +334 -16
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "@kidsinai/kids-client",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.5",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Own-client TUI for Kids OpenCode — talks to local `opencode serve` via @opencode-ai/sdk v2 with kid-warm rendering, mission progress, permission dialog, and stderr-tail audit pipeline.",
|
|
7
7
|
"license": "MIT",
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-read ~/.config/kids-opencode/env after the setup wizard saves it,
|
|
3
|
+
* and inject the values into process.env. This lets the SAME process
|
|
4
|
+
* continue with the new LLM key — no `kids-opencode` re-run needed.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readFileSync, existsSync } from "node:fs"
|
|
8
|
+
import { join } from "node:path"
|
|
9
|
+
|
|
10
|
+
export function reloadEnvFile(configDir: string): Record<string, string> {
|
|
11
|
+
const path = join(configDir, "env")
|
|
12
|
+
if (!existsSync(path)) return {}
|
|
13
|
+
const out: Record<string, string> = {}
|
|
14
|
+
for (const raw of readFileSync(path, "utf8").split("\n")) {
|
|
15
|
+
const line = raw.trim()
|
|
16
|
+
if (!line || line.startsWith("#")) continue
|
|
17
|
+
const eq = line.indexOf("=")
|
|
18
|
+
if (eq <= 0) continue
|
|
19
|
+
const key = line.slice(0, eq).trim()
|
|
20
|
+
let value = line.slice(eq + 1).trim()
|
|
21
|
+
if (value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1)
|
|
22
|
+
out[key] = value
|
|
23
|
+
process.env[key] = value
|
|
24
|
+
}
|
|
25
|
+
return out
|
|
26
|
+
}
|
package/src/core/env.ts
CHANGED
|
@@ -60,11 +60,14 @@ export function validateEnv(env: KidsClientEnv): { ok: true } | { ok: false; rea
|
|
|
60
60
|
}
|
|
61
61
|
// Accept any supported provider's API key, not just DeepRouter. The
|
|
62
62
|
// setup wizard writes whatever the parent picked into ~/.config/kids-opencode/env
|
|
63
|
-
// which the wrapper sources before exec.
|
|
63
|
+
// which the wrapper sources before exec. KIDS_OAUTH_PROVIDER marks an
|
|
64
|
+
// OAuth flow that opencode handles via its own auth.json store —
|
|
65
|
+
// we trust opencode to gate on actual token validity at serve time.
|
|
64
66
|
const hasAnyKey =
|
|
65
67
|
env.deeprouterApiKey
|
|
66
68
|
|| process.env.ANTHROPIC_API_KEY
|
|
67
69
|
|| process.env.OPENAI_API_KEY
|
|
70
|
+
|| process.env.KIDS_OAUTH_PROVIDER
|
|
68
71
|
if (!env.bypassGateway && !hasAnyKey) {
|
|
69
72
|
return {
|
|
70
73
|
ok: false,
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-TUI installer for the upstream `opencode` CLI binary.
|
|
3
|
+
*
|
|
4
|
+
* The kid (or their parent) should never have to drop back to a shell
|
|
5
|
+
* prompt and paste a `curl ... | sh` line. If the AI engine isn't
|
|
6
|
+
* installed when the wizard starts, we run the installer ourselves and
|
|
7
|
+
* stream its progress to the SetupScreen.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { spawn } from "node:child_process"
|
|
11
|
+
import { existsSync } from "node:fs"
|
|
12
|
+
import { homedir } from "node:os"
|
|
13
|
+
import { join } from "node:path"
|
|
14
|
+
|
|
15
|
+
/** True if the upstream opencode binary is on PATH or in its standard install location. */
|
|
16
|
+
export function hasOpencodeBinary(): boolean {
|
|
17
|
+
// PATH lookup (POSIX). `which` and `command -v` aren't reliable as
|
|
18
|
+
// child_process commands without a shell, so just check $PATH dirs.
|
|
19
|
+
const pathDirs = (process.env.PATH ?? "").split(":")
|
|
20
|
+
const candidates = [
|
|
21
|
+
...pathDirs.map((d) => join(d, "opencode")),
|
|
22
|
+
join(homedir(), ".opencode", "bin", "opencode"),
|
|
23
|
+
"/usr/local/bin/opencode",
|
|
24
|
+
"/opt/homebrew/bin/opencode",
|
|
25
|
+
]
|
|
26
|
+
return candidates.some((p) => existsSync(p))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface InstallResult {
|
|
30
|
+
ok: boolean
|
|
31
|
+
error?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Run the upstream installer in a subprocess, streaming each output line
|
|
36
|
+
* to `onProgress`. Resolves when the subprocess exits.
|
|
37
|
+
*
|
|
38
|
+
* Uses /bin/sh + curl pipe to match upstream's official install command.
|
|
39
|
+
* The whole thing typically takes 15-45 seconds depending on network.
|
|
40
|
+
*/
|
|
41
|
+
export function installOpencode(onProgress: (line: string) => void): Promise<InstallResult> {
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
const child = spawn("sh", ["-c", "curl -fsSL https://opencode.ai/install | sh"], {
|
|
44
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
45
|
+
env: { ...process.env },
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const handleStream = (stream: NodeJS.ReadableStream): void => {
|
|
49
|
+
let buf = ""
|
|
50
|
+
stream.on("data", (chunk: Buffer | string) => {
|
|
51
|
+
buf += chunk.toString()
|
|
52
|
+
let nl: number
|
|
53
|
+
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
54
|
+
const line = buf.slice(0, nl)
|
|
55
|
+
buf = buf.slice(nl + 1)
|
|
56
|
+
const trimmed = line.replace(/\r/g, "").trim()
|
|
57
|
+
if (trimmed) onProgress(trimmed)
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
if (child.stdout) handleStream(child.stdout)
|
|
62
|
+
if (child.stderr) handleStream(child.stderr)
|
|
63
|
+
|
|
64
|
+
child.on("error", (err) => {
|
|
65
|
+
resolve({ ok: false, error: err.message })
|
|
66
|
+
})
|
|
67
|
+
child.on("close", (code) => {
|
|
68
|
+
if (code === 0) {
|
|
69
|
+
// Make sure the new bin dir is on PATH for the remainder of THIS run,
|
|
70
|
+
// so subsequent calls (postinstall plugin registration etc.) can find
|
|
71
|
+
// opencode without waiting for a shell restart.
|
|
72
|
+
const newBin = join(homedir(), ".opencode", "bin")
|
|
73
|
+
if (!process.env.PATH?.includes(newBin)) {
|
|
74
|
+
process.env.PATH = `${newBin}:${process.env.PATH ?? ""}`
|
|
75
|
+
}
|
|
76
|
+
resolve({ ok: true })
|
|
77
|
+
} else {
|
|
78
|
+
resolve({ ok: false, error: `installer exited with code ${code}` })
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
}
|
package/src/core/setup.ts
CHANGED
|
@@ -16,6 +16,17 @@ import { dirname, join } from "node:path"
|
|
|
16
16
|
|
|
17
17
|
export type ProviderId = "anthropic" | "openai" | "deeprouter"
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Wire protocol between SetupScreen and bin/kids-opencode wrapper:
|
|
21
|
+
* kids-client exits with this code to ask the wrapper to run
|
|
22
|
+
* `opencode auth login --provider <p>` (interactive OAuth that needs the
|
|
23
|
+
* TTY), then re-exec kids-client. See bin/kids-opencode §11 loop.
|
|
24
|
+
*/
|
|
25
|
+
export const OAUTH_HANDOFF_EXIT_CODE = 123
|
|
26
|
+
|
|
27
|
+
/** Providers that support OAuth login via the upstream opencode kernel. */
|
|
28
|
+
export const OAUTH_PROVIDERS: ReadonlyArray<ProviderId> = ["anthropic"] as const
|
|
29
|
+
|
|
19
30
|
export interface ProviderChoice {
|
|
20
31
|
id: ProviderId
|
|
21
32
|
label: string
|
|
@@ -53,10 +64,10 @@ export const PROVIDERS: ProviderChoice[] = [
|
|
|
53
64
|
},
|
|
54
65
|
{
|
|
55
66
|
id: "deeprouter",
|
|
56
|
-
label: "DeepRouter (
|
|
57
|
-
hint: "
|
|
67
|
+
label: "DeepRouter (OpenAI-compatible gateway)",
|
|
68
|
+
hint: "Cheaper than going direct + one key for all models (Anthropic, OpenAI, Google). Built-in kid-safe filters (NSFW + prompt-injection guard). Limited beta — invite-only.",
|
|
58
69
|
envVar: "DEEPROUTER_API_KEY",
|
|
59
|
-
apiKeyUrl: "https://
|
|
70
|
+
apiKeyUrl: "https://deeprouter.ai/",
|
|
60
71
|
config: (env) => ({
|
|
61
72
|
deeprouter: {
|
|
62
73
|
type: "openai-compatible",
|
|
@@ -93,12 +104,54 @@ export function saveSetup(opts: SaveOptions): void {
|
|
|
93
104
|
const envPath = join(opts.configDir, "env")
|
|
94
105
|
const existing = readEnvFile(envPath)
|
|
95
106
|
existing[provider.envVar] = opts.apiKey
|
|
107
|
+
// If the user previously chose OAuth and is now switching to API key, drop
|
|
108
|
+
// the marker so validateEnv doesn't keep routing through OAuth handoff.
|
|
109
|
+
delete existing.KIDS_OAUTH_PROVIDER
|
|
96
110
|
writeEnvFile(envPath, existing)
|
|
97
111
|
|
|
98
112
|
// 2. Rewrite opencode.json provider section.
|
|
99
|
-
|
|
113
|
+
writeOpencodeConfig(opts.configDir, provider, { withApiKey: true })
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface SaveOauthOptions {
|
|
117
|
+
configDir: string
|
|
118
|
+
provider: ProviderId
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Stage the OAuth handoff. We write opencode.json without an apiKey block
|
|
123
|
+
* (opencode reads OAuth tokens from its own auth.json), drop any stale
|
|
124
|
+
* provider API keys, and write a KIDS_OAUTH_PROVIDER marker so the
|
|
125
|
+
* wrapper knows which provider to log into. The actual
|
|
126
|
+
* `opencode auth login --provider <p>` invocation happens in
|
|
127
|
+
* bin/kids-opencode after kids-client exits with OAUTH_HANDOFF_EXIT_CODE.
|
|
128
|
+
*/
|
|
129
|
+
export function saveSetupOauth(opts: SaveOauthOptions): void {
|
|
130
|
+
const provider = findProvider(opts.provider)
|
|
131
|
+
ensureConfigDir(opts.configDir)
|
|
132
|
+
|
|
133
|
+
// 1. Update env file: clear this provider's API key (avoid stale leakage),
|
|
134
|
+
// set marker pointing at the OAuth provider.
|
|
135
|
+
const envPath = join(opts.configDir, "env")
|
|
136
|
+
const existing = readEnvFile(envPath)
|
|
137
|
+
delete existing[provider.envVar]
|
|
138
|
+
existing.KIDS_OAUTH_PROVIDER = opts.provider
|
|
139
|
+
writeEnvFile(envPath, existing)
|
|
140
|
+
|
|
141
|
+
// 2. opencode.json without apiKey — opencode uses its auth.json store.
|
|
142
|
+
writeOpencodeConfig(opts.configDir, provider, { withApiKey: false })
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function writeOpencodeConfig(
|
|
146
|
+
configDir: string,
|
|
147
|
+
provider: ProviderChoice,
|
|
148
|
+
flags: { withApiKey: boolean },
|
|
149
|
+
): void {
|
|
150
|
+
const configPath = join(configDir, "opencode.json")
|
|
100
151
|
const config = readJsonOrEmpty(configPath)
|
|
101
|
-
config.provider =
|
|
152
|
+
config.provider = flags.withApiKey
|
|
153
|
+
? provider.config(provider.envVar)
|
|
154
|
+
: { [provider.id]: {} }
|
|
102
155
|
config.model = provider.defaultModel
|
|
103
156
|
if (!config.permission) {
|
|
104
157
|
config.permission = {
|
|
@@ -120,11 +173,15 @@ export function saveSetup(opts: SaveOptions): void {
|
|
|
120
173
|
export function hasAnyProviderKey(configDir: string): boolean {
|
|
121
174
|
const env = readEnvFile(join(configDir, "env"))
|
|
122
175
|
if (env.ANTHROPIC_API_KEY || env.OPENAI_API_KEY || env.DEEPROUTER_API_KEY) return true
|
|
176
|
+
// OAuth handoff completed earlier? The marker means opencode has its own
|
|
177
|
+
// auth.json credentials; opencode itself will gate on actual token validity.
|
|
178
|
+
if (env.KIDS_OAUTH_PROVIDER) return true
|
|
123
179
|
// Also accept keys present in the parent shell env (advanced users).
|
|
124
180
|
return !!(
|
|
125
181
|
process.env.ANTHROPIC_API_KEY
|
|
126
182
|
|| process.env.OPENAI_API_KEY
|
|
127
183
|
|| process.env.DEEPROUTER_API_KEY
|
|
184
|
+
|| process.env.KIDS_OAUTH_PROVIDER
|
|
128
185
|
)
|
|
129
186
|
}
|
|
130
187
|
|
package/src/index.tsx
CHANGED
|
@@ -1,23 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* kids-client entry. Composes core/* and renders the Ink app.
|
|
3
3
|
*
|
|
4
|
-
* Boot
|
|
5
|
-
* 1. readEnv
|
|
6
|
-
* 2. validateEnv
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
4
|
+
* Boot orchestration (V0.0.3):
|
|
5
|
+
* 1. readEnv + initial render in "loading"
|
|
6
|
+
* 2. validateEnv:
|
|
7
|
+
* - "needs_setup" → render SetupScreen, await user completion,
|
|
8
|
+
* reload env from file, re-validate, continue inline
|
|
9
|
+
* - "config_missing" / "auth_failed" → error screen, exit
|
|
10
|
+
* - ok → fall through to bootServices
|
|
11
|
+
* 3. bootServices: audit pipeline + opencode serve subprocess +
|
|
12
|
+
* SDK v2 client + SSE subscriber + SIGINT/SIGTERM
|
|
13
|
+
* 4. Render startup screen; user picks a flow.
|
|
12
14
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
+
* Inline boot guarantee: the user never sees "run kids-opencode again".
|
|
16
|
+
* SetupScreen → save → reload env → boot serve → MissionScreen,
|
|
17
|
+
* all in the SAME process.
|
|
15
18
|
*/
|
|
16
19
|
|
|
17
20
|
import React from "react"
|
|
18
21
|
import { render } from "ink"
|
|
19
22
|
import { join } from "node:path"
|
|
20
|
-
import { readEnv, validateEnv } from "./core/env.ts"
|
|
23
|
+
import { readEnv, validateEnv, type KidsClientEnv } from "./core/env.ts"
|
|
21
24
|
import { ServeManager } from "./core/serve-manager.ts"
|
|
22
25
|
import { createKidsClient, type OpencodeClient } from "./core/connection.ts"
|
|
23
26
|
import { SessionManager } from "./core/session.ts"
|
|
@@ -29,58 +32,189 @@ import { readLastSession, writeLastSession } from "./core/last-session.ts"
|
|
|
29
32
|
import { isCompletionTrigger, runCheck } from "./core/check-runner.ts"
|
|
30
33
|
import { App } from "./render/ink/App.tsx"
|
|
31
34
|
import { detectDangerousTopicEn, detectDangerousTopicZh } from "./dangerous-topic-bridge.ts"
|
|
32
|
-
import { saveSetup, type ProviderId } from "./core/setup.ts"
|
|
35
|
+
import { OAUTH_HANDOFF_EXIT_CODE, saveSetup, saveSetupOauth, type ProviderId } from "./core/setup.ts"
|
|
36
|
+
import { reloadEnvFile } from "./core/env-reload.ts"
|
|
33
37
|
import type { InstalledPack } from "./core/course-pack.ts"
|
|
34
|
-
import {
|
|
38
|
+
import { loadCoursePack } from "@kidsinai/kids-opencode-plugin"
|
|
39
|
+
|
|
40
|
+
interface ServiceSet {
|
|
41
|
+
audit: AuditPipeline
|
|
42
|
+
serve: ServeManager
|
|
43
|
+
client: OpencodeClient
|
|
44
|
+
session: SessionManager
|
|
45
|
+
subscriber: EventSubscriber
|
|
46
|
+
quit: () => Promise<void>
|
|
47
|
+
handlers: FullHandlers
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface FullHandlers {
|
|
51
|
+
onStart: (mode: "free" | "course" | "resume" | "help") => void
|
|
52
|
+
onPrompt: (text: string) => Promise<void>
|
|
53
|
+
onPermissionReply: (decision: "allow" | "deny" | "edit") => Promise<void>
|
|
54
|
+
onAbort: () => Promise<void>
|
|
55
|
+
onErrorRetry: () => Promise<void>
|
|
56
|
+
onPickPack: (packId: string) => void
|
|
57
|
+
onMissionNext: () => void
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface AppHandlers {
|
|
61
|
+
onStart: (mode: "free" | "course" | "resume" | "help") => void
|
|
62
|
+
onPrompt: (text: string) => void
|
|
63
|
+
onPermissionReply: (decision: "allow" | "deny" | "edit") => void
|
|
64
|
+
onDangerousAcknowledge: () => void
|
|
65
|
+
onErrorRetry: () => void | Promise<void>
|
|
66
|
+
onQuit: () => void | Promise<void>
|
|
67
|
+
onAbort: () => void
|
|
68
|
+
onHelpBack: () => void
|
|
69
|
+
onPickPack: (packId: string) => void
|
|
70
|
+
onPickerBack: () => void
|
|
71
|
+
onMissionNext: () => void
|
|
72
|
+
onMissionBack: () => void
|
|
73
|
+
onSetupSave: (provider: ProviderId, apiKey: string) => Promise<{ ok: true } | { ok: false; reason: string }>
|
|
74
|
+
onSetupContinue: () => Promise<void>
|
|
75
|
+
onSetupSkip: () => void
|
|
76
|
+
onSetupOAuthHandoff: (provider: ProviderId) => Promise<void>
|
|
77
|
+
}
|
|
35
78
|
|
|
36
79
|
async function main(): Promise<void> {
|
|
37
|
-
const env = readEnv()
|
|
80
|
+
const env: KidsClientEnv = readEnv()
|
|
38
81
|
const store = new Store()
|
|
39
82
|
const installedPacks = listInstalledPacks()
|
|
83
|
+
|
|
40
84
|
store.update({
|
|
41
85
|
coursePack: env.coursePack,
|
|
42
86
|
mission: env.mission,
|
|
43
87
|
screen: { kind: "loading", message: env.locale === "zh-Hans" ? "正在唤醒 AI 老师…" : "Waking up the AI teacher…" },
|
|
44
88
|
})
|
|
45
89
|
|
|
46
|
-
|
|
90
|
+
// Resolve course pack metadata upfront if available.
|
|
91
|
+
applyCoursePackContext(env, store)
|
|
92
|
+
|
|
93
|
+
// Mutable holder for service set; populated by bootServices().
|
|
94
|
+
const servicesHolder: { current: ServiceSet | null } = { current: null }
|
|
95
|
+
|
|
96
|
+
// Promise that the SetupScreen flow resolves when the user has completed
|
|
97
|
+
// (or chosen to skip) setup. main() awaits it before continuing.
|
|
98
|
+
let resolveSetup: (() => void) | null = null
|
|
99
|
+
const setupGate = new Promise<void>((r) => { resolveSetup = r })
|
|
100
|
+
|
|
101
|
+
const handlers: AppHandlers = makeHandlers(store, env, servicesHolder, resolveSetupFn => {
|
|
102
|
+
resolveSetup = resolveSetupFn
|
|
103
|
+
}, () => resolveSetup)
|
|
104
|
+
|
|
105
|
+
renderApp(store, env, installedPacks, handlers)
|
|
106
|
+
|
|
107
|
+
// First validation pass.
|
|
108
|
+
let check = validateEnv(env)
|
|
109
|
+
if (!check.ok && check.variant === "needs_setup") {
|
|
110
|
+
store.update({ screen: { kind: "setup" } })
|
|
111
|
+
await setupGate
|
|
112
|
+
|
|
113
|
+
// Re-source env file (the setup wizard wrote it).
|
|
114
|
+
reloadEnvFile(env.configDir)
|
|
115
|
+
Object.assign(env, readEnv())
|
|
116
|
+
check = validateEnv(env)
|
|
117
|
+
}
|
|
118
|
+
|
|
47
119
|
if (!check.ok) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
// writes config + env file, then re-launches main() to pick up
|
|
51
|
-
// the new key.
|
|
52
|
-
store.update({ screen: { kind: "setup" } })
|
|
53
|
-
renderApp(store, env, installedPacks, baseHandlers(store, env, null, null, null))
|
|
54
|
-
return
|
|
55
|
-
}
|
|
56
|
-
store.update({ screen: { kind: "error", variant: check.variant, detail: check.reason } })
|
|
57
|
-
renderApp(store, env, installedPacks, baseHandlers(store, env, null, null, null))
|
|
120
|
+
const variant = check.variant === "needs_setup" ? "auth_failed" : check.variant
|
|
121
|
+
store.update({ screen: { kind: "error", variant, detail: check.reason } })
|
|
58
122
|
return
|
|
59
123
|
}
|
|
60
124
|
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
125
|
+
// Bootstrap services in-process. Loading screen is shown while we wait.
|
|
126
|
+
store.update({
|
|
127
|
+
screen: {
|
|
128
|
+
kind: "loading",
|
|
129
|
+
message: env.locale === "zh-Hans" ? "启动 AI 引擎…" : "Starting AI engine…",
|
|
130
|
+
},
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
const services = await bootServices(env, store)
|
|
134
|
+
if (!services) {
|
|
135
|
+
// bootServices already updated the store with the failure screen.
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
servicesHolder.current = services
|
|
139
|
+
|
|
140
|
+
// SIGINT / SIGTERM cleanly tears down.
|
|
141
|
+
process.on("SIGINT", () => void services.quit())
|
|
142
|
+
process.on("SIGTERM", () => void services.quit())
|
|
143
|
+
|
|
144
|
+
// Land on startup screen.
|
|
145
|
+
store.update({ screen: { kind: "startup" } })
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ─── handler factory ──────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
function makeHandlers(
|
|
151
|
+
store: Store,
|
|
152
|
+
env: KidsClientEnv,
|
|
153
|
+
servicesHolder: { current: ServiceSet | null },
|
|
154
|
+
_setResolveSetup: (fn: (() => void) | null) => void,
|
|
155
|
+
getResolveSetup: () => (() => void) | null,
|
|
156
|
+
): AppHandlers {
|
|
157
|
+
const ifBooted = <A extends unknown[]>(fn: (s: ServiceSet, ...args: A) => unknown) => (...args: A) => {
|
|
158
|
+
const s = servicesHolder.current
|
|
159
|
+
if (s) return fn(s, ...args)
|
|
160
|
+
return undefined
|
|
82
161
|
}
|
|
83
162
|
|
|
163
|
+
return {
|
|
164
|
+
onStart: ifBooted((s, mode: "free" | "course" | "resume" | "help") => s.handlers.onStart(mode)),
|
|
165
|
+
onPrompt: ifBooted((s, text: string) => s.handlers.onPrompt(text)),
|
|
166
|
+
onPermissionReply: ifBooted((s, d: "allow" | "deny" | "edit") => s.handlers.onPermissionReply(d)),
|
|
167
|
+
onDangerousAcknowledge: () => store.update({ dangerousTopic: null }),
|
|
168
|
+
onErrorRetry: async () => {
|
|
169
|
+
const s = servicesHolder.current
|
|
170
|
+
if (s) return s.handlers.onErrorRetry()
|
|
171
|
+
// Pre-boot error retry: re-run main isn't trivial; just exit.
|
|
172
|
+
process.exit(1)
|
|
173
|
+
},
|
|
174
|
+
onQuit: async () => {
|
|
175
|
+
const s = servicesHolder.current
|
|
176
|
+
if (s) return s.quit()
|
|
177
|
+
process.exit(0)
|
|
178
|
+
},
|
|
179
|
+
onAbort: ifBooted((s) => s.handlers.onAbort()),
|
|
180
|
+
onHelpBack: () => store.update({ screen: { kind: "startup" } }),
|
|
181
|
+
onPickPack: ifBooted((s, id: string) => s.handlers.onPickPack(id)),
|
|
182
|
+
onPickerBack: () => store.update({ screen: { kind: "startup" } }),
|
|
183
|
+
onMissionNext: ifBooted((s) => s.handlers.onMissionNext()),
|
|
184
|
+
onMissionBack: () => store.update({ screen: { kind: "mission" } }),
|
|
185
|
+
onSetupSave: async (provider, apiKey) => {
|
|
186
|
+
try {
|
|
187
|
+
saveSetup({ configDir: env.configDir, provider, apiKey })
|
|
188
|
+
return { ok: true }
|
|
189
|
+
} catch (err) {
|
|
190
|
+
return { ok: false, reason: err instanceof Error ? err.message : String(err) }
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
onSetupContinue: async () => {
|
|
194
|
+
const r = getResolveSetup()
|
|
195
|
+
if (r) r()
|
|
196
|
+
},
|
|
197
|
+
onSetupSkip: () => {
|
|
198
|
+
const r = getResolveSetup()
|
|
199
|
+
if (r) r()
|
|
200
|
+
},
|
|
201
|
+
onSetupOAuthHandoff: async (provider) => {
|
|
202
|
+
try {
|
|
203
|
+
saveSetupOauth({ configDir: env.configDir, provider })
|
|
204
|
+
} catch (err) {
|
|
205
|
+
console.error("kids-client: OAuth handoff prep failed:", err)
|
|
206
|
+
process.exit(1)
|
|
207
|
+
}
|
|
208
|
+
// Hand the TTY to bin/kids-opencode so it can run
|
|
209
|
+
// `opencode auth login --provider <p>` interactively, then re-exec us.
|
|
210
|
+
process.exit(OAUTH_HANDOFF_EXIT_CODE)
|
|
211
|
+
},
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ─── service bootstrap ────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
async function bootServices(env: KidsClientEnv, store: Store): Promise<ServiceSet | null> {
|
|
84
218
|
const audit = new AuditPipeline({
|
|
85
219
|
bufferPath: join(env.configDir, "audit-buffer.jsonl"),
|
|
86
220
|
})
|
|
@@ -100,8 +234,7 @@ async function main(): Promise<void> {
|
|
|
100
234
|
const readiness = await serve.ensureReady()
|
|
101
235
|
if (readiness.kind === "timeout") {
|
|
102
236
|
store.update({ screen: { kind: "error", variant: "serve_unreachable", detail: readiness.lastError } })
|
|
103
|
-
|
|
104
|
-
return
|
|
237
|
+
return null
|
|
105
238
|
}
|
|
106
239
|
|
|
107
240
|
const client = createKidsClient({
|
|
@@ -114,8 +247,8 @@ async function main(): Promise<void> {
|
|
|
114
247
|
onSessionCreated: (e) => {
|
|
115
248
|
store.update({ sessionId: e.sessionID })
|
|
116
249
|
writeLastSession(env.configDir, {
|
|
117
|
-
coursePack:
|
|
118
|
-
mission:
|
|
250
|
+
coursePack: store.getSnapshot().coursePack,
|
|
251
|
+
mission: store.getSnapshot().mission,
|
|
119
252
|
lastActiveAt: new Date().toISOString(),
|
|
120
253
|
projectDir: process.cwd(),
|
|
121
254
|
})
|
|
@@ -137,7 +270,6 @@ async function main(): Promise<void> {
|
|
|
137
270
|
},
|
|
138
271
|
onTextEnded: (e) => store.endStream(e.messageID),
|
|
139
272
|
onPermissionAsked: (e) => {
|
|
140
|
-
// pickup of stars_estimated from the latest plugin audit event.
|
|
141
273
|
const recentAudit = store.getSnapshot().auditBuffer.slice(-10).reverse() as Array<Record<string, unknown>>
|
|
142
274
|
const matching = recentAudit.find(
|
|
143
275
|
(a) => a && typeof a === "object" && a.event === "tool.execute.before" && a.tool === e.tool,
|
|
@@ -174,121 +306,25 @@ async function main(): Promise<void> {
|
|
|
174
306
|
})
|
|
175
307
|
void subscriber.run()
|
|
176
308
|
|
|
177
|
-
|
|
178
|
-
// jump straight in (Enter) or pick a different one (c).
|
|
179
|
-
store.update({ screen: { kind: "startup" } })
|
|
180
|
-
|
|
181
|
-
const handleQuit = async (): Promise<void> => {
|
|
309
|
+
const quit = async (): Promise<void> => {
|
|
182
310
|
subscriber.stop()
|
|
183
311
|
await audit.stop()
|
|
184
312
|
await serve.shutdown()
|
|
185
313
|
process.exit(0)
|
|
186
314
|
}
|
|
187
|
-
process.on("SIGINT", () => void handleQuit())
|
|
188
|
-
process.on("SIGTERM", () => void handleQuit())
|
|
189
315
|
|
|
190
|
-
const handlers =
|
|
191
|
-
renderApp(store, env, installedPacks, handlers)
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// ─── handler factories ───────────────────────────────────────────────────
|
|
316
|
+
const handlers = makeFullHandlers(store, env, session, client, serve)
|
|
195
317
|
|
|
196
|
-
|
|
197
|
-
onStart: (mode: "free" | "course" | "resume" | "help") => void
|
|
198
|
-
onPrompt: (text: string) => void
|
|
199
|
-
onPermissionReply: (decision: "allow" | "deny" | "edit") => void
|
|
200
|
-
onDangerousAcknowledge: () => void
|
|
201
|
-
onErrorRetry: () => void | Promise<void>
|
|
202
|
-
onQuit: () => void | Promise<void>
|
|
203
|
-
onAbort: () => void
|
|
204
|
-
onHelpBack: () => void
|
|
205
|
-
onPickPack: (packId: string) => void
|
|
206
|
-
onPickerBack: () => void
|
|
207
|
-
onMissionNext: () => void
|
|
208
|
-
onMissionBack: () => void
|
|
209
|
-
onSetupSave: (provider: ProviderId, apiKey: string) => Promise<{ ok: true } | { ok: false; reason: string }>
|
|
210
|
-
onSetupSkip: () => void
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
function makeSetupHandlers(store: Store, env: ReturnType<typeof readEnv>): Pick<AppHandlers, "onSetupSave" | "onSetupSkip"> {
|
|
214
|
-
return {
|
|
215
|
-
onSetupSave: async (provider, apiKey) => {
|
|
216
|
-
try {
|
|
217
|
-
saveSetup({ configDir: env.configDir, provider, apiKey })
|
|
218
|
-
return { ok: true }
|
|
219
|
-
} catch (err) {
|
|
220
|
-
return { ok: false, reason: err instanceof Error ? err.message : String(err) }
|
|
221
|
-
}
|
|
222
|
-
},
|
|
223
|
-
onSetupSkip: () => {
|
|
224
|
-
// After setup completes (or user skips), tell the user to restart so
|
|
225
|
-
// the wrapper picks up the new env file. Re-launching main() in-process
|
|
226
|
-
// would require tearing down Ink which is messy; a re-exec is cleaner.
|
|
227
|
-
process.stderr.write("\nKids OpenCode: setup saved. Please run `kids-opencode` again to start.\n")
|
|
228
|
-
process.exit(0)
|
|
229
|
-
},
|
|
230
|
-
}
|
|
318
|
+
return { audit, serve, client, session, subscriber, quit, handlers }
|
|
231
319
|
}
|
|
232
320
|
|
|
233
|
-
|
|
234
|
-
* Minimal handlers for the pre-validation / pre-readiness error path.
|
|
235
|
-
* Many actions are no-ops because the app isn't fully booted; quit is
|
|
236
|
-
* the realistic action.
|
|
237
|
-
*/
|
|
238
|
-
function baseHandlers(
|
|
321
|
+
function makeFullHandlers(
|
|
239
322
|
store: Store,
|
|
240
|
-
env:
|
|
241
|
-
_session: SessionManager | null,
|
|
242
|
-
_client: OpencodeClient | null,
|
|
243
|
-
serve: ServeManager | null,
|
|
244
|
-
): AppHandlers {
|
|
245
|
-
const noop = (): void => {}
|
|
246
|
-
const setup = makeSetupHandlers(store, env)
|
|
247
|
-
return {
|
|
248
|
-
onStart: noop,
|
|
249
|
-
onPrompt: noop,
|
|
250
|
-
onPermissionReply: noop,
|
|
251
|
-
onDangerousAcknowledge: () => store.update({ dangerousTopic: null }),
|
|
252
|
-
onErrorRetry: async () => {
|
|
253
|
-
if (!serve) {
|
|
254
|
-
process.exit(1)
|
|
255
|
-
return
|
|
256
|
-
}
|
|
257
|
-
store.update({
|
|
258
|
-
screen: {
|
|
259
|
-
kind: "loading",
|
|
260
|
-
message: env.locale === "zh-Hans" ? "再试一次…" : "Trying again…",
|
|
261
|
-
},
|
|
262
|
-
})
|
|
263
|
-
const again = await serve.ensureReady()
|
|
264
|
-
if (again.kind === "timeout") {
|
|
265
|
-
store.update({ screen: { kind: "error", variant: "serve_unreachable", detail: again.lastError } })
|
|
266
|
-
} else {
|
|
267
|
-
store.update({ screen: { kind: "startup" } })
|
|
268
|
-
}
|
|
269
|
-
},
|
|
270
|
-
onQuit: async () => {
|
|
271
|
-
if (serve) await serve.shutdown()
|
|
272
|
-
process.exit(0)
|
|
273
|
-
},
|
|
274
|
-
onAbort: noop,
|
|
275
|
-
onHelpBack: () => store.update({ screen: { kind: "startup" } }),
|
|
276
|
-
onPickPack: noop,
|
|
277
|
-
onPickerBack: () => store.update({ screen: { kind: "startup" } }),
|
|
278
|
-
onMissionNext: noop,
|
|
279
|
-
onMissionBack: noop,
|
|
280
|
-
...setup,
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
function fullHandlers(
|
|
285
|
-
store: Store,
|
|
286
|
-
env: ReturnType<typeof readEnv>,
|
|
323
|
+
env: KidsClientEnv,
|
|
287
324
|
session: SessionManager,
|
|
288
325
|
client: OpencodeClient,
|
|
289
326
|
serve: ServeManager,
|
|
290
|
-
|
|
291
|
-
): AppHandlers {
|
|
327
|
+
): FullHandlers {
|
|
292
328
|
const updateLastSession = (): void => {
|
|
293
329
|
writeLastSession(env.configDir, {
|
|
294
330
|
coursePack: store.getSnapshot().coursePack,
|
|
@@ -329,10 +365,9 @@ function fullHandlers(
|
|
|
329
365
|
refreshContext()
|
|
330
366
|
flashToast(store, {
|
|
331
367
|
kind: "info",
|
|
332
|
-
text:
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
: `Resuming: ${last.coursePack}${last.mission ? " · " + last.mission : ""}`,
|
|
368
|
+
text: env.locale === "zh-Hans"
|
|
369
|
+
? `继续上次:${last.coursePack}${last.mission ? " · " + last.mission : ""}`
|
|
370
|
+
: `Resuming: ${last.coursePack}${last.mission ? " · " + last.mission : ""}`,
|
|
336
371
|
})
|
|
337
372
|
} else {
|
|
338
373
|
flashToast(store, {
|
|
@@ -343,14 +378,12 @@ function fullHandlers(
|
|
|
343
378
|
store.update({ screen: { kind: "mission" } })
|
|
344
379
|
return
|
|
345
380
|
}
|
|
346
|
-
// mode === "free" (or unrecognised) — enter MissionScreen.
|
|
347
381
|
store.update({ screen: { kind: "mission" } })
|
|
348
382
|
},
|
|
349
383
|
onPrompt: async (text) => {
|
|
350
384
|
const snap = store.getSnapshot()
|
|
351
385
|
store.appendMessage({ id: `kid-${Date.now()}`, actor: "kid", text, streaming: false, ts: Date.now() })
|
|
352
386
|
|
|
353
|
-
// In-TUI mission check intercept. Don't even hit the LLM.
|
|
354
387
|
if (snap.mission && isCompletionTrigger(text, env.locale)) {
|
|
355
388
|
const outcome = runCheck({
|
|
356
389
|
missionId: snap.mission,
|
|
@@ -384,14 +417,12 @@ function fullHandlers(
|
|
|
384
417
|
return
|
|
385
418
|
}
|
|
386
419
|
|
|
387
|
-
// Dangerous topic intercept on kid input.
|
|
388
420
|
const hit = env.locale === "zh-Hans" ? detectDangerousTopicZh(text) : detectDangerousTopicEn(text)
|
|
389
421
|
if (hit) {
|
|
390
422
|
store.update({ dangerousTopic: { category: hit, snippet: text } })
|
|
391
423
|
return
|
|
392
424
|
}
|
|
393
425
|
|
|
394
|
-
// Normal LLM prompt.
|
|
395
426
|
store.update({ thinking: true })
|
|
396
427
|
updateLastSession()
|
|
397
428
|
try {
|
|
@@ -412,17 +443,23 @@ function fullHandlers(
|
|
|
412
443
|
if (decision === "edit") {
|
|
413
444
|
flashToast(store, {
|
|
414
445
|
kind: "info",
|
|
415
|
-
text:
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
: "You take this step — tell the AI what you'd prefer",
|
|
446
|
+
text: env.locale === "zh-Hans"
|
|
447
|
+
? "你来改这一步,告诉 AI 你想怎么做"
|
|
448
|
+
: "You take this step — tell the AI what you'd prefer",
|
|
419
449
|
})
|
|
420
450
|
}
|
|
421
|
-
} catch {
|
|
422
|
-
|
|
423
|
-
|
|
451
|
+
} catch { /* SSE surfaces errors */ }
|
|
452
|
+
},
|
|
453
|
+
onAbort: async () => {
|
|
454
|
+
try {
|
|
455
|
+
await session.abort()
|
|
456
|
+
store.update({ thinking: false })
|
|
457
|
+
flashToast(store, {
|
|
458
|
+
kind: "warn",
|
|
459
|
+
text: env.locale === "zh-Hans" ? "已停止" : "Stopped",
|
|
460
|
+
})
|
|
461
|
+
} catch { /* ignore */ }
|
|
424
462
|
},
|
|
425
|
-
onDangerousAcknowledge: () => store.update({ dangerousTopic: null }),
|
|
426
463
|
onErrorRetry: async () => {
|
|
427
464
|
store.update({
|
|
428
465
|
screen: {
|
|
@@ -437,26 +474,11 @@ function fullHandlers(
|
|
|
437
474
|
store.update({ screen: { kind: "startup" } })
|
|
438
475
|
}
|
|
439
476
|
},
|
|
440
|
-
onQuit: quit,
|
|
441
|
-
onAbort: async () => {
|
|
442
|
-
try {
|
|
443
|
-
await session.abort()
|
|
444
|
-
store.update({ thinking: false })
|
|
445
|
-
flashToast(store, {
|
|
446
|
-
kind: "warn",
|
|
447
|
-
text: env.locale === "zh-Hans" ? "已停止" : "Stopped",
|
|
448
|
-
})
|
|
449
|
-
} catch {
|
|
450
|
-
// ignore
|
|
451
|
-
}
|
|
452
|
-
},
|
|
453
|
-
onHelpBack: () => store.update({ screen: { kind: "startup" } }),
|
|
454
477
|
onPickPack: (packId) => {
|
|
455
478
|
store.update({ coursePack: packId, mission: null })
|
|
456
479
|
refreshContext()
|
|
457
480
|
store.update({ screen: { kind: "mission" } })
|
|
458
481
|
},
|
|
459
|
-
onPickerBack: () => store.update({ screen: { kind: "startup" } }),
|
|
460
482
|
onMissionNext: () => {
|
|
461
483
|
const snap = store.getSnapshot()
|
|
462
484
|
if (!snap.coursePack || !snap.mission) {
|
|
@@ -479,16 +501,37 @@ function fullHandlers(
|
|
|
479
501
|
text: env.locale === "zh-Hans" ? `开始:${next.title}` : `Starting: ${next.title}`,
|
|
480
502
|
})
|
|
481
503
|
},
|
|
482
|
-
onMissionBack: () => store.update({ screen: { kind: "mission" } }),
|
|
483
|
-
...makeSetupHandlers(store, env),
|
|
484
504
|
}
|
|
485
505
|
}
|
|
486
506
|
|
|
487
|
-
// ───
|
|
507
|
+
// ─── helpers ──────────────────────────────────────────────────────────────
|
|
508
|
+
|
|
509
|
+
function applyCoursePackContext(env: KidsClientEnv, store: Store): void {
|
|
510
|
+
const ctx = resolveContext(env.coursePack, env.mission)
|
|
511
|
+
if (ctx) {
|
|
512
|
+
store.update({
|
|
513
|
+
packTitle: ctx.packTitle,
|
|
514
|
+
missionTitle: ctx.missionTitle,
|
|
515
|
+
missionIndex: ctx.missionIndex,
|
|
516
|
+
missionTotal: ctx.missionTotal,
|
|
517
|
+
starsBudget: ctx.starsBudget,
|
|
518
|
+
starsBalance: ctx.starsBudget,
|
|
519
|
+
})
|
|
520
|
+
} else if (env.coursePack) {
|
|
521
|
+
store.update({
|
|
522
|
+
toast: {
|
|
523
|
+
kind: "warn",
|
|
524
|
+
text: env.locale === "zh-Hans"
|
|
525
|
+
? `没找到 Course Pack: ${env.coursePack}(按 c 重新选)`
|
|
526
|
+
: `Course Pack not found: ${env.coursePack} (press c to pick)`,
|
|
527
|
+
},
|
|
528
|
+
})
|
|
529
|
+
}
|
|
530
|
+
}
|
|
488
531
|
|
|
489
532
|
function renderApp(
|
|
490
533
|
store: Store,
|
|
491
|
-
env:
|
|
534
|
+
env: KidsClientEnv,
|
|
492
535
|
installedPacks: InstalledPack[],
|
|
493
536
|
handlers: AppHandlers,
|
|
494
537
|
): void {
|
|
@@ -536,11 +579,6 @@ function errMessage(err: unknown): string {
|
|
|
536
579
|
return String(err)
|
|
537
580
|
}
|
|
538
581
|
|
|
539
|
-
// Touch findMission so TS doesn't complain about the import being unused
|
|
540
|
-
// when typecheck runs against the v0.0.1 SDK that doesn't expose v2 yet.
|
|
541
|
-
void findMission
|
|
542
|
-
void loadCoursePack
|
|
543
|
-
|
|
544
582
|
void main().catch((err) => {
|
|
545
583
|
console.error("kids-client: fatal startup error:", err)
|
|
546
584
|
process.exit(1)
|
package/src/render/ink/App.tsx
CHANGED
|
@@ -41,7 +41,9 @@ export interface AppDeps {
|
|
|
41
41
|
onMissionNext: () => void
|
|
42
42
|
onMissionBack: () => void
|
|
43
43
|
onSetupSave: (provider: ProviderId, apiKey: string) => Promise<{ ok: true } | { ok: false; reason: string }>
|
|
44
|
+
onSetupContinue: () => Promise<void>
|
|
44
45
|
onSetupSkip: () => void
|
|
46
|
+
onSetupOAuthHandoff: (provider: ProviderId) => Promise<void>
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
export function App(deps: AppDeps): React.ReactElement {
|
|
@@ -74,7 +76,7 @@ export function App(deps: AppDeps): React.ReactElement {
|
|
|
74
76
|
case "loading":
|
|
75
77
|
return <LoadingScreen locale={deps.locale} message={state.screen.message} />
|
|
76
78
|
case "setup":
|
|
77
|
-
return <SetupScreen locale={deps.locale} onSave={deps.onSetupSave} onSkip={deps.onSetupSkip} />
|
|
79
|
+
return <SetupScreen locale={deps.locale} onSave={deps.onSetupSave} onContinue={deps.onSetupContinue} onSkip={deps.onSetupSkip} onOAuthHandoff={deps.onSetupOAuthHandoff} />
|
|
78
80
|
case "startup":
|
|
79
81
|
return <StartupScreen locale={deps.locale} coursePack={state.coursePack} onStart={deps.onStart} />
|
|
80
82
|
case "mission":
|
|
@@ -4,62 +4,218 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Audience: a parent (the kid sees the intro and is told to grab a
|
|
6
6
|
* grown-up). The wizard walks through:
|
|
7
|
+
* 0. engine_install (auto, only if upstream opencode CLI missing)
|
|
7
8
|
* 1. Welcome / "this part needs a grown-up"
|
|
8
9
|
* 2. Pick provider (Anthropic / OpenAI / DeepRouter)
|
|
9
10
|
* 3. Paste API key (with link to where to get one)
|
|
10
|
-
* 4. Save → re-
|
|
11
|
-
*
|
|
12
|
-
* The choice is persisted via core/setup.ts (writes ~/.config/kids-opencode/env
|
|
13
|
-
* + updates opencode.json provider section).
|
|
11
|
+
* 4. Save → continue inline (no re-exec) → MissionScreen
|
|
14
12
|
*/
|
|
15
13
|
|
|
16
|
-
import React, { useState } from "react"
|
|
14
|
+
import React, { useEffect, useState } from "react"
|
|
17
15
|
import { Box, Text, useInput } from "ink"
|
|
18
16
|
import TextInput from "ink-text-input"
|
|
17
|
+
import Spinner from "ink-spinner"
|
|
19
18
|
import { getTheme } from "../theme.ts"
|
|
20
19
|
import { KidsLogo } from "../components/KidsLogo.tsx"
|
|
21
20
|
import {
|
|
22
21
|
findProvider,
|
|
23
22
|
looksLikeApiKey,
|
|
23
|
+
OAUTH_PROVIDERS,
|
|
24
24
|
PROVIDERS,
|
|
25
25
|
type ProviderId,
|
|
26
26
|
} from "../../../core/setup.ts"
|
|
27
|
+
import { hasOpencodeBinary, installOpencode } from "../../../core/opencode-installer.ts"
|
|
27
28
|
|
|
28
|
-
type Step =
|
|
29
|
+
type Step =
|
|
30
|
+
| "engine_install"
|
|
31
|
+
| "engine_done"
|
|
32
|
+
| "intro"
|
|
33
|
+
| "provider"
|
|
34
|
+
| "auth_choice"
|
|
35
|
+
| "oauth_handoff"
|
|
36
|
+
| "apikey"
|
|
37
|
+
| "saving"
|
|
38
|
+
| "done"
|
|
39
|
+
| "error"
|
|
29
40
|
|
|
30
41
|
interface SetupScreenProps {
|
|
31
42
|
locale: "zh-Hans" | "en"
|
|
32
43
|
onSave: (provider: ProviderId, apiKey: string) => Promise<{ ok: true } | { ok: false; reason: string }>
|
|
44
|
+
/** After save, kicks off inline boot. Resolves when AI is ready. */
|
|
45
|
+
onContinue: () => Promise<void>
|
|
46
|
+
/** Skip key — useful for advanced users who set env vars themselves. */
|
|
33
47
|
onSkip: () => void
|
|
48
|
+
/**
|
|
49
|
+
* Hand off to bin/kids-opencode for `opencode auth login --provider <p>`.
|
|
50
|
+
* Implementation writes opencode.json + the KIDS_OAUTH_PROVIDER marker,
|
|
51
|
+
* then process.exit(OAUTH_HANDOFF_EXIT_CODE). Never returns.
|
|
52
|
+
*/
|
|
53
|
+
onOAuthHandoff: (provider: ProviderId) => Promise<void>
|
|
34
54
|
}
|
|
35
55
|
|
|
36
|
-
export function SetupScreen({ locale, onSave, onSkip }: SetupScreenProps): React.ReactElement {
|
|
56
|
+
export function SetupScreen({ locale, onSave, onContinue, onSkip, onOAuthHandoff }: SetupScreenProps): React.ReactElement {
|
|
37
57
|
const theme = getTheme()
|
|
38
58
|
const t = STRINGS[locale]
|
|
39
|
-
const
|
|
59
|
+
const initialStep: Step = hasOpencodeBinary() ? "intro" : "engine_install"
|
|
60
|
+
const [step, setStep] = useState<Step>(initialStep)
|
|
40
61
|
const [providerIdx, setProviderIdx] = useState(0)
|
|
62
|
+
const [authChoiceIdx, setAuthChoiceIdx] = useState(0) // 0 = subscription, 1 = api key
|
|
41
63
|
const [apiKey, setApiKey] = useState("")
|
|
42
64
|
const [errorMsg, setErrorMsg] = useState("")
|
|
65
|
+
const [engineLog, setEngineLog] = useState<string[]>([])
|
|
66
|
+
const [engineRunning, setEngineRunning] = useState(false)
|
|
67
|
+
|
|
68
|
+
// Auto-trigger engine install once on first render.
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (initialStep === "engine_install" && !engineRunning) {
|
|
71
|
+
setEngineRunning(true)
|
|
72
|
+
void installOpencode((line) => {
|
|
73
|
+
setEngineLog((prev) => {
|
|
74
|
+
const next = [...prev, line]
|
|
75
|
+
return next.length > 8 ? next.slice(next.length - 8) : next
|
|
76
|
+
})
|
|
77
|
+
}).then((result) => {
|
|
78
|
+
setEngineRunning(false)
|
|
79
|
+
if (result.ok) {
|
|
80
|
+
setStep("engine_done")
|
|
81
|
+
} else {
|
|
82
|
+
setErrorMsg(result.error ?? "engine install failed")
|
|
83
|
+
setStep("error")
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
88
|
+
}, [])
|
|
43
89
|
|
|
44
90
|
useInput((input, key) => {
|
|
45
|
-
if (step === "
|
|
91
|
+
if (step === "engine_done") {
|
|
92
|
+
if (key.return) setStep("intro")
|
|
93
|
+
} else if (step === "intro") {
|
|
46
94
|
if (key.return) setStep("provider")
|
|
47
95
|
else if (input === "s" || input === "S") onSkip()
|
|
48
96
|
} else if (step === "provider") {
|
|
49
97
|
if (key.upArrow) setProviderIdx((i) => Math.max(0, i - 1))
|
|
50
98
|
else if (key.downArrow) setProviderIdx((i) => Math.min(PROVIDERS.length - 1, i + 1))
|
|
51
|
-
else if (key.return)
|
|
99
|
+
else if (key.return) {
|
|
100
|
+
// Anthropic supports both Pro/Max subscription OAuth and API key —
|
|
101
|
+
// surface the choice. Other providers go straight to api-key input.
|
|
102
|
+
const picked = PROVIDERS[providerIdx]!
|
|
103
|
+
if (OAUTH_PROVIDERS.includes(picked.id)) {
|
|
104
|
+
setAuthChoiceIdx(0)
|
|
105
|
+
setStep("auth_choice")
|
|
106
|
+
} else {
|
|
107
|
+
setStep("apikey")
|
|
108
|
+
}
|
|
109
|
+
}
|
|
52
110
|
else if (key.escape) setStep("intro")
|
|
111
|
+
} else if (step === "auth_choice") {
|
|
112
|
+
if (key.upArrow) setAuthChoiceIdx((i) => Math.max(0, i - 1))
|
|
113
|
+
else if (key.downArrow) setAuthChoiceIdx((i) => Math.min(1, i + 1))
|
|
114
|
+
else if (key.return) {
|
|
115
|
+
if (authChoiceIdx === 0) {
|
|
116
|
+
// Pro/Max OAuth — render a brief handoff screen, then exit so
|
|
117
|
+
// the wrapper can run `opencode auth login` with full TTY.
|
|
118
|
+
setStep("oauth_handoff")
|
|
119
|
+
// Fire-and-forget — onOAuthHandoff calls process.exit, never returns.
|
|
120
|
+
void onOAuthHandoff(PROVIDERS[providerIdx]!.id)
|
|
121
|
+
} else {
|
|
122
|
+
setStep("apikey")
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
else if (key.escape) setStep("provider")
|
|
126
|
+
} else if (step === "apikey") {
|
|
127
|
+
// Picked the wrong provider? Esc bounces back to the picker.
|
|
128
|
+
// (Enter is consumed by TextInput's onSubmit below, so we only need Esc here.)
|
|
129
|
+
if (key.escape) {
|
|
130
|
+
setApiKey("")
|
|
131
|
+
setStep("provider")
|
|
132
|
+
} else if (
|
|
133
|
+
(input === "d" || input === "D")
|
|
134
|
+
&& provider.id !== "deeprouter"
|
|
135
|
+
&& apiKey === ""
|
|
136
|
+
) {
|
|
137
|
+
// Funnel: parent decides Anthropic/OpenAI billing is too much friction
|
|
138
|
+
// — switch to DeepRouter inline without re-traversing the picker.
|
|
139
|
+
// Guarded by `apiKey === ""` so the keystroke only diverts before
|
|
140
|
+
// the user has started typing; otherwise `d` is just a character.
|
|
141
|
+
const dIdx = PROVIDERS.findIndex((p) => p.id === "deeprouter")
|
|
142
|
+
if (dIdx >= 0) {
|
|
143
|
+
setProviderIdx(dIdx)
|
|
144
|
+
setApiKey("")
|
|
145
|
+
}
|
|
146
|
+
}
|
|
53
147
|
} else if (step === "done") {
|
|
54
|
-
if (key.return)
|
|
148
|
+
if (key.return) {
|
|
149
|
+
// Inline boot — no exit.
|
|
150
|
+
void onContinue()
|
|
151
|
+
}
|
|
55
152
|
} else if (step === "error") {
|
|
56
|
-
if (key.return)
|
|
153
|
+
if (key.return) {
|
|
154
|
+
// From any failure step, retry from apikey unless engine failed
|
|
155
|
+
setStep(engineLog.length > 0 && !hasOpencodeBinary() ? "engine_install" : "apikey")
|
|
156
|
+
if (engineLog.length > 0 && !hasOpencodeBinary()) {
|
|
157
|
+
// Restart engine install
|
|
158
|
+
setEngineLog([])
|
|
159
|
+
setEngineRunning(true)
|
|
160
|
+
void installOpencode((line) => {
|
|
161
|
+
setEngineLog((prev) => {
|
|
162
|
+
const next = [...prev, line]
|
|
163
|
+
return next.length > 8 ? next.slice(next.length - 8) : next
|
|
164
|
+
})
|
|
165
|
+
}).then((result) => {
|
|
166
|
+
setEngineRunning(false)
|
|
167
|
+
if (result.ok) setStep("engine_done")
|
|
168
|
+
else {
|
|
169
|
+
setErrorMsg(result.error ?? "engine install failed")
|
|
170
|
+
setStep("error")
|
|
171
|
+
}
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
}
|
|
57
175
|
}
|
|
58
176
|
})
|
|
59
177
|
|
|
60
178
|
const provider = PROVIDERS[providerIdx]!
|
|
61
179
|
const providerObj = findProvider(provider.id)
|
|
62
180
|
|
|
181
|
+
if (step === "engine_install") {
|
|
182
|
+
return (
|
|
183
|
+
<Box flexDirection="column" alignItems="center" paddingY={1}>
|
|
184
|
+
<KidsLogo />
|
|
185
|
+
<Box marginTop={2} borderStyle="round" borderColor={theme.accent} paddingX={2} paddingY={1} flexDirection="column">
|
|
186
|
+
<Box>
|
|
187
|
+
<Text color={theme.accent}>{engineRunning ? <Spinner type="dots" /> : " "}</Text>
|
|
188
|
+
<Text color={theme.accent} bold> {t.engineInstalling}</Text>
|
|
189
|
+
</Box>
|
|
190
|
+
<Box marginTop={1}>
|
|
191
|
+
<Text color={theme.fgDim}>{t.engineHint}</Text>
|
|
192
|
+
</Box>
|
|
193
|
+
{engineLog.length > 0 && (
|
|
194
|
+
<Box marginTop={1} flexDirection="column">
|
|
195
|
+
{engineLog.map((line, i) => (
|
|
196
|
+
<Text key={i} color={theme.fgDim} dimColor> {line}</Text>
|
|
197
|
+
))}
|
|
198
|
+
</Box>
|
|
199
|
+
)}
|
|
200
|
+
</Box>
|
|
201
|
+
</Box>
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (step === "engine_done") {
|
|
206
|
+
return (
|
|
207
|
+
<Box flexDirection="column" alignItems="center" paddingY={1}>
|
|
208
|
+
<KidsLogo />
|
|
209
|
+
<Box marginTop={2} borderStyle="round" borderColor={theme.success} paddingX={2} paddingY={1}>
|
|
210
|
+
<Text color={theme.success} bold>{t.engineDone}</Text>
|
|
211
|
+
</Box>
|
|
212
|
+
<Box marginTop={1}>
|
|
213
|
+
<Text color={theme.accent}>{t.continueHint}</Text>
|
|
214
|
+
</Box>
|
|
215
|
+
</Box>
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
|
|
63
219
|
if (step === "intro") {
|
|
64
220
|
return (
|
|
65
221
|
<Box flexDirection="column" alignItems="center" paddingY={1}>
|
|
@@ -107,13 +263,68 @@ export function SetupScreen({ locale, onSave, onSkip }: SetupScreenProps): React
|
|
|
107
263
|
)
|
|
108
264
|
}
|
|
109
265
|
|
|
266
|
+
if (step === "auth_choice") {
|
|
267
|
+
const choices = t.authChoice.options
|
|
268
|
+
return (
|
|
269
|
+
<Box flexDirection="column" borderStyle="double" borderColor={theme.accent} paddingX={2} paddingY={1}>
|
|
270
|
+
<Text color={theme.accent} bold>{t.authChoice.title(providerObj.label)}</Text>
|
|
271
|
+
<Box marginTop={1} flexDirection="column">
|
|
272
|
+
{choices.map((c, i) => {
|
|
273
|
+
const active = i === authChoiceIdx
|
|
274
|
+
return (
|
|
275
|
+
<Box key={i}>
|
|
276
|
+
<Text color={active ? theme.kid : theme.fg}>{active ? "▶ " : " "}</Text>
|
|
277
|
+
<Box flexDirection="column" flexGrow={1}>
|
|
278
|
+
<Text color={active ? theme.accent : theme.fg} bold={active}>{c.label}</Text>
|
|
279
|
+
<Text color={theme.fgDim} dimColor={!active}> {c.hint}</Text>
|
|
280
|
+
</Box>
|
|
281
|
+
</Box>
|
|
282
|
+
)
|
|
283
|
+
})}
|
|
284
|
+
</Box>
|
|
285
|
+
<Box marginTop={1}>
|
|
286
|
+
<Text color={theme.accent}>{t.authChoice.keys}</Text>
|
|
287
|
+
</Box>
|
|
288
|
+
</Box>
|
|
289
|
+
)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (step === "oauth_handoff") {
|
|
293
|
+
return (
|
|
294
|
+
<Box flexDirection="column" alignItems="center" paddingY={1}>
|
|
295
|
+
<KidsLogo />
|
|
296
|
+
<Box marginTop={2} borderStyle="round" borderColor={theme.accent} paddingX={2} paddingY={1} flexDirection="column">
|
|
297
|
+
<Box>
|
|
298
|
+
<Text color={theme.accent}>
|
|
299
|
+
<Spinner type="dots" />
|
|
300
|
+
</Text>
|
|
301
|
+
<Text color={theme.accent} bold> {t.oauthHandoff.title}</Text>
|
|
302
|
+
</Box>
|
|
303
|
+
<Box marginTop={1}>
|
|
304
|
+
<Text color={theme.fgDim}>{t.oauthHandoff.line1}</Text>
|
|
305
|
+
</Box>
|
|
306
|
+
<Box>
|
|
307
|
+
<Text color={theme.fgDim}>{t.oauthHandoff.line2}</Text>
|
|
308
|
+
</Box>
|
|
309
|
+
</Box>
|
|
310
|
+
</Box>
|
|
311
|
+
)
|
|
312
|
+
}
|
|
313
|
+
|
|
110
314
|
if (step === "apikey") {
|
|
315
|
+
const steps = t.providerSteps[provider.id]
|
|
111
316
|
return (
|
|
112
317
|
<Box flexDirection="column" borderStyle="double" borderColor={theme.accent} paddingX={2} paddingY={1}>
|
|
113
318
|
<Text color={theme.accent} bold>{t.apiKeyTitle(providerObj.label)}</Text>
|
|
114
319
|
<Box marginTop={1} flexDirection="column">
|
|
115
320
|
<Text color={theme.fgDim}>{t.apiKeyHint(providerObj.apiKeyUrl)}</Text>
|
|
116
321
|
</Box>
|
|
322
|
+
<Box marginTop={1} flexDirection="column" paddingX={1}>
|
|
323
|
+
<Text color={theme.accent}>{t.stepsHeader}</Text>
|
|
324
|
+
{steps.map((line, i) => (
|
|
325
|
+
<Text key={i} color={theme.fgDim}> {line}</Text>
|
|
326
|
+
))}
|
|
327
|
+
</Box>
|
|
117
328
|
<Box marginTop={1}>
|
|
118
329
|
<Text color={theme.kid}>🔑 </Text>
|
|
119
330
|
<TextInput
|
|
@@ -143,6 +354,14 @@ export function SetupScreen({ locale, onSave, onSkip }: SetupScreenProps): React
|
|
|
143
354
|
<Box marginTop={1}>
|
|
144
355
|
<Text color={theme.fgDim}>{t.apiKeyEnter}</Text>
|
|
145
356
|
</Box>
|
|
357
|
+
<Box>
|
|
358
|
+
<Text color={theme.fgDim}>{t.apiKeyBack}</Text>
|
|
359
|
+
</Box>
|
|
360
|
+
{provider.id !== "deeprouter" && (
|
|
361
|
+
<Box>
|
|
362
|
+
<Text color={theme.accent}>{t.apiKeyToDR}</Text>
|
|
363
|
+
</Box>
|
|
364
|
+
)}
|
|
146
365
|
</Box>
|
|
147
366
|
)
|
|
148
367
|
}
|
|
@@ -150,7 +369,10 @@ export function SetupScreen({ locale, onSave, onSkip }: SetupScreenProps): React
|
|
|
150
369
|
if (step === "saving") {
|
|
151
370
|
return (
|
|
152
371
|
<Box paddingY={1} paddingX={2}>
|
|
153
|
-
<Text color={theme.accent}>
|
|
372
|
+
<Text color={theme.accent}>
|
|
373
|
+
<Spinner type="dots" />
|
|
374
|
+
</Text>
|
|
375
|
+
<Text color={theme.accent}> {t.saving}</Text>
|
|
154
376
|
</Box>
|
|
155
377
|
)
|
|
156
378
|
}
|
|
@@ -188,6 +410,10 @@ export function SetupScreen({ locale, onSave, onSkip }: SetupScreenProps): React
|
|
|
188
410
|
|
|
189
411
|
const STRINGS = {
|
|
190
412
|
"zh-Hans": {
|
|
413
|
+
engineInstalling: "正在安装 AI 引擎…",
|
|
414
|
+
engineHint: "从 opencode.ai 下载(大约 30 秒)。卡住的话按 Enter 重试。",
|
|
415
|
+
engineDone: "✓ AI 引擎安装完成",
|
|
416
|
+
continueHint: "[Enter] 下一步",
|
|
191
417
|
introTitle: "👋 这一步需要家长帮忙",
|
|
192
418
|
introLine1: "AI 老师要用一个 \"API key\" 才能工作 —— 就像给它一把钥匙。",
|
|
193
419
|
introLine2: "家长打开账号给 AI 服务(Anthropic / OpenAI 等),拿到 key 粘进来就行。",
|
|
@@ -196,19 +422,67 @@ const STRINGS = {
|
|
|
196
422
|
providerTitle: "选一个 AI 服务",
|
|
197
423
|
providerKeys: "[↑↓] 选 · [Enter] 下一步 · [Esc] 返回",
|
|
198
424
|
getKey: "去拿 key",
|
|
425
|
+
authChoice: {
|
|
426
|
+
title: (label: string) => `${label} 怎么用?`,
|
|
427
|
+
keys: "[↑↓] 选 · [Enter] 确认 · [Esc] 返回",
|
|
428
|
+
options: [
|
|
429
|
+
{
|
|
430
|
+
label: "用我的 Claude Pro/Max 订阅(推荐)",
|
|
431
|
+
hint: "不用 API key、不用充值;用现有 claude.ai 账号一键登录",
|
|
432
|
+
},
|
|
433
|
+
{
|
|
434
|
+
label: "用 API key(按量计费 ~$5/月)",
|
|
435
|
+
hint: "公司账号、没订阅、或想分账时选这个",
|
|
436
|
+
},
|
|
437
|
+
],
|
|
438
|
+
},
|
|
439
|
+
oauthHandoff: {
|
|
440
|
+
title: "正在让 Claude 登录接管屏幕…",
|
|
441
|
+
line1: "马上会跳出浏览器让你登录 claude.ai 账号。",
|
|
442
|
+
line2: "登录完后我会自动接回来,给孩子继续。",
|
|
443
|
+
},
|
|
199
444
|
apiKeyTitle: (label: string) => `输入 ${label} 的 API key`,
|
|
200
445
|
apiKeyHint: (url: string) => `没 key?打开浏览器:${url}`,
|
|
201
446
|
apiKeyPlaceholder: (env: string) => `${env}(粘进来后按 Enter)`,
|
|
202
447
|
apiKeyEnter: "[Enter] 保存 · 你的 key 只存在本地",
|
|
448
|
+
apiKeyBack: "[Esc] 选错了?回去重选",
|
|
449
|
+
apiKeyToDR: "[d] 不想充值?改用 DeepRouter — 无需信用卡(输入前按)",
|
|
203
450
|
apiKeyInvalid: (env: string) => `这看起来不是有效的 ${env}。再试一次。`,
|
|
451
|
+
stepsHeader: "在哪里点开:",
|
|
452
|
+
providerSteps: {
|
|
453
|
+
anthropic: [
|
|
454
|
+
"1. 打开浏览器 → console.anthropic.com",
|
|
455
|
+
"2. 用 Google 账号登录,或邮箱注册(免费)",
|
|
456
|
+
"3. 右上角点头像 → Billing → Add credits → 充 $5 起(信用卡 / Apple Pay)",
|
|
457
|
+
"4. 左侧菜单点 API Keys → 按 \"Create Key\" 按钮 → 起个名(比如 kids)",
|
|
458
|
+
"5. 弹窗里复制 sk-ant- 开头的整串,回到这里粘上",
|
|
459
|
+
],
|
|
460
|
+
openai: [
|
|
461
|
+
"1. 打开浏览器 → platform.openai.com/api-keys",
|
|
462
|
+
"2. 用 Google 账号登录,或邮箱注册",
|
|
463
|
+
"3. 左侧 Billing → Add to credit balance → 充 $5 起(信用卡)",
|
|
464
|
+
"4. 回到 API Keys → 按 \"+ Create new secret key\" → 起个名(比如 kids)",
|
|
465
|
+
"5. 弹窗里复制 sk- 开头的整串(只显示一次!),回来粘上",
|
|
466
|
+
],
|
|
467
|
+
deeprouter: [
|
|
468
|
+
"1. 打开浏览器 → deeprouter.ai(目前内测,需要邀请码)",
|
|
469
|
+
"2. 拿邀请码注册账号",
|
|
470
|
+
"3. 控制台 → API Keys → 创建新 key",
|
|
471
|
+
"4. 复制 key,回到这里粘上",
|
|
472
|
+
],
|
|
473
|
+
},
|
|
204
474
|
saving: "保存中…",
|
|
205
475
|
errTitle: "出了点问题",
|
|
206
476
|
errRetry: "[Enter] 再试",
|
|
207
477
|
doneTitle: "🎉 搞定!家长任务完成。",
|
|
208
|
-
doneNext: "
|
|
209
|
-
doneHint: "[Enter]
|
|
478
|
+
doneNext: "马上启动,让孩子继续。",
|
|
479
|
+
doneHint: "[Enter] 启动",
|
|
210
480
|
},
|
|
211
481
|
en: {
|
|
482
|
+
engineInstalling: "Setting up the AI engine…",
|
|
483
|
+
engineHint: "Downloading from opencode.ai (about 30 seconds). Stuck? Press Enter to retry.",
|
|
484
|
+
engineDone: "✓ AI engine ready",
|
|
485
|
+
continueHint: "[Enter] Next",
|
|
212
486
|
introTitle: "👋 Grown-up help needed for this part",
|
|
213
487
|
introLine1: "The AI teacher needs an \"API key\" to work — think of it as a password.",
|
|
214
488
|
introLine2: "A parent opens an account with an AI service (Anthropic / OpenAI), copies the key, pastes it here.",
|
|
@@ -217,16 +491,60 @@ const STRINGS = {
|
|
|
217
491
|
providerTitle: "Pick an AI service",
|
|
218
492
|
providerKeys: "[↑↓] choose · [Enter] next · [Esc] back",
|
|
219
493
|
getKey: "Get key at",
|
|
494
|
+
authChoice: {
|
|
495
|
+
title: (label: string) => `How will you connect to ${label}?`,
|
|
496
|
+
keys: "[↑↓] choose · [Enter] confirm · [Esc] back",
|
|
497
|
+
options: [
|
|
498
|
+
{
|
|
499
|
+
label: "Use my Claude Pro/Max subscription (recommended)",
|
|
500
|
+
hint: "No API key, no top-up — sign in with your existing claude.ai account",
|
|
501
|
+
},
|
|
502
|
+
{
|
|
503
|
+
label: "Use an API key (pay-as-you-go ~$5/month)",
|
|
504
|
+
hint: "Pick this for company accounts, no subscription, or separate billing",
|
|
505
|
+
},
|
|
506
|
+
],
|
|
507
|
+
},
|
|
508
|
+
oauthHandoff: {
|
|
509
|
+
title: "Handing off to Claude login…",
|
|
510
|
+
line1: "A browser window will open to sign in to your claude.ai account.",
|
|
511
|
+
line2: "I'll pick back up automatically once you're done.",
|
|
512
|
+
},
|
|
220
513
|
apiKeyTitle: (label: string) => `Enter your ${label} API key`,
|
|
221
514
|
apiKeyHint: (url: string) => `Don't have a key yet? Open: ${url}`,
|
|
222
515
|
apiKeyPlaceholder: (env: string) => `${env} (paste then Enter)`,
|
|
223
516
|
apiKeyEnter: "[Enter] save · Your key stays on this machine.",
|
|
517
|
+
apiKeyBack: "[Esc] Picked wrong one? Go back and re-pick.",
|
|
518
|
+
apiKeyToDR: "[d] Skip the billing — use DeepRouter instead (no credit card). Press before typing.",
|
|
224
519
|
apiKeyInvalid: (env: string) => `That doesn't look like a valid ${env}. Try again.`,
|
|
520
|
+
stepsHeader: "Where to click:",
|
|
521
|
+
providerSteps: {
|
|
522
|
+
anthropic: [
|
|
523
|
+
"1. Open in browser → console.anthropic.com",
|
|
524
|
+
"2. Sign in with Google, or sign up with email (free)",
|
|
525
|
+
"3. Top-right profile → Billing → Add credits → top up $5+ (card / Apple Pay)",
|
|
526
|
+
"4. Left menu → API Keys → \"Create Key\" → name it (e.g. kids)",
|
|
527
|
+
"5. Copy the sk-ant-… string from the popup, paste it here",
|
|
528
|
+
],
|
|
529
|
+
openai: [
|
|
530
|
+
"1. Open in browser → platform.openai.com/api-keys",
|
|
531
|
+
"2. Sign in with Google, or sign up with email",
|
|
532
|
+
"3. Left menu → Billing → Add to credit balance → top up $5+ (card)",
|
|
533
|
+
"4. Back to API Keys → \"+ Create new secret key\" → name it (e.g. kids)",
|
|
534
|
+
"5. Copy the sk- string from the popup (shown only once!), paste it here",
|
|
535
|
+
],
|
|
536
|
+
deeprouter: [
|
|
537
|
+
"1. Open in browser → deeprouter.ai (closed beta — invite code required)",
|
|
538
|
+
"2. Sign up with the invite code",
|
|
539
|
+
"3. Dashboard → API Keys → Create new key",
|
|
540
|
+
"4. Copy the key, paste it here",
|
|
541
|
+
],
|
|
542
|
+
},
|
|
225
543
|
saving: "Saving…",
|
|
226
544
|
errTitle: "Something went wrong",
|
|
227
545
|
errRetry: "[Enter] Try again",
|
|
228
546
|
doneTitle: "🎉 All set! Grown-up step done.",
|
|
229
|
-
doneNext: "
|
|
547
|
+
doneNext: "Starting up now.",
|
|
230
548
|
doneHint: "[Enter] Start",
|
|
231
549
|
},
|
|
232
550
|
} as const
|