@kidsinai/kids-client 0.0.11 → 0.0.12

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.11",
4
+ "version": "0.0.12",
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
@@ -37,6 +37,12 @@ export interface KidsClientEnv {
37
37
  configDir: string
38
38
  /** When true, the client renders a "Tony banner" / suppresses interactive prompts (CI). */
39
39
  noBanner: boolean
40
+ /**
41
+ * Airbotix Portal base URL — used by the [w] Wallet / Top-up shortcut to
42
+ * deep-link parents into login + Airwallex top-up. Defaults to
43
+ * https://app.airbotix.ai; staging overrides via AIRBOTIX_PORTAL_URL.
44
+ */
45
+ portalBaseUrl: string
40
46
  }
41
47
 
42
48
  export function readEnv(): KidsClientEnv {
@@ -55,6 +61,7 @@ export function readEnv(): KidsClientEnv {
55
61
  opencodeBin: process.env.OPENCODE_BIN ?? "opencode",
56
62
  configDir: process.env.KIDS_OPENCODE_CONFIG_DIR ?? join(homedir(), ".config", "kids-opencode"),
57
63
  noBanner: process.env.KIDS_OPENCODE_NO_BANNER === "1",
64
+ portalBaseUrl: process.env.AIRBOTIX_PORTAL_URL || "https://app.airbotix.ai",
58
65
  }
59
66
  }
60
67
 
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Wallet / login deep-link to Airbotix Portal.
3
+ *
4
+ * V0 strategy: open the parent's default browser to portal/wallet?from=cli.
5
+ * Portal handles auth (login first if no session) and Airwallex hosted card
6
+ * entry. TUI does not touch card data — PCI scope stays in the browser.
7
+ *
8
+ * A stable device-id (random UUID, persisted under configDir) is included
9
+ * so platform-backend can later correlate top-ups with the local install
10
+ * for V1 device-link polling. Today portal just logs it.
11
+ */
12
+
13
+ import { spawn } from "node:child_process"
14
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
15
+ import { join } from "node:path"
16
+ import { randomUUID } from "node:crypto"
17
+
18
+ export const DEFAULT_PORTAL_BASE_URL = "https://app.airbotix.ai"
19
+
20
+ export function getOrCreateDeviceId(configDir: string): string {
21
+ const p = join(configDir, "device-id")
22
+ if (existsSync(p)) {
23
+ const v = readFileSync(p, "utf8").trim()
24
+ if (v) return v
25
+ }
26
+ mkdirSync(configDir, { recursive: true })
27
+ const id = randomUUID()
28
+ writeFileSync(p, id + "\n", { mode: 0o600 })
29
+ return id
30
+ }
31
+
32
+ export interface WalletUrlOpts {
33
+ portalBaseUrl?: string
34
+ deviceId: string
35
+ locale?: "zh-Hans" | "en"
36
+ }
37
+
38
+ export function buildWalletUrl(opts: WalletUrlOpts): string {
39
+ const base = (opts.portalBaseUrl || DEFAULT_PORTAL_BASE_URL).replace(/\/+$/, "")
40
+ const params = new URLSearchParams({
41
+ from: "cli",
42
+ device: opts.deviceId,
43
+ })
44
+ if (opts.locale) params.set("lang", opts.locale)
45
+ return `${base}/portal/wallet?${params.toString()}`
46
+ }
47
+
48
+ export type OpenResult = { ok: true } | { ok: false; reason: string }
49
+
50
+ export function openInBrowser(url: string): OpenResult {
51
+ const platform = process.platform
52
+ let cmd: string
53
+ let args: string[]
54
+ if (platform === "darwin") {
55
+ cmd = "open"
56
+ args = [url]
57
+ } else if (platform === "win32") {
58
+ // `start` is a cmd.exe builtin; the empty "" is the window title slot.
59
+ cmd = "cmd"
60
+ args = ["/c", "start", "", url]
61
+ } else {
62
+ cmd = "xdg-open"
63
+ args = [url]
64
+ }
65
+ try {
66
+ const child = spawn(cmd, args, { detached: true, stdio: "ignore" })
67
+ child.on("error", () => {
68
+ // Swallow async spawn errors — TUI already showed a toast saying we
69
+ // tried, and the parent can copy the URL from the toast as fallback.
70
+ })
71
+ child.unref()
72
+ return { ok: true }
73
+ } catch (err) {
74
+ return { ok: false, reason: err instanceof Error ? err.message : String(err) }
75
+ }
76
+ }
package/src/index.tsx CHANGED
@@ -37,6 +37,7 @@ import { reloadEnvFile } from "./core/env-reload.ts"
37
37
  import { hasSeenTour, markTourSeen } from "./core/tour-marker.ts"
