@kidsinai/kids-client 0.0.2 → 0.0.4

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.4",
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
@@ -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,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
+ }
@@ -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" }