@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 +1 -1
- package/src/core/env-reload.ts +26 -0
- package/src/core/env.ts +11 -4
- package/src/core/opencode-installer.ts +82 -0
- package/src/core/setup.ts +185 -0
- package/src/core/store.ts +1 -0
- package/src/index.tsx +219 -159
- package/src/render/ink/App.tsx +7 -0
- package/src/render/ink/components/KidsLogo.tsx +70 -0
- package/src/render/ink/screens/SetupScreen.tsx +343 -0
- package/src/render/ink/screens/StartupScreen.tsx +32 -27
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "@kidsinai/kids-client",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.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
|
-
|
|
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: "
|
|
65
|
-
variant: "
|
|
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
|
+
}
|