38
38
  import type { InstalledPack } from "./core/course-pack.ts"
39
39
  import { loadCoursePack } from "@kidsinai/kids-opencode-plugin"
40
+ import { buildWalletUrl, getOrCreateDeviceId, openInBrowser } from "./core/wallet-link.ts"
40
41
 
41
42
  interface ServiceSet {
42
43
  audit: AuditPipeline
@@ -77,6 +78,7 @@ interface AppHandlers {
77
78
  onSetupSkip: () => void
78
79
  onSetupOAuthHandoff: (provider: ProviderId) => Promise<void>
79
80
  onTourDone: () => void
81
+ onOpenWallet: () => void
80
82
  }
81
83
 
82
84
  async function main(): Promise<void> {
@@ -248,6 +250,25 @@ function makeHandlers(
248
250
  const r = getResolveTour()
249
251
  if (r) r()
250
252
  },
253
+ onOpenWallet: () => {
254
+ const deviceId = getOrCreateDeviceId(env.configDir)
255
+ const url = buildWalletUrl({
256
+ portalBaseUrl: env.portalBaseUrl,
257
+ deviceId,
258
+ locale: env.locale,
259
+ })
260
+ const result = openInBrowser(url)
261
+ const okText = env.locale === "zh-Hans"
262
+ ? `已在浏览器打开:${url}`
263
+ : `Opened in your browser: ${url}`
264
+ const failText = env.locale === "zh-Hans"
265
+ ? `没办法自动开浏览器。请手动打开:${url}`
266
+ : `Couldn't auto-open the browser. Open manually: ${url}`
267
+ flashToast(store, {
268
+ kind: result.ok ? "success" : "warn",
269
+ text: result.ok ? okText : failText,
270
+ })
271
+ },
251
272
  }
252
273
  }
253
274
 
@@ -62,6 +62,13 @@ export interface AppDeps {
62
62
  onSetupSkip: () => void
63
63
  onSetupOAuthHandoff: (provider: ProviderId) => Promise<void>
64
64
  onTourDone: () => void
65
+ /**
66
+ * Open the Airbotix Portal wallet/login page in the parent's default
67
+ * browser. Wired into [w] on StartupScreen and into the
68
+ * `stars_exhausted` ErrorScreen so parents can top up without
69
+ * remembering the URL.
70
+ */
71
+ onOpenWallet: () => void
65
72
  }
66
73
 
