@kidsinai/kids-client 0.0.4 → 0.0.6

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.4",
4
+ "version": "0.0.6",
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
@@ -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,
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 (Airbotix's own gateway)",
57
- hint: "Not yet live for public use; recommended for staff dogfood only.",
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://app.airbotix.ai/portal/wallet",
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
- const configPath = join(opts.configDir, "opencode.json")
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 = provider.config(provider.envVar)
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/core/store.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  export type Screen =
11
11
  | { kind: "loading"; message?: string }
12
12
  | { kind: "setup" }
13
+ | { kind: "tour" }
13
14
  | { kind: "startup" }
14
15
  | { kind: "mission" }
15
16
  | { kind: "help" }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * First-run welcome-tour marker. File existence at
3
+ * `<configDir>/tour-seen` means the kid has been through the intro at
4
+ * least once; the next launch goes straight to StartupScreen.
5
+ *
6
+ * The tour only fires when (a) marker absent AND (b) the SetupScreen
7
+ * just ran. Returning users who inherited env vars skip it.
8
+ */
9
+
10
+ import { existsSync, writeFileSync, chmodSync } from "node:fs"
11
+ import { join } from "node:path"
12
+
13
+ const FILE_NAME = "tour-seen"
14
+
15
+ export function hasSeenTour(configDir: string): boolean {
16
+ return existsSync(join(configDir, FILE_NAME))
17
+ }
18
+
19
+ export function markTourSeen(configDir: string): void {
20
+ const path = join(configDir, FILE_NAME)
21
+ try {
22
+ writeFileSync(path, new Date().toISOString() + "\n", "utf8")
23
+ chmodSync(path, 0o600)
24
+ } catch {
25
+ /* non-fatal — re-show is a feature, not a bug */
26
+ }
27
+ }
package/src/index.tsx CHANGED
@@ -32,8 +32,9 @@ import { readLastSession, writeLastSession } from "./core/last-session.ts"
32
32
  import { isCompletionTrigger, runCheck } from "./core/check-runner.ts"
33
33
  import { App } from "./render/ink/App.tsx"
34
34
  import { detectDangerousTopicEn, detectDangerousTopicZh } from "./dangerous-topic-bridge.ts"
35
- import { saveSetup, type ProviderId } from "./core/setup.ts"
35
+ import { OAUTH_HANDOFF_EXIT_CODE, saveSetup, saveSetupOauth, type ProviderId } from "./core/setup.ts"
36
36
  import { reloadEnvFile } from "./core/env-reload.ts"
37
+ import { hasSeenTour, markTourSeen } from "./core/tour-marker.ts"
37
38
  import type { InstalledPack } from "./core/course-pack.ts"
38
39
  import { loadCoursePack } from "@kidsinai/kids-opencode-plugin"
39
40
 
@@ -73,6 +74,8 @@ interface AppHandlers {
73
74
  onSetupSave: (provider: ProviderId, apiKey: string) => Promise<{ ok: true } | { ok: false; reason: string }>
74
75
  onSetupContinue: () => Promise<void>
75
76
  onSetupSkip: () => void
77
+ onSetupOAuthHandoff: (provider: ProviderId) => Promise<void>
78
+ onTourDone: () => void
76
79
  }
77
80
 
78
81
  async function main(): Promise<void> {
@@ -97,17 +100,24 @@ async function main(): Promise<void> {
97
100
  let resolveSetup: (() => void) | null = null
98
101
  const setupGate = new Promise<void>((r) => { resolveSetup = r })
99
102
 
103
+ // Same pattern for the first-run welcome tour. Only awaited if we actually
104
+ // route to it (returning users with valid env skip both setup and tour).
105
+ let resolveTour: (() => void) | null = null
106
+ const tourGate = new Promise<void>((r) => { resolveTour = r })
107
+
100
108
  const handlers: AppHandlers = makeHandlers(store, env, servicesHolder, resolveSetupFn => {
101
109
  resolveSetup = resolveSetupFn
102
- }, () => resolveSetup)
110
+ }, () => resolveSetup, () => resolveTour)
103
111
 
104
112
  renderApp(store, env, installedPacks, handlers)
105
113
 
106
114
  // First validation pass.
107
115
  let check = validateEnv(env)
116
+ let didSetup = false
108
117
  if (!check.ok && check.variant === "needs_setup") {
109
118
  store.update({ screen: { kind: "setup" } })
110
119
  await setupGate
120
+ didSetup = true
111
121
 
112
122
  // Re-source env file (the setup wizard wrote it).
113
123
  reloadEnvFile(env.configDir)
@@ -121,6 +131,14 @@ async function main(): Promise<void> {
121
131
  return
122
132
  }
123
133
 
134
+ // First-run tour: only fires if the kid just went through setup AND hasn't
135
+ // seen the tour. Returning users with inherited env vars skip it entirely.
136
+ if (didSetup && !hasSeenTour(env.configDir)) {
137
+ store.update({ screen: { kind: "tour" } })
138
+ await tourGate
139
+ markTourSeen(env.configDir)
140
+ }
141
+
124
142
  // Bootstrap services in-process. Loading screen is shown while we wait.
125
143
  store.update({
126
144
  screen: {
@@ -152,6 +170,7 @@ function makeHandlers(
152
170
  servicesHolder: { current: ServiceSet | null },
153
171
  _setResolveSetup: (fn: (() => void) | null) => void,
154
172
  getResolveSetup: () => (() => void) | null,
173
+ getResolveTour: () => (() => void) | null,
155
174
  ): AppHandlers {
156
175
  const ifBooted = <A extends unknown[]>(fn: (s: ServiceSet, ...args: A) => unknown) => (...args: A) => {
157
176
  const s = servicesHolder.current
@@ -197,6 +216,21 @@ function makeHandlers(
197
216
  const r = getResolveSetup()
198
217
  if (r) r()
199
218
  },
219
+ onSetupOAuthHandoff: async (provider) => {
220
+ try {
221
+ saveSetupOauth({ configDir: env.configDir, provider })
222
+ } catch (err) {
223
+ console.error("kids-client: OAuth handoff prep failed:", err)
224
+ process.exit(1)
225
+ }
226
+ // Hand the TTY to bin/kids-opencode so it can run
227
+ // `opencode auth login --provider <p>` interactively, then re-exec us.
228
+ process.exit(OAUTH_HANDOFF_EXIT_CODE)
229
+ },
230
+ onTourDone: () => {
231
+ const r = getResolveTour()
232
+ if (r) r()
233
+ },
200
234
  }
201
235
  }
202
236
 
@@ -274,7 +308,8 @@ async function bootServices(env: KidsClientEnv, store: Store): Promise<ServiceSe
274
308
  })
275
309
  },
276
310
  onLlmError: (e) => {
277
- store.update({ thinking: false, screen: { kind: "error", variant: "network_down", detail: e.message } })
311
+ const variant = classifyLlmError(e.message)
312
+ store.update({ thinking: false, screen: { kind: "error", variant, detail: e.message } })
278
313
  },
279
314
  onCompactionEnded: () => {
280
315
  flashToast(store, {
@@ -548,7 +583,38 @@ function handlePluginAudit(event: unknown, store: Store): void {
548
583
  const snap = store.getSnapshot()
549
584
  const newBalance = Math.max(0, snap.starsBalance - e.stars_charged)
550
585
  store.update({ starsBalance: newBalance })
586
+ // Preemptive switch the moment the balance lands at zero — kid sees the
587
+ // friendly "out of stars" screen before the next tool call fails server-side.
588
+ if (newBalance === 0 && snap.starsBudget > 0 && snap.screen.kind === "mission") {
589
+ store.update({
590
+ screen: { kind: "error", variant: "stars_exhausted" },
591
+ thinking: false,
592
+ })
593
+ }
594
+ }
595
+ }
596
+
597
+ /**
598
+ * Pattern-match the LLM/plugin error message against known billing failures
599
+ * so the kid lands on the friendly "out of stars" screen instead of the
600
+ * generic "network down" one. The plugin / DeepRouter wraps these with
601
+ * codes like WALLET_INSUFFICIENT / FAMILY_PAUSED (platform-backend §7) or
602
+ * plain English ("insufficient credits", "rate limit", "402").
603
+ */
604
+ function classifyLlmError(msg: string): "stars_exhausted" | "network_down" {
605
+ const m = msg.toLowerCase()
606
+ if (
607
+ m.includes("wallet_insufficient")
608
+ || m.includes("family_paused")
609
+ || m.includes("insufficient")
610
+ || m.includes("out of credit")
611
+ || m.includes("out of stars")
612
+ || m.includes("quota")
613
+ || m.includes("402")
614
+ ) {
615
+ return "stars_exhausted"
551
616
  }
617
+ return "network_down"
552
618
  }
553
619
 
554
620
  const TOAST_TTL_MS = 3500
@@ -22,6 +22,7 @@ import { CoursePackPicker } from "./screens/CoursePackPicker.tsx"
22
22
  import { MissionCompleteScreen } from "./screens/MissionCompleteScreen.tsx"
23
23
  import { LoadingScreen } from "./screens/LoadingScreen.tsx"
24
24
  import { SetupScreen } from "./screens/SetupScreen.tsx"
25
+ import { TourScreen } from "./screens/TourScreen.tsx"
25
26
  import type { ProviderId } from "../../core/setup.ts"
26
27
 
27
28
  export interface AppDeps {
@@ -43,6 +44,8 @@ export interface AppDeps {
43
44
  onSetupSave: (provider: ProviderId, apiKey: string) => Promise<{ ok: true } | { ok: false; reason: string }>
44
45
  onSetupContinue: () => Promise<void>
45
46
  onSetupSkip: () => void
47
+ onSetupOAuthHandoff: (provider: ProviderId) => Promise<void>
48
+ onTourDone: () => void
46
49
  }
47
50
 
48
51
  export function App(deps: AppDeps): React.ReactElement {
@@ -75,7 +78,9 @@ export function App(deps: AppDeps): React.ReactElement {
75
78
  case "loading":
76
79
  return <LoadingScreen locale={deps.locale} message={state.screen.message} />
77
80
  case "setup":
78
- return <SetupScreen locale={deps.locale} onSave={deps.onSetupSave} onContinue={deps.onSetupContinue} onSkip={deps.onSetupSkip} />
81
+ return <SetupScreen locale={deps.locale} onSave={deps.onSetupSave} onContinue={deps.onSetupContinue} onSkip={deps.onSetupSkip} onOAuthHandoff={deps.onSetupOAuthHandoff} />
82
+ case "tour":
83
+ return <TourScreen locale={deps.locale} onDone={deps.onTourDone} />
79
84
  case "startup":
80
85
  return <StartupScreen locale={deps.locale} coursePack={state.coursePack} onStart={deps.onStart} />
81
86
  case "mission":
@@ -78,8 +78,8 @@ const STRINGS = {
78
78
  },
79
79
  stars_exhausted: {
80
80
  title: "今天的 ⭐ 用完了",
81
- body: "明天再来,或者请家长在 airbotix.ai/portal/wallet 充值。",
82
- retry: "重试",
81
+ body: "今天先到这里啦!\n你做得很好,我们明天接着来。\n或者找家长打开 airbotix.ai/portal/wallet 多充一点 ⭐,然后按 Enter 接着做。",
82
+ retry: "找完家长了,再试一次",
83
83
  },
84
84
  auth_failed: {
85
85
  title: "AI 老师认不出你",
@@ -111,8 +111,8 @@ const STRINGS = {
111
111
  },
112
112
  stars_exhausted: {
113
113
  title: "Out of ⭐ for today",
114
- body: "Come back tomorrow, or ask a parent to top up at airbotix.ai/portal/wallet.",
115
- retry: "Retry",
114
+ body: "Great work today!\nWe'll pick this up tomorrow.\nOr ask a parent to top up at airbotix.ai/portal/wallet, then press Enter to keep going.",
115
+ retry: "Asked a parent — try again",
116
116
  },
117
117
  auth_failed: {
118
118
  title: "AI doesn't recognise you",
@@ -7,6 +7,11 @@
7
7
  * e → close modal, send a fresh kid prompt of the form "no, do it differently"
8
8
  *
9
9
  * The PRD uses "y/n/e" to avoid the engineering-vibes of allow/deny.
10
+ *
11
+ * For write/edit tools we now render a short preview of the exact file
12
+ * change the kid is about to approve. Black-box `y` was a trust-without-
13
+ * verify hole — this closes it AND doubles as a learning surface (the kid
14
+ * sees what code the AI is producing).
10
15
  */
11
16
 
12
17
  import React from "react"
@@ -22,6 +27,8 @@ interface PermissionModalProps {
22
27
  onEdit: () => void
23
28
  }
24
29
 
30
+ const DIFF_MAX_LINES = 14
31
+
25
32
  export function PermissionModal({ permission, locale, onAllow, onDeny, onEdit }: PermissionModalProps): React.ReactElement {
26
33
  const theme = getTheme()
27
34
  useInput((input) => {
@@ -31,6 +38,7 @@ export function PermissionModal({ permission, locale, onAllow, onDeny, onEdit }:
31
38
  else if (ch === "e") onEdit()
32
39
  })
33
40
  const t = STRINGS[locale]
41
+ const preview = extractFilePreview(permission)
34
42
  return (
35
43
  <Box flexDirection="column" borderStyle="double" borderColor={theme.warn} paddingX={2} paddingY={1}>
36
44
  <Text color={theme.warn} bold>{t.title}</Text>
@@ -42,6 +50,22 @@ export function PermissionModal({ permission, locale, onAllow, onDeny, onEdit }:
42
50
  <Text color={theme.fgDim}>tool: {permission.tool}</Text>
43
51
  </Box>
44
52
  )}
53
+ {preview && (
54
+ <Box marginTop={1} flexDirection="column" borderStyle="single" borderColor={theme.fgDim} paddingX={1}>
55
+ <Box>
56
+ <Text color={theme.accent} bold>{preview.kind === "edit" ? t.editingFile : t.writingFile}: </Text>
57
+ <Text color={theme.fg}>{preview.path}</Text>
58
+ </Box>
59
+ <Box marginTop={1} flexDirection="column">
60
+ {renderDiffLines(preview, theme)}
61
+ </Box>
62
+ {preview.truncated > 0 && (
63
+ <Box marginTop={1}>
64
+ <Text color={theme.fgDim} dimColor>{t.truncated(preview.truncated)}</Text>
65
+ </Box>
66
+ )}
67
+ </Box>
68
+ )}
45
69
  {permission.starsEstimated && permission.starsEstimated > 0 && (
46
70
  <Box marginTop={1}>
47
71
  <Text color={theme.stars}>{t.starsCost(permission.starsEstimated)}</Text>
@@ -59,6 +83,82 @@ export function PermissionModal({ permission, locale, onAllow, onDeny, onEdit }:
59
83
  )
60
84
  }
61
85
 
86
+ type FilePreview =
87
+ | {
88
+ kind: "write"
89
+ path: string
90
+ lines: string[]
91
+ truncated: number
92
+ }
93
+ | {
94
+ kind: "edit"
95
+ path: string
96
+ removed: string[]
97
+ added: string[]
98
+ truncated: number
99
+ }
100
+
101
+ /**
102
+ * opencode write tool metadata: { filePath/path, content }
103
+ * opencode edit tool metadata: { filePath/path, oldString/old_text, newString/new_text }
104
+ * Defensive shape extraction — the SDK exposes these as Record<string, unknown>.
105
+ */
106
+ function extractFilePreview(permission: PendingPermission): FilePreview | null {
107
+ if (!permission.tool) return null
108
+ const tool = permission.tool.toLowerCase()
109
+ const m = permission.metadata ?? {}
110
+ const path = pickString(m, ["filePath", "path", "file_path", "filename"])
111
+ if (!path) return null
112
+
113
+ if (tool === "write") {
114
+ const content = pickString(m, ["content", "text", "newContent", "new_content"])
115
+ if (content === null) return null
116
+ const all = content.split("\n")
117
+ const shown = all.slice(0, DIFF_MAX_LINES)
118
+ return { kind: "write", path, lines: shown, truncated: Math.max(0, all.length - shown.length) }
119
+ }
120
+
121
+ if (tool === "edit") {
122
+ const oldStr = pickString(m, ["oldString", "old_string", "oldText", "old_text"]) ?? ""
123
+ const newStr = pickString(m, ["newString", "new_string", "newText", "new_text"]) ?? ""
124
+ const removed = oldStr.split("\n").filter((l, i, arr) => !(i === arr.length - 1 && l === ""))
125
+ const added = newStr.split("\n").filter((l, i, arr) => !(i === arr.length - 1 && l === ""))
126
+ const totalLines = removed.length + added.length
127
+ if (totalLines === 0) return null
128
+ const half = Math.max(1, Math.floor(DIFF_MAX_LINES / 2))
129
+ const removedShown = removed.slice(0, half)
130
+ const addedShown = added.slice(0, DIFF_MAX_LINES - removedShown.length)
131
+ const truncated = (removed.length - removedShown.length) + (added.length - addedShown.length)
132
+ return { kind: "edit", path, removed: removedShown, added: addedShown, truncated }
133
+ }
134
+
135
+ return null
136
+ }
137
+
138
+ function renderDiffLines(preview: FilePreview, theme: ReturnType<typeof getTheme>): React.ReactElement[] {
139
+ if (preview.kind === "write") {
140
+ return preview.lines.map((line, i) => (
141
+ <Text key={`w-${i}`} color={theme.success}>{`+ ${line}`}</Text>
142
+ ))
143
+ }
144
+ const out: React.ReactElement[] = []
145
+ preview.removed.forEach((line, i) => out.push(
146
+ <Text key={`r-${i}`} color={theme.danger}>{`- ${line}`}</Text>
147
+ ))
148
+ preview.added.forEach((line, i) => out.push(
149
+ <Text key={`a-${i}`} color={theme.success}>{`+ ${line}`}</Text>
150
+ ))
151
+ return out
152
+ }
153
+
154
+ function pickString(obj: Record<string, unknown>, keys: string[]): string | null {
155
+ for (const k of keys) {
156
+ const v = obj[k]
157
+ if (typeof v === "string") return v
158
+ }
159
+ return null
160
+ }
161
+
62
162
  const STRINGS = {
63
163
  "zh-Hans": {
64
164
  title: "AI 想做这件事",
@@ -66,6 +166,9 @@ const STRINGS = {
66
166
  no: "不要",
67
167
  edit: "我来改",
68
168
  starsCost: (n: number) => `预估消耗 ${n}⭐`,
169
+ writingFile: "新建文件",
170
+ editingFile: "改这个文件",
171
+ truncated: (n: number) => `… 还有 ${n} 行没显示。完整改动会在文件里。`,
69
172
  },
70
173
  en: {
71
174
  title: "The AI wants to do this",
@@ -73,5 +176,8 @@ const STRINGS = {
73
176
  no: "Stop",
74
177
  edit: "I'll do it",
75
178
  starsCost: (n: number) => `Estimated cost: ${n}⭐`,
179
+ writingFile: "Creating file",
180
+ editingFile: "Editing file",
181
+ truncated: (n: number) => `… ${n} more lines not shown. Full change lands in the file.`,
76
182
  },
77
183
  } as const
@@ -20,6 +20,7 @@ import { KidsLogo } from "../components/KidsLogo.tsx"
20
20
  import {
21
21
  findProvider,
22
22
  looksLikeApiKey,
23
+ OAUTH_PROVIDERS,
23
24
  PROVIDERS,
24
25
  type ProviderId,
25
26
  } from "../../../core/setup.ts"
@@ -30,6 +31,8 @@ type Step =
30
31
  | "engine_done"
31
32
  | "intro"
32
33
  | "provider"
34
+ | "auth_choice"
35
+ | "oauth_handoff"
33
36
  | "apikey"
34
37
  | "saving"
35
38
  | "done"
@@ -42,14 +45,21 @@ interface SetupScreenProps {
42
45
  onContinue: () => Promise<void>
43
46
  /** Skip key — useful for advanced users who set env vars themselves. */
44
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>
45
54
  }
46
55
 
47
- export function SetupScreen({ locale, onSave, onContinue, onSkip }: SetupScreenProps): React.ReactElement {
56
+ export function SetupScreen({ locale, onSave, onContinue, onSkip, onOAuthHandoff }: SetupScreenProps): React.ReactElement {
48
57
  const theme = getTheme()
49
58
  const t = STRINGS[locale]
50
59
  const initialStep: Step = hasOpencodeBinary() ? "intro" : "engine_install"
51
60
  const [step, setStep] = useState<Step>(initialStep)
52
61
  const [providerIdx, setProviderIdx] = useState(0)
62
+ const [authChoiceIdx, setAuthChoiceIdx] = useState(0) // 0 = subscription, 1 = api key
53
63
  const [apiKey, setApiKey] = useState("")
54
64
  const [errorMsg, setErrorMsg] = useState("")
55
65
  const [engineLog, setEngineLog] = useState<string[]>([])
@@ -86,8 +96,54 @@ export function SetupScreen({ locale, onSave, onContinue, onSkip }: SetupScreenP
86
96
  } else if (step === "provider") {
87
97
  if (key.upArrow) setProviderIdx((i) => Math.max(0, i - 1))
88
98
  else if (key.downArrow) setProviderIdx((i) => Math.min(PROVIDERS.length - 1, i + 1))
89
- else if (key.return) setStep("apikey")
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
+ }
90
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
+ }
91
147
  } else if (step === "done") {
92
148
  if (key.return) {
93
149
  // Inline boot — no exit.
@@ -207,13 +263,68 @@ export function SetupScreen({ locale, onSave, onContinue, onSkip }: SetupScreenP
207
263
  )
208
264
  }
209
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
+
210
314
  if (step === "apikey") {
315
+ const steps = t.providerSteps[provider.id]
211
316
  return (
212
317
  <Box flexDirection="column" borderStyle="double" borderColor={theme.accent} paddingX={2} paddingY={1}>
213
318
  <Text color={theme.accent} bold>{t.apiKeyTitle(providerObj.label)}</Text>
214
319
  <Box marginTop={1} flexDirection="column">
215
320
  <Text color={theme.fgDim}>{t.apiKeyHint(providerObj.apiKeyUrl)}</Text>
216
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>
217
328
  <Box marginTop={1}>
218
329
  <Text color={theme.kid}>🔑 </Text>
219
330
  <TextInput
@@ -243,6 +354,14 @@ export function SetupScreen({ locale, onSave, onContinue, onSkip }: SetupScreenP
243
354
  <Box marginTop={1}>
244
355
  <Text color={theme.fgDim}>{t.apiKeyEnter}</Text>
245
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
+ )}
246
365
  </Box>
247
366
  )
248
367
  }
@@ -303,11 +422,55 @@ const STRINGS = {
303
422
  providerTitle: "选一个 AI 服务",
304
423
  providerKeys: "[↑↓] 选 · [Enter] 下一步 · [Esc] 返回",
305
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
+ },
306
444
  apiKeyTitle: (label: string) => `输入 ${label} 的 API key`,
307
445
  apiKeyHint: (url: string) => `没 key?打开浏览器:${url}`,
308
446
  apiKeyPlaceholder: (env: string) => `${env}(粘进来后按 Enter)`,
309
447
  apiKeyEnter: "[Enter] 保存 · 你的 key 只存在本地",
448
+ apiKeyBack: "[Esc] 选错了?回去重选",
449
+ apiKeyToDR: "[d] 不想充值?改用 DeepRouter — 无需信用卡(输入前按)",
310
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
+ },
311
474
  saving: "保存中…",
312
475
  errTitle: "出了点问题",
313
476
  errRetry: "[Enter] 再试",
@@ -328,11 +491,55 @@ const STRINGS = {
328
491
  providerTitle: "Pick an AI service",
329
492
  providerKeys: "[↑↓] choose · [Enter] next · [Esc] back",
330
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
+ },
331
513
  apiKeyTitle: (label: string) => `Enter your ${label} API key`,
332
514
  apiKeyHint: (url: string) => `Don't have a key yet? Open: ${url}`,
333
515
  apiKeyPlaceholder: (env: string) => `${env} (paste then Enter)`,
334
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.",
335
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
+ },
336
543
  saving: "Saving…",
337
544
  errTitle: "Something went wrong",
338
545
  errRetry: "[Enter] Try again",
@@ -0,0 +1,155 @@
1
+ /**
2
+ * First-run welcome tour — 3 steps between SetupScreen and StartupScreen.
3
+ *
4
+ * Triggered exactly once per install (gated by ~/.config/kids-opencode/tour-seen).
5
+ * A returning kid who already has env vars set skips this entirely; only
6
+ * fires when the wizard just ran. Skip ([s]) marks-seen the same as Done.
7
+ *
8
+ * Step content maps to the three things a 12-yo first-timer needs to know
9
+ * before they look at the StartupScreen and freeze:
10
+ * 1. What is a Course Pack?
11
+ * 2. How do I talk to the AI?
12
+ * 3. What do y / n / e mean when the AI asks permission?
13
+ */
14
+
15
+ import React, { useState } from "react"
16
+ import { Box, Text, useInput } from "ink"
17
+ import { getTheme } from "../theme.ts"
18
+ import { KidsLogo } from "../components/KidsLogo.tsx"
19
+
20
+ interface TourScreenProps {
21
+ locale: "zh-Hans" | "en"
22
+ onDone: () => void
23
+ }
24
+
25
+ export function TourScreen({ locale, onDone }: TourScreenProps): React.ReactElement {
26
+ const theme = getTheme()
27
+ const t = STRINGS[locale]
28
+ const [step, setStep] = useState(0)
29
+ const last = t.steps.length - 1
30
+
31
+ useInput((input, key) => {
32
+ if (input === "s" || input === "S") {
33
+ onDone()
34
+ return
35
+ }
36
+ if (key.return || key.rightArrow) {
37
+ if (step >= last) onDone()
38
+ else setStep((s) => Math.min(last, s + 1))
39
+ return
40
+ }
41
+ if (key.leftArrow) {
42
+ setStep((s) => Math.max(0, s - 1))
43
+ }
44
+ })
45
+
46
+ const current = t.steps[step]!
47
+ const isLast = step === last
48
+
49
+ return (
50
+ <Box flexDirection="column" alignItems="center" paddingY={1}>
51
+ <KidsLogo />
52
+ <Box marginTop={1}>
53
+ <Text color={theme.fgDim}>{t.progress(step + 1, t.steps.length)}</Text>
54
+ </Box>
55
+ <Box marginTop={1} borderStyle="round" borderColor={theme.accent} paddingX={3} paddingY={1} flexDirection="column" width={68}>
56
+ <Text color={theme.accent} bold>{current.title}</Text>
57
+ <Box marginTop={1} flexDirection="column">
58
+ {current.body.map((line, i) => (
59
+ <Text key={i} color={theme.fg}>{line}</Text>
60
+ ))}
61
+ </Box>
62
+ </Box>
63
+ <Box marginTop={2}>
64
+ <Text color={theme.accent}>{isLast ? t.startNow : t.next}</Text>
65
+ <Text color={theme.fg}> · </Text>
66
+ <Text color={theme.fgDim}>{t.skip}</Text>
67
+ </Box>
68
+ </Box>
69
+ )
70
+ }
71
+
72
+ const STRINGS = {
73
+ "zh-Hans": {
74
+ progress: (cur: number, total: number) => `${cur} / ${total}`,
75
+ next: "[Enter] 下一步 [←] 上一步",
76
+ startNow: "[Enter] 开始玩 [←] 上一步",
77
+ skip: "[s] 跳过引导",
78
+ steps: [
79
+ {
80
+ title: "📦 什么是 Course Pack?",
81
+ body: [
82
+ "Course Pack 是老师为你设计好的一组小项目。",
83
+ "比如:「做一个网站介绍你的小狗」、「画一只 AI 恐龙」。",
84
+ "",
85
+ "每个 Pack 里有几个 Mission,一步一步带你完成。",
86
+ "做完一个 Mission,✨ 就解锁下一个。",
87
+ ],
88
+ },
89
+ {
90
+ title: "💬 怎么跟我说话?",
91
+ body: [
92
+ "直接打中文或英文,按 Enter 发给我。比如:",
93
+ " 「帮我做一个红色按钮」",
94
+ " 「换一个更可爱的字体」",
95
+ "",
96
+ "想验收一关:打 「我做完了」 或 /check",
97
+ "想叫我停下来:按 Esc",
98
+ ],
99
+ },
100
+ {
101
+ title: "🔐 y / n / e — 你说了算",
102
+ body: [
103
+ "我要动你的文件之前,会先问你。三个按键:",
104
+ "",
105
+ " [y] 可以做 — 我一次操作",
106
+ " [n] 不要 — 我会停下来,换个办法",
107
+ " [e] 我来改 — 你自己写这一步,告诉我你想怎么做",
108
+ "",
109
+ "我永远不会绕过这一步偷偷改文件。",
110
+ ],
111
+ },
112
+ ],
113
+ },
114
+ en: {
115
+ progress: (cur: number, total: number) => `${cur} / ${total}`,
116
+ next: "[Enter] next [←] back",
117
+ startNow: "[Enter] start playing [←] back",
118
+ skip: "[s] skip tour",
119
+ steps: [
120
+ {
121
+ title: "📦 What's a Course Pack?",
122
+ body: [
123
+ "A Course Pack is a set of small projects a teacher made for you.",
124
+ "Like: \"Build a website about your dog\" or \"Draw an AI dinosaur\".",
125
+ "",
126
+ "Each Pack has a few Missions, walking you through step by step.",
127
+ "Finish one Mission, ✨ unlock the next.",
128
+ ],
129
+ },
130
+ {
131
+ title: "💬 How do I talk to you?",
132
+ body: [
133
+ "Just type in English or Chinese and press Enter. For example:",
134
+ " \"Make me a red button\"",
135
+ " \"Use a cuter font\"",
136
+ "",
137
+ "To check off a mission: type \"I'm done\" or /check",
138
+ "To stop me mid-reply: press Esc",
139
+ ],
140
+ },
141
+ {
142
+ title: "🔐 y / n / e — you're in charge",
143
+ body: [
144
+ "Before I touch your files I'll ask you. Three keys:",
145
+ "",
146
+ " [y] go ahead — I'll do it once",
147
+ " [n] stop — I'll back off and try another way",
148
+ " [e] I'll do it — you write this step and tell me what you want",
149
+ "",
150
+ "I'll never sneak past this to change files on my own.",
151
+ ],
152
+ },
153
+ ],
154
+ },
155
+ } as const