@kidsinai/kids-opencode-plugin 0.0.1

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.
@@ -0,0 +1,386 @@
1
+ // Acceptance check runner.
2
+ //
3
+ // Walks a Mission's `acceptance.yml` against the kid's current project folder
4
+ // (process.cwd()) and reports pass/fail per check. Used by:
5
+ //
6
+ // kids-opencode check <mission-id>
7
+ //
8
+ // Pure functions; the CLI entry point is in ./check-runner.ts.
9
+
10
+ import { existsSync, readFileSync, statSync } from "node:fs"
11
+ import { join, resolve, isAbsolute } from "node:path"
12
+ import { parse as parseYaml } from "yaml"
13
+ import { bundledCoursePacksDir, findMission, loadCoursePack } from "./course-pack.js"
14
+
15
+ export interface AcceptanceFile {
16
+ mission_id: string
17
+ title?: string
18
+ checks: AcceptanceCheck[]
19
+ completion_message?: string
20
+ parent_summary_fields?: string[]
21
+ pack_completion_message?: string
22
+ }
23
+
24
+ export type AcceptanceCheck =
25
+ | FileExistsCheck
26
+ | FileContainsRegexCheck
27
+ | FileContainsCountMinCheck
28
+ | FileContainsAnyRegexCheck
29
+ | FileTextLengthMinCheck
30
+ | AllMustExistCheck
31
+ | AuditLogCheck
32
+
33
+ interface CheckBase {
34
+ id: string
35
+ description: string
36
+ }
37
+
38
+ export interface FileExistsCheck extends CheckBase {
39
+ type: "file_exists"
40
+ path: string
41
+ }
42
+
43
+ export interface FileContainsRegexCheck extends CheckBase {
44
+ type: "file_contains_regex"
45
+ path: string
46
+ pattern: string
47
+ }
48
+
49
+ export interface FileContainsCountMinCheck extends CheckBase {
50
+ type: "file_contains_count_min"
51
+ path: string
52
+ pattern: string
53
+ min_count: number
54
+ }
55
+
56
+ export interface FileContainsAnyRegexCheck extends CheckBase {
57
+ type: "file_contains_any_regex"
58
+ path: string
59
+ patterns: string[]
60
+ }
61
+
62
+ export interface FileTextLengthMinCheck extends CheckBase {
63
+ type: "file_text_length_min"
64
+ path: string
65
+ min_chars: number
66
+ }
67
+
68
+ export interface AllMustExistCheck extends CheckBase {
69
+ type: "all_must_exist"
70
+ paths: string[]
71
+ }
72
+
73
+ export interface AuditLogCheck extends CheckBase {
74
+ type: "audit_log_check"
75
+ audit_rule: string
76
+ }
77
+
78
+ export interface CheckResult {
79
+ id: string
80
+ description: string
81
+ type: string
82
+ status: "pass" | "fail" | "skip"
83
+ detail?: string
84
+ }
85
+
86
+ export interface MissionResult {
87
+ mission_id: string
88
+ title?: string
89
+ passed: number
90
+ failed: number
91
+ skipped: number
92
+ total: number
93
+ ok: boolean
94
+ results: CheckResult[]
95
+ completion_message?: string
96
+ }
97
+
98
+ /**
99
+ * Resolve a path that's guaranteed to be inside `projectDir`. Refuses absolute
100
+ * paths and `..` escapes, matching the plugin's path-guard discipline.
101
+ */
102
+ function resolveProjectPath(projectDir: string, rel: string): string | null {
103
+ if (isAbsolute(rel)) return null
104
+ if (rel.includes("..")) return null
105
+ return resolve(projectDir, rel)
106
+ }
107
+
108
+ /**
109
+ * Translate PCRE-style inline modifier prefixes (`(?i)`, `(?s)`, `(?m)`) into
110
+ * JavaScript RegExp flags. Acceptance YAMLs are authored by the curriculum
111
+ * team; supporting the conventional `(?i)` prefix is friendlier than asking
112
+ * authors to know JS regex specifics.
113
+ *
114
+ * Anything we don't recognise is left in place (so JS regex callers still
115
+ * get the syntax error they'd otherwise get).
116
+ */
117
+ function compileRegex(pattern: string, extraFlags = ""): RegExp {
118
+ let flags = extraFlags
119
+ let remaining = pattern
120
+ const modifierRe = /^\(\?([imsux]+)\)/
121
+ const match = modifierRe.exec(remaining)
122
+ if (match) {
123
+ const modifiers = match[1] ?? ""
124
+ if (modifiers.includes("i") && !flags.includes("i")) flags += "i"
125
+ if (modifiers.includes("s") && !flags.includes("s")) flags += "s"
126
+ if (modifiers.includes("m") && !flags.includes("m")) flags += "m"
127
+ // u/x silently ignored — x (extended) has no JS equivalent
128
+ remaining = remaining.slice(match[0].length)
129
+ }
130
+ return new RegExp(remaining, flags)
131
+ }
132
+
133
+ /**
134
+ * Read text from a project file; returns null if missing or unreadable.
135
+ */
136
+ function readProjectFile(projectDir: string, rel: string): string | null {
137
+ const full = resolveProjectPath(projectDir, rel)
138
+ if (!full) return null
139
+ if (!existsSync(full)) return null
140
+ try {
141
+ const st = statSync(full)
142
+ if (!st.isFile()) return null
143
+ return readFileSync(full, "utf8")
144
+ } catch {
145
+ return null
146
+ }
147
+ }
148
+
149
+ function evalCheck(projectDir: string, check: AcceptanceCheck): CheckResult {
150
+ switch (check.type) {
151
+ case "file_exists": {
152
+ const full = resolveProjectPath(projectDir, check.path)
153
+ const ok = !!full && existsSync(full)
154
+ return {
155
+ id: check.id,
156
+ description: check.description,
157
+ type: check.type,
158
+ status: ok ? "pass" : "fail",
159
+ detail: ok ? `${check.path} exists` : `${check.path} missing`,
160
+ }
161
+ }
162
+ case "file_contains_regex": {
163
+ const text = readProjectFile(projectDir, check.path)
164
+ if (text === null) {
165
+ return {
166
+ id: check.id,
167
+ description: check.description,
168
+ type: check.type,
169
+ status: "fail",
170
+ detail: `${check.path} could not be read`,
171
+ }
172
+ }
173
+ let re: RegExp
174
+ try {
175
+ re = compileRegex(check.pattern)
176
+ } catch (err) {
177
+ return {
178
+ id: check.id,
179
+ description: check.description,
180
+ type: check.type,
181
+ status: "fail",
182
+ detail: `invalid regex: ${(err as Error).message}`,
183
+ }
184
+ }
185
+ const ok = re.test(text)
186
+ return {
187
+ id: check.id,
188
+ description: check.description,
189
+ type: check.type,
190
+ status: ok ? "pass" : "fail",
191
+ detail: ok ? `pattern matched` : `pattern not found`,
192
+ }
193
+ }
194
+ case "file_contains_count_min": {
195
+ const text = readProjectFile(projectDir, check.path)
196
+ if (text === null) {
197
+ return {
198
+ id: check.id,
199
+ description: check.description,
200
+ type: check.type,
201
+ status: "fail",
202
+ detail: `${check.path} could not be read`,
203
+ }
204
+ }
205
+ let re: RegExp
206
+ try {
207
+ re = compileRegex(check.pattern, "g")
208
+ } catch (err) {
209
+ return {
210
+ id: check.id,
211
+ description: check.description,
212
+ type: check.type,
213
+ status: "fail",
214
+ detail: `invalid regex: ${(err as Error).message}`,
215
+ }
216
+ }
217
+ const matches = text.match(re) ?? []
218
+ const ok = matches.length >= check.min_count
219
+ return {
220
+ id: check.id,
221
+ description: check.description,
222
+ type: check.type,
223
+ status: ok ? "pass" : "fail",
224
+ detail: `found ${matches.length} (need ≥ ${check.min_count})`,
225
+ }
226
+ }
227
+ case "file_contains_any_regex": {
228
+ const text = readProjectFile(projectDir, check.path)
229
+ if (text === null) {
230
+ return {
231
+ id: check.id,
232
+ description: check.description,
233
+ type: check.type,
234
+ status: "fail",
235
+ detail: `${check.path} could not be read`,
236
+ }
237
+ }
238
+ const ok = check.patterns.some((p) => {
239
+ try {
240
+ return compileRegex(p).test(text)
241
+ } catch {
242
+ return false
243
+ }
244
+ })
245
+ return {
246
+ id: check.id,
247
+ description: check.description,
248
+ type: check.type,
249
+ status: ok ? "pass" : "fail",
250
+ detail: ok ? `one of the patterns matched` : `none of the patterns matched`,
251
+ }
252
+ }
253
+ case "file_text_length_min": {
254
+ const text = readProjectFile(projectDir, check.path)
255
+ if (text === null) {
256
+ return {
257
+ id: check.id,
258
+ description: check.description,
259
+ type: check.type,
260
+ status: "fail",
261
+ detail: `${check.path} could not be read`,
262
+ }
263
+ }
264
+ // Tag-stripped length for the "actual content" heuristic.
265
+ const stripped = text.replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim()
266
+ const ok = stripped.length >= check.min_chars
267
+ return {
268
+ id: check.id,
269
+ description: check.description,
270
+ type: check.type,
271
+ status: ok ? "pass" : "fail",
272
+ detail: `text length ${stripped.length} (need ≥ ${check.min_chars})`,
273
+ }
274
+ }
275
+ case "all_must_exist": {
276
+ const missing = check.paths.filter((p) => {
277
+ const full = resolveProjectPath(projectDir, p)
278
+ return !full || !existsSync(full)
279
+ })
280
+ const ok = missing.length === 0
281
+ return {
282
+ id: check.id,
283
+ description: check.description,
284
+ type: check.type,
285
+ status: ok ? "pass" : "fail",
286
+ detail: ok ? `all paths present` : `missing: ${missing.join(", ")}`,
287
+ }
288
+ }
289
+ case "audit_log_check": {
290
+ // V0: audit log is stderr-only. We don't have a way to retroactively
291
+ // walk it from the runner. Skip with a note so the kid sees the
292
+ // intent but isn't blocked by a check we can't yet enforce.
293
+ return {
294
+ id: check.id,
295
+ description: check.description,
296
+ type: check.type,
297
+ status: "skip",
298
+ detail: `audit_log_check '${check.audit_rule}' requires platform-backend Phase 5 ingestion; not evaluated offline`,
299
+ }
300
+ }
301
+ default: {
302
+ // Exhaustiveness check.
303
+ const _: never = check
304
+ return {
305
+ id: (check as CheckBase).id,
306
+ description: (check as CheckBase).description,
307
+ type: (check as { type: string }).type,
308
+ status: "fail",
309
+ detail: `unsupported check type`,
310
+ }
311
+ }
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Load `acceptance.yml` for a Mission by (packId, missionId).
317
+ * Returns null if the file is missing or unparseable.
318
+ */
319
+ export function loadAcceptanceForMission(
320
+ packId: string,
321
+ missionId: string,
322
+ ): AcceptanceFile | null {
323
+ if (!packId || !missionId) return null
324
+ if (packId.includes("/") || packId.includes("..")) return null
325
+ if (missionId.includes("/") || missionId.includes("..")) return null
326
+
327
+ const acceptancePath = join(bundledCoursePacksDir(), packId, missionId, "acceptance.yml")
328
+ if (!existsSync(acceptancePath)) return null
329
+
330
+ try {
331
+ const raw = readFileSync(acceptancePath, "utf8")
332
+ return parseYaml(raw) as AcceptanceFile
333
+ } catch {
334
+ return null
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Run all checks for a Mission against `projectDir` (defaults to process.cwd()).
340
+ * Resolves the pack from KIDS_COURSE_PACK or an explicit argument.
341
+ */
342
+ export function runMissionChecks(
343
+ missionId: string,
344
+ options: { packId?: string; projectDir?: string } = {},
345
+ ): MissionResult | { error: string } {
346
+ const packId = options.packId ?? process.env.KIDS_COURSE_PACK ?? ""
347
+ const projectDir = options.projectDir ?? process.cwd()
348
+
349
+ if (!packId) {
350
+ return {
351
+ error:
352
+ "no Course Pack specified (set KIDS_COURSE_PACK or pass --course on the kids-opencode wrapper)",
353
+ }
354
+ }
355
+
356
+ const pack = loadCoursePack(packId)
357
+ if (!pack) {
358
+ return { error: `Course Pack not found: ${packId}` }
359
+ }
360
+ const mission = findMission(pack, missionId)
361
+ if (!mission) {
362
+ return { error: `Mission '${missionId}' not in pack '${packId}'` }
363
+ }
364
+
365
+ const acceptance = loadAcceptanceForMission(packId, missionId)
366
+ if (!acceptance) {
367
+ return { error: `acceptance.yml missing or unreadable for ${packId}/${missionId}` }
368
+ }
369
+
370
+ const results = (acceptance.checks ?? []).map((c) => evalCheck(projectDir, c))
371
+ const passed = results.filter((r) => r.status === "pass").length
372
+ const failed = results.filter((r) => r.status === "fail").length
373
+ const skipped = results.filter((r) => r.status === "skip").length
374
+
375
+ return {
376
+ mission_id: missionId,
377
+ title: acceptance.title ?? mission.title,
378
+ passed,
379
+ failed,
380
+ skipped,
381
+ total: results.length,
382
+ ok: failed === 0,
383
+ results,
384
+ completion_message: acceptance.completion_message,
385
+ }
386
+ }
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env bun
2
+ //
3
+ // `kids-opencode check <mission-id>` — CLI entry point.
4
+ //
5
+ // Walks the bundled acceptance.yml for the given Mission against the kid's
6
+ // current project folder (cwd) and prints a friendly report.
7
+ //
8
+ // Resolves the Course Pack from $KIDS_COURSE_PACK or `--course <id>`.
9
+ // Exit codes:
10
+ // 0 — all checks passed
11
+ // 1 — one or more checks failed
12
+ // 2 — usage error (missing args, pack not found, etc.)
13
+
14
+ import { runMissionChecks } from "./acceptance.js"
15
+
16
+ function usage(): never {
17
+ process.stderr.write(
18
+ [
19
+ "Usage: kids-opencode check <mission-id> [--course <pack-id>]",
20
+ "",
21
+ "Walks the Mission's acceptance criteria against the current folder.",
22
+ "",
23
+ "Examples:",
24
+ " cd ~/my-portfolio",
25
+ " kids-opencode check mission-1 --course portfolio-site",
26
+ "",
27
+ " # Or set the env var once and check multiple times:",
28
+ " export KIDS_COURSE_PACK=portfolio-site",
29
+ " kids-opencode check mission-1",
30
+ " kids-opencode check mission-2",
31
+ "",
32
+ ].join("\n"),
33
+ )
34
+ process.exit(2)
35
+ }
36
+
37
+ interface ParsedArgs {
38
+ missionId: string
39
+ packId?: string
40
+ }
41
+
42
+ function parseArgs(argv: string[]): ParsedArgs | null {
43
+ let missionId = ""
44
+ let packId: string | undefined
45
+ let i = 0
46
+ while (i < argv.length) {
47
+ const a = argv[i]
48
+ if (a === "--course") {
49
+ packId = argv[i + 1]
50
+ i += 2
51
+ continue
52
+ }
53
+ if (a?.startsWith("--course=")) {
54
+ packId = a.slice("--course=".length)
55
+ i += 1
56
+ continue
57
+ }
58
+ if (a === "--help" || a === "-h") {
59
+ return null
60
+ }
61
+ if (a && !a.startsWith("-") && !missionId) {
62
+ missionId = a
63
+ i += 1
64
+ continue
65
+ }
66
+ i += 1
67
+ }
68
+ if (!missionId) return null
69
+ return { missionId, packId }
70
+ }
71
+
72
+ function statusGlyph(status: string): string {
73
+ switch (status) {
74
+ case "pass":
75
+ return "✅"
76
+ case "fail":
77
+ return "❌"
78
+ case "skip":
79
+ return "⏭ "
80
+ default:
81
+ return " "
82
+ }
83
+ }
84
+
85
+ async function main(): Promise<void> {
86
+ const args = parseArgs(process.argv.slice(2))
87
+ if (!args) usage()
88
+
89
+ const result = runMissionChecks(args.missionId, { packId: args.packId })
90
+
91
+ if ("error" in result) {
92
+ process.stderr.write(`kids-opencode check: ${result.error}\n`)
93
+ process.exit(2)
94
+ }
95
+
96
+ // Render report.
97
+ const lines: string[] = []
98
+ lines.push("")
99
+ lines.push(`Mission: ${result.title ?? result.mission_id}`)
100
+ lines.push(`Folder: ${process.cwd()}`)
101
+ lines.push("")
102
+ lines.push(`Checks (${result.passed}/${result.total} pass, ${result.failed} fail, ${result.skipped} skip):`)
103
+ for (const r of result.results) {
104
+ lines.push(` ${statusGlyph(r.status)} ${r.description}`)
105
+ if (r.detail) {
106
+ lines.push(` → ${r.detail}`)
107
+ }
108
+ }
109
+ lines.push("")
110
+ if (result.ok) {
111
+ lines.push("🎉 All required checks passed.")
112
+ if (result.completion_message) {
113
+ lines.push("")
114
+ lines.push(result.completion_message)
115
+ }
116
+ } else {
117
+ lines.push("Some checks failed. Open the file(s) above and ask the AI for help:")
118
+ lines.push("")
119
+ lines.push(" kids-opencode")
120
+ lines.push("")
121
+ lines.push("Then say what's missing and walk through the fix together.")
122
+ }
123
+
124
+ process.stdout.write(lines.join("\n") + "\n")
125
+ process.exit(result.ok ? 0 : 1)
126
+ }
127
+
128
+ main().catch((err) => {
129
+ process.stderr.write(`kids-opencode check: ${(err as Error).message}\n`)
130
+ process.exit(2)
131
+ })
@@ -0,0 +1,119 @@
1
+ // Course Pack loader.
2
+ //
3
+ // Resolves a pack by ID against the bundled `course-packs/` directory
4
+ // (shipped with this npm package). Returns the system-prompt overlay,
5
+ // learning objectives, and mission metadata so the plugin can prepend
6
+ // them to the kid-safe system prompt.
7
+
8
+ import { existsSync, readFileSync } from "node:fs"
9
+ import { dirname, join, resolve } from "node:path"
10
+ import { fileURLToPath } from "node:url"
11
+ import { parse as parseYaml } from "yaml"
12
+
13
+ export interface CoursePack {
14
+ id: string
15
+ version: string
16
+ title: string
17
+ short_description?: string
18
+ target_age_band?: string
19
+ estimated_duration_minutes?: number
20
+ estimated_stars_budget?: number
21
+ learning_objectives?: string[]
22
+ prerequisites?: string[]
23
+ missions: CoursePackMission[]
24
+ system_prompt_overlay?: string
25
+ teacher_notes?: string[]
26
+ }
27
+
28
+ export interface CoursePackMission {
29
+ id: string
30
+ title: string
31
+ estimated_minutes?: number
32
+ estimated_stars?: number
33
+ file?: string
34
+ acceptance?: string
35
+ }
36
+
37
+ /**
38
+ * Resolve the bundled `course-packs/` directory relative to this source file.
39
+ * Works in dev (running from `src/`) and post-publish (running from `dist/`).
40
+ */
41
+ export function bundledCoursePacksDir(): string {
42
+ // import.meta.url points at the .ts (dev) or .js (built); either way
43
+ // the `course-packs/` directory is two levels up from this file.
44
+ // - dev: src/course-pack.ts → ../course-packs/
45
+ // - built: dist/course-pack.js → ../course-packs/
46
+ const here = dirname(fileURLToPath(import.meta.url))
47
+ return resolve(here, "..", "course-packs")
48
+ }
49
+
50
+ /**
51
+ * Load a Course Pack by ID. Returns null if the pack does not exist.
52
+ * Logs (does not throw) on parse error to avoid crashing kid sessions.
53
+ */
54
+ export function loadCoursePack(packId: string): CoursePack | null {
55
+ if (!packId) return null
56
+ // Defence: refuse path-like IDs.
57
+ if (packId.includes("/") || packId.includes("..") || packId.includes("\\")) {
58
+ return null
59
+ }
60
+
61
+ const packYamlPath = join(bundledCoursePacksDir(), packId, "pack.yml")
62
+ if (!existsSync(packYamlPath)) {
63
+ return null
64
+ }
65
+
66
+ try {
67
+ const raw = readFileSync(packYamlPath, "utf8")
68
+ const parsed = parseYaml(raw) as CoursePack
69
+ return parsed
70
+ } catch (err) {
71
+ process.stderr.write(
72
+ `[kids-audit] {"event":"course_pack.load_error","pack":"${packId}","error":"${(err as Error).message}"}\n`,
73
+ )
74
+ return null
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Get the mission within a pack, or null if missing.
80
+ */
81
+ export function findMission(
82
+ pack: CoursePack,
83
+ missionId: string,
84
+ ): CoursePackMission | null {
85
+ return pack.missions?.find((m) => m.id === missionId) ?? null
86
+ }
87
+
88
+ /**
89
+ * Build a stitched overlay block to prepend to the kid-safe system prompt.
90
+ * Composes: pack.system_prompt_overlay + current Mission context block.
91
+ *
92
+ * Returns an empty string if the pack is null (free-play mode).
93
+ */
94
+ export function buildOverlay(
95
+ pack: CoursePack | null,
96
+ missionId: string | undefined,
97
+ ): string {
98
+ if (!pack) return ""
99
+
100
+ const parts: string[] = []
101
+ if (pack.system_prompt_overlay) {
102
+ parts.push(pack.system_prompt_overlay.trim())
103
+ }
104
+
105
+ if (missionId) {
106
+ const m = findMission(pack, missionId)
107
+ if (m) {
108
+ parts.push(
109
+ `\n## Active mission\n` +
110
+ `- Mission ID: ${m.id}\n` +
111
+ `- Title: ${m.title}\n` +
112
+ (m.estimated_minutes ? `- Estimated time: ${m.estimated_minutes} min\n` : "") +
113
+ (m.estimated_stars ? `- Estimated cost: ${m.estimated_stars}⭐\n` : ""),
114
+ )
115
+ }
116
+ }
117
+
118
+ return parts.join("\n\n").trim()
119
+ }