67
74
  export function App(deps: AppDeps): React.ReactElement {
@@ -98,7 +105,7 @@ export function App(deps: AppDeps): React.ReactElement {
98
105
  case "tour":
99
106
  return <TourScreen locale={deps.locale} onDone={deps.onTourDone} />
100
107
  case "startup":
101
- return <StartupScreen locale={deps.locale} coursePack={state.coursePack} onStart={deps.onStart} />
108
+ return <StartupScreen locale={deps.locale} coursePack={state.coursePack} toast={state.toast} onStart={deps.onStart} onOpenWallet={deps.onOpenWallet} />
102
109
  case "mission":
103
110
  return <MissionScreen state={state} locale={deps.locale} onPrompt={deps.onPrompt} onAbort={deps.onAbort} />
104
111
  case "help":
@@ -132,8 +139,10 @@ export function App(deps: AppDeps): React.ReactElement {
132
139
  variant={state.screen.variant}
133
140
  detail={state.screen.detail}
134
141
  locale={deps.locale}
142
+ toast={state.toast}
135
143
  onRetry={deps.onErrorRetry}
136
144
  onReconfigure={RECONFIGURABLE_VARIANTS.has(state.screen.variant) ? deps.onReconfigure : undefined}
145
+ onOpenWallet={state.screen.variant === "stars_exhausted" ? deps.onOpenWallet : undefined}
137
146
  onQuit={deps.onQuit}
138
147
  />
139
148
  )
@@ -14,11 +14,13 @@ import React from "react"
14
14
  import { Box, Text, useInput } from "ink"
15
15
  import { getTheme } from "../theme.ts"
16
16
  import type { ErrorVariant } from "../../../core/store.ts"
17
+ import { Toast, type ToastState } from "../components/Toast.tsx"
17
18
 
18
19
  interface ErrorScreenProps {
19
20
  variant: ErrorVariant
20
21
  locale: "zh-Hans" | "en"
21
22
  detail?: string
23
+ toast?: ToastState | null
22
24
  onRetry?: () => void
23
25
  onQuit?: () => void
24
26
  /**
@@ -28,13 +30,20 @@ interface ErrorScreenProps {
28
30
  * alone won't fix a wrong key.
29
31
  */
30
32
  onReconfigure?: () => void
33
+ /**
34
+ * Open the Airbotix Portal wallet page in the parent's default browser.
35
+ * Wired only for `stars_exhausted` so retry-alone (which won't change the
36
+ * balance) is not the only option.
37
+ */
38
+ onOpenWallet?: () => void
31
39
  }
32
40
 
33
- export function ErrorScreen({ variant, locale, detail, onRetry, onQuit, onReconfigure }: ErrorScreenProps): React.ReactElement {
41
+ export function ErrorScreen({ variant, locale, detail, toast, onRetry, onQuit, onReconfigure, onOpenWallet }: ErrorScreenProps): React.ReactElement {
34
42
  const theme = getTheme()
35
43
  useInput((input, key) => {
36
44
  if (key.return && onRetry) onRetry()
37
45
  else if ((input === "c" || input === "C") && onReconfigure) onReconfigure()
46
+ else if ((input === "w" || input === "W") && onOpenWallet) onOpenWallet()
38
47
  else if (input === "q" && onQuit) onQuit()
39
48
  })
40
49
  const t = STRINGS[locale][variant]
@@ -66,6 +75,12 @@ export function ErrorScreen({ variant, locale, detail, onRetry, onQuit, onReconf
66
75
  <Text color={theme.fg}> {STRINGS[locale].reconfigure}</Text>
67
76
  </Box>
68
77
  )}
78
+ {onOpenWallet && (
79
+ <Box marginRight={2}>
80
+ <Text color={theme.accent}>[w]</Text>
81
+ <Text color={theme.fg}> {STRINGS[locale].topUp}</Text>
82
+ </Box>
83
+ )}
69
84
  {onQuit && (
70
85
  <Box>
71
86
  <Text color={theme.accent}>[q]</Text>
@@ -73,6 +88,11 @@ export function ErrorScreen({ variant, locale, detail, onRetry, onQuit, onReconf
73
88
  </Box>
74
89
  )}
75
90
  </Box>
91
+ {toast && (
92
+ <Box marginTop={1}>
93
+ <Toast toast={toast} />
94
+ </Box>
95
+ )}
76
96
  </Box>
77
97
  )
78
98
  }
@@ -81,6 +101,7 @@ const STRINGS = {
81
101
  "zh-Hans": {
82
102
  quit: "退出",
83
103
  reconfigure: "改设置(换 key / 换 provider)",
104
+ topUp: "去充值(开浏览器)",
84
105
  serve_unreachable: {
85
106
  title: "AI 老师还没起来",
86
107
  body: "后台 AI 服务好像没启动。要不要再试一次?",
@@ -98,7 +119,7 @@ const STRINGS = {
98
119
  },
99
120
  stars_exhausted: {
100
121
  title: "今天的 ⭐ 用完了",
101
- body: "今天先到这里啦!\n你做得很好,我们明天接着来。\n或者找家长打开 airbotix.ai/portal/wallet 多充一点 ⭐,然后按 Enter 接着做。",
122
+ body: "今天先到这里啦!\n你做得很好,我们明天接着来。\n或者按 [w] 让家长去充值,回来按 Enter 接着做。",
102
123
  retry: "找完家长了,再试一次",
103
124
  },
104
125
  auth_failed: {
@@ -120,6 +141,7 @@ const STRINGS = {
120
141
  en: {
121
142
  quit: "Quit",
122
143
  reconfigure: "Change settings (switch key / provider)",
144
+ topUp: "Top up (opens browser)",
123
145
  serve_unreachable: {
124
146
  title: "AI teacher didn't start",
125
147
  body: "The background AI service isn't running. Try again?",
@@ -137,7 +159,7 @@ const STRINGS = {
137
159
  },
138
160
  stars_exhausted: {
139
161
  title: "Out of ⭐ for today",
140
- 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.",
162
+ body: "Great work today!\nWe'll pick this up tomorrow.\nOr press [w] so a parent can top up, then press Enter to keep going.",
141
163
  retry: "Asked a parent — try again",
142
164
  },
143
165
  auth_failed: {
@@ -5,6 +5,7 @@
5
5
  * Enter → start a free-play session OR continue if a course pack is set
6
6
  * c → choose a Course Pack
7
7
  * r → resume the last session
8
+ * w → open Airbotix Portal wallet / login in the parent's browser
8
9
  * h → show kid-friendly help
9
10
  */
10
11
 
@@ -13,19 +14,23 @@ import { Box, Text, useInput } from "ink"
13
14
  import { getTheme } from "../theme.ts"
14
15
  import { KidsLogo } from "../components/KidsLogo.tsx"
15
16
  import { KeyHints } from "../components/KeyHints.tsx"
17
+ import { Toast, type ToastState } from "../components/Toast.tsx"
16
18
 
17
19
  interface StartupScreenProps {
18
20
  locale: "zh-Hans" | "en"
19
21
  coursePack: string | null
22
+ toast: ToastState | null
20
23
  onStart: (mode: "free" | "course" | "resume" | "help") => void
24
+ onOpenWallet: () => void
21
25
  }
22
26
 
23
- export function StartupScreen({ locale, coursePack, onStart }: StartupScreenProps): React.ReactElement {
27
+ export function StartupScreen({ locale, coursePack, toast, onStart, onOpenWallet }: StartupScreenProps): React.ReactElement {
24
28
  const theme = getTheme()
25
29
  useInput((input, key) => {
26
30
  if (key.return) onStart(coursePack ? "course" : "free")
27
31
  else if (input === "c") onStart("course")
28
32
  else if (input === "r") onStart("resume")
33
+ else if (input === "w" || input === "W") onOpenWallet()
29
34
  else if (input === "h") onStart("help")
30
35
  })
31
36
  const t = STRINGS[locale]
@@ -51,9 +56,15 @@ export function StartupScreen({ locale, coursePack, onStart }: StartupScreenProp
51
56
  { key: "Enter", label: coursePack ? t.startCourse : t.startFree },
52
57
  { key: "c", label: t.pickCourse },
53
58
  { key: "r", label: t.resume },
59
+ { key: "w", label: t.wallet },
54
60
  { key: "h", label: t.help },
55
61
  ]} />
56
62
  </Box>
63
+ {toast && (
64
+ <Box marginTop={1}>
65
+ <Toast toast={toast} />
66
+ </Box>
67
+ )}
57
68
  </Box>
58
69
  )
59
70
  }
@@ -70,6 +81,7 @@ const STRINGS = {
70
81
  startCourse: "继续 Course Pack",
71
82
  pickCourse: "选 Course Pack",
72
83
  resume: "继续上次",
84
+ wallet: "钱包 / 充值(开浏览器)",
73
85
  help: "帮助",
74
86
  },
75
87
  en: {
@@ -83,6 +95,7 @@ const STRINGS = {
83
95
  startCourse: "Continue Course Pack",
84
96
  pickCourse: "Pick a Course Pack",
85
97
  resume: "Resume last session",
98
+ wallet: "Wallet / Top up (opens browser)",
86
99
  help: "Help",
87
100
  },
88
101
  } as const