@kidsinai/kids-client 0.0.2 → 0.0.3

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 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.2",
4
+ "version": "0.0.3",
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",
package/src/core/env.ts CHANGED
@@ -50,7 +50,7 @@ export function readEnv(): KidsClientEnv {
50
50
  }
51
51
  }
52
52
 
53
- export function validateEnv(env: KidsClientEnv): { ok: true } | { ok: false; reason: string; variant: "config_missing" | "auth_failed" } {
53
+ export function validateEnv(env: KidsClientEnv): { ok: true } | { ok: false; reason: string; variant: "config_missing" | "auth_failed" | "needs_setup" } {
54
54
  if (!env.opencodeServerPassword) {
55
55
  return {
56
56
  ok: false,
@@ -58,11 +58,18 @@ export function validateEnv(env: KidsClientEnv): { ok: true } | { ok: false; rea
58
58
  variant: "config_missing",
59
59
  }
60
60
  }
61
- if (!env.bypassGateway && !env.deeprouterApiKey) {
61
+ // Accept any supported provider's API key, not just DeepRouter. The
62
+ // setup wizard writes whatever the parent picked into ~/.config/kids-opencode/env
63
+ // which the wrapper sources before exec.
64
+ const hasAnyKey =
65
+ env.deeprouterApiKey
66
+ || process.env.ANTHROPIC_API_KEY
67
+ || process.env.OPENAI_API_KEY
68
+ if (!env.bypassGateway && !hasAnyKey) {
62
69
  return {
63
70
  ok: false,
64
- reason: "DEEPROUTER_API_KEY is empty. Run `kids-opencode register` first, or set KIDS_LLM_BYPASS_GATEWAY=1 with a provider key for dogfood.",
65
- variant: "auth_failed",
71
+ reason: "No LLM provider key found. The first-run setup wizard will walk you through this.",
72
+ variant: "needs_setup",
66
73
  }
67
74
  }
68
75
  return { ok: true }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * First-run setup wizard backend.
3
+ *
4
+ * Writes:
5
+ * ~/.config/kids-opencode/env (KEY=value, chmod 600)
6
+ * ~/.config/kids-opencode/opencode.json (provider + model rewritten)
7
+ *
8
+ * The env file is sourced by bin/kids-opencode before exec'ing
9
+ * kids-client, so the LLM key becomes available to the AI engine
10
+ * (which reads it via opencode.json's `{env:NAME}` interpolation) without
11
+ * polluting the user's shell rc.
12
+ */
13
+
14
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from "node:fs"
15
+ import { dirname, join } from "node:path"
16
+
17
+ export type ProviderId = "anthropic" | "openai" | "deeprouter"
18
+
19
+ export interface ProviderChoice {
20
+ id: ProviderId
21
+ label: string
22
+ hint: string
23
+ envVar: string
24
+ apiKeyUrl: string
25
+ /** opencode.json provider block to use. apiKey defaults to "{env:<envVar>}". */
26
+ config: (envVar: string) => Record<string, unknown>
27
+ /** Default model id for this provider. */
28
+ defaultModel: string
29
+ }
30
+
31
+ export const PROVIDERS: ProviderChoice[] = [
32
+ {
33
+ id: "anthropic",
34
+ label: "Anthropic Claude (recommended)",
35
+ hint: "Best for ages 12+. ~$5/month for typical kid use.",
36
+ envVar: "ANTHROPIC_API_KEY",
37
+ apiKeyUrl: "https://console.anthropic.com/settings/keys",
38
+ config: (env) => ({
39
+ anthropic: { apiKey: `{env:${env}}` },
40
+ }),
41
+ defaultModel: "anthropic/claude-3-5-sonnet-20241022",
42
+ },
43
+ {
44
+ id: "openai",
45
+ label: "OpenAI GPT-4",
46
+ hint: "Also works. ~$5-10/month for typical kid use.",
47
+ envVar: "OPENAI_API_KEY",
48
+ apiKeyUrl: "https://platform.openai.com/api-keys",
49
+ config: (env) => ({
50
+ openai: { apiKey: `{env:${env}}` },
51
+ }),
52
+ defaultModel: "openai/gpt-4o",
53
+ },
54
+ {
55
+ id: "deeprouter",
56
+ label: "DeepRouter (Airbotix's own gateway)",
57
+ hint: "Not yet live for public use; recommended for staff dogfood only.",
58
+ envVar: "DEEPROUTER_API_KEY",
59
+ apiKeyUrl: "https://app.airbotix.ai/portal/wallet",
60
+ config: (env) => ({
61
+ deeprouter: {
62
+ type: "openai-compatible",
63
+ baseURL: "https://api.deeprouter.ai/v1",
64
+ apiKey: `{env:${env}}`,
65
+ },
66
+ }),
67
+ defaultModel: "deeprouter/claude-3-5-sonnet",
68
+ },
69
+ ]
70
+
71
+ export function findProvider(id: ProviderId): ProviderChoice {
72
+ const p = PROVIDERS.find((p) => p.id === id)
73
+ if (!p) throw new Error(`unknown provider: ${id}`)
74
+ return p
75
+ }
76
+
77
+ export interface SaveOptions {
78
+ configDir: string
79
+ provider: ProviderId
80
+ apiKey: string
81
+ }
82
+
83
+ /**
84
+ * Persist the user's choice. Idempotent — re-running overwrites with the
85
+ * latest. The env file is line-based KEY=value; we preserve unrelated
86
+ * lines so multi-provider setups don't lose state.
87
+ */
88
+ export function saveSetup(opts: SaveOptions): void {
89
+ const provider = findProvider(opts.provider)
90
+ ensureConfigDir(opts.configDir)
91
+
92
+ // 1. Write the env file with the provider's key.
93
+ const envPath = join(opts.configDir, "env")
94
+ const existing = readEnvFile(envPath)
95
+ existing[provider.envVar] = opts.apiKey
96
+ writeEnvFile(envPath, existing)
97
+
98
+ // 2. Rewrite opencode.json provider section.
99
+ const configPath = join(opts.configDir, "opencode.json")
100
+ const config = readJsonOrEmpty(configPath)
101
+ config.provider = provider.config(provider.envVar)
102
+ config.model = provider.defaultModel
103
+ if (!config.permission) {
104
+ config.permission = {
105
+ default: "ask",
106
+ tools: { read: "ask", write: "ask", edit: "ask", glob: "ask", grep: "ask", webfetch: "ask" },
107
+ }
108
+ }
109
+ if (!config.agent) {
110
+ config.agent = { tools: ["read", "write", "edit", "glob", "grep", "webfetch"] }
111
+ }
112
+ if (!Array.isArray(config.plugin)) {
113
+ config.plugin = ["@kidsinai/kids-opencode-plugin"]
114
+ }
115
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8")
116
+ chmodSync(configPath, 0o600)
117
+ }
118
+
119
+ /** True if the user already has a valid key for any supported provider. */
120
+ export function hasAnyProviderKey(configDir: string): boolean {
121
+ const env = readEnvFile(join(configDir, "env"))
122
+ if (env.ANTHROPIC_API_KEY || env.OPENAI_API_KEY || env.DEEPROUTER_API_KEY) return true
123
+ // Also accept keys present in the parent shell env (advanced users).
124
+ return !!(
125
+ process.env.ANTHROPIC_API_KEY
126
+ || process.env.OPENAI_API_KEY
127
+ || process.env.DEEPROUTER_API_KEY
128
+ )
129
+ }
130
+
131
+ /** Crude check of API key shape — refuses obvious typos. */
132
+ export function looksLikeApiKey(provider: ProviderId, key: string): boolean {
133
+ const trimmed = key.trim()
134
+ if (trimmed.length < 20) return false
135
+ switch (provider) {
136
+ case "anthropic": return trimmed.startsWith("sk-ant-")
137
+ case "openai": return trimmed.startsWith("sk-") || trimmed.startsWith("sk-proj-")
138
+ case "deeprouter": return trimmed.length >= 24
139
+ }
140
+ }
141
+
142
+ // ─── internals ────────────────────────────────────────────────────────────
143
+
144
+ function ensureConfigDir(dir: string): void {
145
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
146
+ try { chmodSync(dir, 0o700) } catch { /* not fatal */ }
147
+ }
148
+
149
+ function readEnvFile(path: string): Record<string, string> {
150
+ if (!existsSync(path)) return {}
151
+ const out: Record<string, string> = {}
152
+ for (const raw of readFileSync(path, "utf8").split("\n")) {
153
+ const line = raw.trim()
154
+ if (!line || line.startsWith("#")) continue
155
+ const eq = line.indexOf("=")
156
+ if (eq <= 0) continue
157
+ const key = line.slice(0, eq).trim()
158
+ let value = line.slice(eq + 1).trim()
159
+ if (value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1)
160
+ out[key] = value
161
+ }
162
+ return out
163
+ }
164
+
165
+ function writeEnvFile(path: string, vars: Record<string, string>): void {
166
+ ensureConfigDir(dirname(path))
167
+ const lines: string[] = [
168
+ "# Generated by kids-opencode setup wizard. Edit at your own risk.",
169
+ "# The wrapper sources this file before launching the AI engine.",
170
+ ]
171
+ for (const [k, v] of Object.entries(vars)) {
172
+ lines.push(`${k}="${v}"`)
173
+ }
174
+ writeFileSync(path, lines.join("\n") + "\n", "utf8")
175
+ try { chmodSync(path, 0o600) } catch { /* not fatal */ }
176
+ }
177
+
178
+ function readJsonOrEmpty(path: string): Record<string, unknown> {
179
+ if (!existsSync(path)) return {}
180
+ try {
181
+ return JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>
182
+ } catch {
183
+ return {}
184
+ }
185
+ }
package/src/core/store.ts CHANGED
@@ -9,6 +9,7 @@
9
9
 
10
10
  export type Screen =
11
11
  | { kind: "loading"; message?: string }
12
+ | { kind: "setup" }
12
13
  | { kind: "startup" }
13
14
  | { kind: "mission" }
14
15
  | { kind: "help" }
package/src/index.tsx CHANGED
@@ -29,6 +29,7 @@ import { readLastSession, writeLastSession } from "./core/last-session.ts"
29
29
  import { isCompletionTrigger, runCheck } from "./core/check-runner.ts"
30
30
  import { App } from "./render/ink/App.tsx"
31
31
  import { detectDangerousTopicEn, detectDangerousTopicZh } from "./dangerous-topic-bridge.ts"
32
+ import { saveSetup, type ProviderId } from "./core/setup.ts"
32
33
  import type { InstalledPack } from "./core/course-pack.ts"
33
34
  import { findMission, loadCoursePack } from "@kidsinai/kids-opencode-plugin"
34
35
 
@@ -44,6 +45,14 @@ async function main(): Promise<void> {
44
45
 
45
46
  const check = validateEnv(env)
46
47
  if (!check.ok) {
48
+ if (check.variant === "needs_setup") {
49
+ // First-run wizard. Render the setup screen; the wizard's onSave
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
+ }
47
56
  store.update({ screen: { kind: "error", variant: check.variant, detail: check.reason } })
48
57
  renderApp(store, env, installedPacks, baseHandlers(store, env, null, null, null))
49
58
  return
@@ -197,6 +206,28 @@ interface AppHandlers {
197
206
  onPickerBack: () => void
198
207
  onMissionNext: () => void
199
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
+ }
200
231
  }
201
232
 
202
233
  /**
@@ -212,6 +243,7 @@ function baseHandlers(
212
243
  serve: ServeManager | null,
213
244
  ): AppHandlers {
214
245
  const noop = (): void => {}
246
+ const setup = makeSetupHandlers(store, env)
215
247
  return {
216
248
  onStart: noop,
217
249
  onPrompt: noop,
@@ -245,6 +277,7 @@ function baseHandlers(
245
277
  onPickerBack: () => store.update({ screen: { kind: "startup" } }),
246
278
  onMissionNext: noop,
247
279
  onMissionBack: noop,
280
+ ...setup,
248
281
  }
249
282
  }
250
283
 
@@ -447,6 +480,7 @@ function fullHandlers(
447
480
  })
448
481
  },
449
482
  onMissionBack: () => store.update({ screen: { kind: "mission" } }),
483
+ ...makeSetupHandlers(store, env),
450
484
  }
451
485
  }
452
486
 
@@ -21,6 +21,8 @@ import { HelpScreen } from "./screens/HelpScreen.tsx"
21
21
  import { CoursePackPicker } from "./screens/CoursePackPicker.tsx"
22
22
  import { MissionCompleteScreen } from "./screens/MissionCompleteScreen.tsx"
23
23
  import { LoadingScreen } from "./screens/LoadingScreen.tsx"
24
+ import { SetupScreen } from "./screens/SetupScreen.tsx"
25
+ import type { ProviderId } from "../../core/setup.ts"
24
26
 
25
27
  export interface AppDeps {
26
28
  store: Store
@@ -38,6 +40,8 @@ export interface AppDeps {
38
40
  onPickerBack: () => void
39
41
  onMissionNext: () => void
40
42
  onMissionBack: () => void
43
+ onSetupSave: (provider: ProviderId, apiKey: string) => Promise<{ ok: true } | { ok: false; reason: string }>
44
+ onSetupSkip: () => void
41
45
  }
42
46
 
43
47
  export function App(deps: AppDeps): React.ReactElement {
@@ -69,6 +73,8 @@ export function App(deps: AppDeps): React.ReactElement {
69
73
  switch (state.screen.kind) {
70
74
  case "loading":
71
75
  return <LoadingScreen locale={deps.locale} message={state.screen.message} />
76
+ case "setup":
77
+ return <SetupScreen locale={deps.locale} onSave={deps.onSetupSave} onSkip={deps.onSetupSkip} />
72
78
  case "startup":
73
79
  return <StartupScreen locale={deps.locale} coursePack={state.coursePack} onStart={deps.onStart} />
74
80
  case "mission":
@@ -0,0 +1,70 @@
1
+ /**
2
+ * "Kids OpenCode" ASCII art logo, color-matched to the brand mark.
3
+ *
4
+ * Brand colors (per the official logo):
5
+ * K — cyan/blue
6
+ * I — orange/yellow
7
+ * D — green
8
+ * S — magenta/purple
9
+ *
10
+ * Each letter is 6 rows × ~7-8 cols of block characters. We render row-by-row
11
+ * with separate <Text> colors per letter to get the multi-color effect Ink
12
+ * can't otherwise produce inside a single string.
13
+ */
14
+
15
+ import React from "react"
16
+ import { Box, Text } from "ink"
17
+ import { getTheme } from "../theme.ts"
18
+
19
+ // Block-letter rows. Each letter is a fixed-width column for clean alignment.
20
+ const ROWS: Array<{ K: string; I: string; D: string; S: string }> = [
21
+ { K: "██╗ ██╗", I: "██╗", D: "██████╗ ", S: "███████╗" },
22
+ { K: "██║ ██╔╝", I: "██║", D: "██╔══██╗", S: "██╔════╝" },
23
+ { K: "█████╔╝ ", I: "██║", D: "██║ ██║", S: "███████╗" },
24
+ { K: "██╔═██╗ ", I: "██║", D: "██║ ██║", S: "╚════██║" },
25
+ { K: "██║ ██╗", I: "██║", D: "██████╔╝", S: "███████║" },
26
+ { K: "╚═╝ ╚═╝", I: "╚═╝", D: "╚═════╝ ", S: "╚══════╝" },
27
+ ]
28
+
29
+ const SPARKLE_ROW_TOP = " ✦ ⭐ ✦ ⭐ "
30
+ const SPARKLE_ROW_BOTTOM = " ⭐ ✦ ⭐ ✦ "
31
+
32
+ export function KidsLogo(): React.ReactElement {
33
+ const theme = getTheme()
34
+ // Colors approximate the brand logo. Use "Bright" variants so dark terminals pop.
35
+ const cK = "cyanBright"
36
+ const cI = "yellow"
37
+ const cD = "greenBright"
38
+ const cS = "magentaBright"
39
+ const gap = " " // 2-col gap between letters
40
+ return (
41
+ <Box flexDirection="column" alignItems="center">
42
+ <Text color={theme.accent}>{SPARKLE_ROW_TOP}</Text>
43
+ {ROWS.map((row, i) => (
44
+ <Box key={i} flexDirection="row">
45
+ <Text color={cK}>{row.K}</Text>
46
+ <Text>{gap}</Text>
47
+ <Text color={cI}>{row.I}</Text>
48
+ <Text>{gap}</Text>
49
+ <Text color={cD}>{row.D}</Text>
50
+ <Text>{gap}</Text>
51
+ <Text color={cS}>{row.S}</Text>
52
+ </Box>
53
+ ))}
54
+ <Box marginTop={1}>
55
+ <Text color={cK} bold>{"<"}</Text>
56
+ <Text color={cK} bold>{" O"}</Text>
57
+ <Text color={cD} bold>{"p"}</Text>
58
+ <Text color={cD} bold>{"e"}</Text>
59
+ <Text color={cD} bold>{"n"}</Text>
60
+ <Text color={cD} bold>{"C"}</Text>
61
+ <Text color={cK} bold>{"o"}</Text>
62
+ <Text color={cS} bold>{"d"}</Text>
63
+ <Text color={cS} bold>{"e "}</Text>
64
+ <Text color={cS} bold>{">"}</Text>
65
+ </Box>
66
+ <Text color={cS}>{" ━━━━━━━━━━━━━ "}</Text>
67
+ <Text color={theme.accent}>{SPARKLE_ROW_BOTTOM}</Text>
68
+ </Box>
69
+ )
70
+ }
@@ -0,0 +1,232 @@
1
+ /**
2
+ * First-run setup wizard. Triggered when kids-client detects no LLM
3
+ * provider key is configured.
4
+ *
5
+ * Audience: a parent (the kid sees the intro and is told to grab a
6
+ * grown-up). The wizard walks through:
7
+ * 1. Welcome / "this part needs a grown-up"
8
+ * 2. Pick provider (Anthropic / OpenAI / DeepRouter)
9
+ * 3. Paste API key (with link to where to get one)
10
+ * 4. Save → re-validate → route to startup
11
+ *
12
+ * The choice is persisted via core/setup.ts (writes ~/.config/kids-opencode/env
13
+ * + updates opencode.json provider section).
14
+ */
15
+
16
+ import React, { useState } from "react"
17
+ import { Box, Text, useInput } from "ink"
18
+ import TextInput from "ink-text-input"
19
+ import { getTheme } from "../theme.ts"
20
+ import { KidsLogo } from "../components/KidsLogo.tsx"
21
+ import {
22
+ findProvider,
23
+ looksLikeApiKey,
24
+ PROVIDERS,
25
+ type ProviderId,
26
+ } from "../../../core/setup.ts"
27
+
28
+ type Step = "intro" | "provider" | "apikey" | "saving" | "done" | "error"
29
+
30
+ interface SetupScreenProps {
31
+ locale: "zh-Hans" | "en"
32
+ onSave: (provider: ProviderId, apiKey: string) => Promise<{ ok: true } | { ok: false; reason: string }>
33
+ onSkip: () => void
34
+ }
35
+
36
+ export function SetupScreen({ locale, onSave, onSkip }: SetupScreenProps): React.ReactElement {
37
+ const theme = getTheme()
38
+ const t = STRINGS[locale]
39
+ const [step, setStep] = useState<Step>("intro")
40
+ const [providerIdx, setProviderIdx] = useState(0)
41
+ const [apiKey, setApiKey] = useState("")
42
+ const [errorMsg, setErrorMsg] = useState("")
43
+
44
+ useInput((input, key) => {
45
+ if (step === "intro") {
46
+ if (key.return) setStep("provider")
47
+ else if (input === "s" || input === "S") onSkip()
48
+ } else if (step === "provider") {
49
+ if (key.upArrow) setProviderIdx((i) => Math.max(0, i - 1))
50
+ else if (key.downArrow) setProviderIdx((i) => Math.min(PROVIDERS.length - 1, i + 1))
51
+ else if (key.return) setStep("apikey")
52
+ else if (key.escape) setStep("intro")
53
+ } else if (step === "done") {
54
+ if (key.return) onSkip() // continue to startup
55
+ } else if (step === "error") {
56
+ if (key.return) setStep("apikey")
57
+ }
58
+ })
59
+
60
+ const provider = PROVIDERS[providerIdx]!
61
+ const providerObj = findProvider(provider.id)
62
+
63
+ if (step === "intro") {
64
+ return (
65
+ <Box flexDirection="column" alignItems="center" paddingY={1}>
66
+ <KidsLogo />
67
+ <Box marginTop={2} borderStyle="round" borderColor={theme.warn} paddingX={2} paddingY={1} flexDirection="column">
68
+ <Text color={theme.warn} bold>{t.introTitle}</Text>
69
+ <Box marginTop={1} flexDirection="column">
70
+ <Text color={theme.fg}>{t.introLine1}</Text>
71
+ <Text color={theme.fg}>{t.introLine2}</Text>
72
+ </Box>
73
+ <Box marginTop={1}>
74
+ <Text color={theme.fgDim}>{t.introCost}</Text>
75
+ </Box>
76
+ </Box>
77
+ <Box marginTop={1}>
78
+ <Text color={theme.accent}>{t.introContinue}</Text>
79
+ </Box>
80
+ </Box>
81
+ )
82
+ }
83
+
84
+ if (step === "provider") {
85
+ return (
86
+ <Box flexDirection="column" borderStyle="double" borderColor={theme.accent} paddingX={2} paddingY={1}>
87
+ <Text color={theme.accent} bold>{t.providerTitle}</Text>
88
+ <Box marginTop={1} flexDirection="column">
89
+ {PROVIDERS.map((p, i) => {
90
+ const active = i === providerIdx
91
+ return (
92
+ <Box key={p.id}>
93
+ <Text color={active ? theme.kid : theme.fg}>{active ? "▶ " : " "}</Text>
94
+ <Box flexDirection="column" flexGrow={1}>
95
+ <Text color={active ? theme.accent : theme.fg} bold={active}>{p.label}</Text>
96
+ <Text color={theme.fgDim} dimColor={!active}> {p.hint}</Text>
97
+ {active && <Text color={theme.fgDim}> {t.getKey}: {p.apiKeyUrl}</Text>}
98
+ </Box>
99
+ </Box>
100
+ )
101
+ })}
102
+ </Box>
103
+ <Box marginTop={1}>
104
+ <Text color={theme.accent}>{t.providerKeys}</Text>
105
+ </Box>
106
+ </Box>
107
+ )
108
+ }
109
+
110
+ if (step === "apikey") {
111
+ return (
112
+ <Box flexDirection="column" borderStyle="double" borderColor={theme.accent} paddingX={2} paddingY={1}>
113
+ <Text color={theme.accent} bold>{t.apiKeyTitle(providerObj.label)}</Text>
114
+ <Box marginTop={1} flexDirection="column">
115
+ <Text color={theme.fgDim}>{t.apiKeyHint(providerObj.apiKeyUrl)}</Text>
116
+ </Box>
117
+ <Box marginTop={1}>
118
+ <Text color={theme.kid}>🔑 </Text>
119
+ <TextInput
120
+ value={apiKey}
121
+ onChange={setApiKey}
122
+ onSubmit={(v) => {
123
+ const k = v.trim()
124
+ if (!looksLikeApiKey(provider.id, k)) {
125
+ setErrorMsg(t.apiKeyInvalid(providerObj.envVar))
126
+ setStep("error")
127
+ return
128
+ }
129
+ setStep("saving")
130
+ void onSave(provider.id, k).then((res) => {
131
+ if (res.ok) {
132
+ setStep("done")
133
+ } else {
134
+ setErrorMsg(res.reason)
135
+ setStep("error")
136
+ }
137
+ })
138
+ }}
139
+ placeholder={t.apiKeyPlaceholder(providerObj.envVar)}
140
+ mask="*"
141
+ />
142
+ </Box>
143
+ <Box marginTop={1}>
144
+ <Text color={theme.fgDim}>{t.apiKeyEnter}</Text>
145
+ </Box>
146
+ </Box>
147
+ )
148
+ }
149
+
150
+ if (step === "saving") {
151
+ return (
152
+ <Box paddingY={1} paddingX={2}>
153
+ <Text color={theme.accent}>{t.saving}</Text>
154
+ </Box>
155
+ )
156
+ }
157
+
158
+ if (step === "error") {
159
+ return (
160
+ <Box flexDirection="column" borderStyle="round" borderColor={theme.danger} paddingX={2} paddingY={1}>
161
+ <Text color={theme.danger} bold>{t.errTitle}</Text>
162
+ <Box marginTop={1}>
163
+ <Text color={theme.fg}>{errorMsg}</Text>
164
+ </Box>
165
+ <Box marginTop={1}>
166
+ <Text color={theme.accent}>{t.errRetry}</Text>
167
+ </Box>
168
+ </Box>
169
+ )
170
+ }
171
+
172
+ // step === "done"
173
+ return (
174
+ <Box flexDirection="column" alignItems="center" paddingY={1}>
175
+ <KidsLogo />
176
+ <Box marginTop={2} flexDirection="column" alignItems="center">
177
+ <Text color={theme.success} bold>{t.doneTitle}</Text>
178
+ <Box marginTop={1}>
179
+ <Text color={theme.fg}>{t.doneNext}</Text>
180
+ </Box>
181
+ <Box marginTop={1}>
182
+ <Text color={theme.accent}>{t.doneHint}</Text>
183
+ </Box>
184
+ </Box>
185
+ </Box>
186
+ )
187
+ }
188
+
189
+ const STRINGS = {
190
+ "zh-Hans": {
191
+ introTitle: "👋 这一步需要家长帮忙",
192
+ introLine1: "AI 老师要用一个 \"API key\" 才能工作 —— 就像给它一把钥匙。",
193
+ introLine2: "家长打开账号给 AI 服务(Anthropic / OpenAI 等),拿到 key 粘进来就行。",
194
+ introCost: "通常 ~$5/月,普通孩子用够了。",
195
+ introContinue: "[Enter] 让家长来 · [s] 暂时跳过",
196
+ providerTitle: "选一个 AI 服务",
197
+ providerKeys: "[↑↓] 选 · [Enter] 下一步 · [Esc] 返回",
198
+ getKey: "去拿 key",
199
+ apiKeyTitle: (label: string) => `输入 ${label} 的 API key`,
200
+ apiKeyHint: (url: string) => `没 key?打开浏览器:${url}`,
201
+ apiKeyPlaceholder: (env: string) => `${env}(粘进来后按 Enter)`,
202
+ apiKeyEnter: "[Enter] 保存 · 你的 key 只存在本地",
203
+ apiKeyInvalid: (env: string) => `这看起来不是有效的 ${env}。再试一次。`,
204
+ saving: "保存中…",
205
+ errTitle: "出了点问题",
206
+ errRetry: "[Enter] 再试",
207
+ doneTitle: "🎉 搞定!家长任务完成。",
208
+ doneNext: "你可以让孩子继续了。下一屏是启动屏。",
209
+ doneHint: "[Enter] 开始",
210
+ },
211
+ en: {
212
+ introTitle: "👋 Grown-up help needed for this part",
213
+ introLine1: "The AI teacher needs an \"API key\" to work — think of it as a password.",
214
+ introLine2: "A parent opens an account with an AI service (Anthropic / OpenAI), copies the key, pastes it here.",
215
+ introCost: "Usually ~$5/month for typical kid use.",
216
+ introContinue: "[Enter] Hand to a grown-up · [s] Skip for now",
217
+ providerTitle: "Pick an AI service",
218
+ providerKeys: "[↑↓] choose · [Enter] next · [Esc] back",
219
+ getKey: "Get key at",
220
+ apiKeyTitle: (label: string) => `Enter your ${label} API key`,
221
+ apiKeyHint: (url: string) => `Don't have a key yet? Open: ${url}`,
222
+ apiKeyPlaceholder: (env: string) => `${env} (paste then Enter)`,
223
+ apiKeyEnter: "[Enter] save · Your key stays on this machine.",
224
+ apiKeyInvalid: (env: string) => `That doesn't look like a valid ${env}. Try again.`,
225
+ saving: "Saving…",
226
+ errTitle: "Something went wrong",
227
+ errRetry: "[Enter] Try again",
228
+ doneTitle: "🎉 All set! Grown-up step done.",
229
+ doneNext: "You can hand it back to the kid now. Next screen is the welcome.",
230
+ doneHint: "[Enter] Start",
231
+ },
232
+ } as const
@@ -2,15 +2,16 @@
2
2
  * §3.1 Startup screen — first impression. Must paint within 5s.
3
3
  *
4
4
  * Quick keys:
5
- * Enter → start a free-play session
6
- * c → choose a Course Pack (V0 MVP: hardcoded portfolio-site)
7
- * r → resume the last session (V0 MVP: not implemented yet)
8
- * h → show help (kid-friendly)
5
+ * Enter → start a free-play session OR continue if a course pack is set
6
+ * c → choose a Course Pack
7
+ * r → resume the last session
8
+ * h → show kid-friendly help
9
9
  */
10
10
 
11
11
  import React from "react"
12
12
  import { Box, Text, useInput } from "ink"
13
13
  import { getTheme } from "../theme.ts"
14
+ import { KidsLogo } from "../components/KidsLogo.tsx"
14
15
  import { KeyHints } from "../components/KeyHints.tsx"
15
16
 
16
17
  interface StartupScreenProps {
@@ -29,23 +30,23 @@ export function StartupScreen({ locale, coursePack, onStart }: StartupScreenProp
29
30
  })
30
31
  const t = STRINGS[locale]
31
32
  return (
32
- <Box flexDirection="column" paddingY={1}>
33
- <Box borderStyle="double" borderColor={theme.border} paddingX={2} paddingY={1} flexDirection="column">
34
- <Text color={theme.accent} bold>
35
- Airbotix Kids OpenCode
36
- </Text>
37
- <Box marginTop={1} flexDirection="column">
38
- <Text color={theme.fg}>{t.greet1}</Text>
39
- <Text color={theme.fg}>{t.greet2}</Text>
40
- </Box>
41
- <Box marginTop={1}>
42
- <Text color={theme.fgDim}>{t.disclaim}</Text>
43
- </Box>
44
- <Box marginTop={1}>
45
- <Text color={theme.warn}>{t.helpline}</Text>
46
- </Box>
33
+ <Box flexDirection="column" alignItems="center" paddingY={1}>
34
+ <KidsLogo />
35
+ <Box marginTop={1} flexDirection="column" alignItems="center">
36
+ <Text color={theme.kid} bold>{t.tagline}</Text>
37
+ </Box>
38
+ <Box marginTop={1} flexDirection="column" alignItems="center">
39
+ <Text color={theme.fg}>{t.line1}</Text>
40
+ <Text color={theme.fg}>{t.line2}</Text>
41
+ <Text color={theme.fg}>{t.line3}</Text>
42
+ </Box>
43
+ <Box marginTop={1}>
44
+ <Text color={theme.fgDim}>{t.disclaim}</Text>
47
45
  </Box>
48
46
  <Box marginTop={1}>
47
+ <Text color={theme.warn} bold>{t.helpline}</Text>
48
+ </Box>
49
+ <Box marginTop={2}>
49
50
  <KeyHints hints={[
50
51
  { key: "Enter", label: coursePack ? t.startCourse : t.startFree },
51
52
  { key: "c", label: t.pickCourse },
@@ -59,10 +60,12 @@ export function StartupScreen({ locale, coursePack, onStart }: StartupScreenProp
59
60
 
60
61
  const STRINGS = {
61
62
  "zh-Hans": {
62
- greet1: "你好!我是 Kids OpenCode —— 帮你做编程项目的 AI 老师。",
63
- greet2: "",
64
- disclaim: "我不是真人,有时候会答错。遇到不懂的,问家长或老师。",
65
- helpline: "澳大利亚紧急求助:Kids Helpline 1800 55 1800",
63
+ tagline: "🤖 你的 AI 编程伙伴 🤖",
64
+ line1: "✨ 跟 AI 一起做真实的项目",
65
+ line2: "💡 没有工程师术语,听得懂",
66
+ line3: "🎯 做完一关 庆祝下一关",
67
+ disclaim: "我不是真人,有时候会答错。问家长或老师。",
68
+ helpline: "🇦🇺 紧急求助:Kids Helpline 1800 55 1800",
66
69
  startFree: "开始新项目",
67
70
  startCourse: "继续 Course Pack",
68
71
  pickCourse: "选 Course Pack",
@@ -70,10 +73,12 @@ const STRINGS = {
70
73
  help: "帮助",
71
74
  },
72
75
  en: {
73
- greet1: "Hi! I'm Kids OpenCode — the AI teacher who helps you build coding projects.",
74
- greet2: "",
75
- disclaim: "I'm not a real person and I can be wrong. Ask a parent or teacher if you're unsure.",
76
- helpline: "In Australia: Kids Helpline 1800 55 1800",
76
+ tagline: "🤖 Your AI coding buddy 🤖",
77
+ line1: "✨ Build real projects with AI help",
78
+ line2: "💡 No engineering jargon easy to follow",
79
+ line3: "🎯 Finish a mission celebrate the next one",
80
+ disclaim: "I'm not a real person and I can be wrong. Ask a parent or teacher.",
81
+ helpline: "🇦🇺 Emergency: Kids Helpline 1800 55 1800",
77
82
  startFree: "Start a new project",
78
83
  startCourse: "Continue Course Pack",
79
84
  pickCourse: "Pick a Course Pack",