termi-kids 0.1.0

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.
Files changed (64) hide show
  1. package/LICENSE +34 -0
  2. package/README.md +148 -0
  3. package/SAFETY.md +187 -0
  4. package/bin/termi.js +22 -0
  5. package/dist/agent/context.js +126 -0
  6. package/dist/agent/loop.js +172 -0
  7. package/dist/agent/prompts/system.js +45 -0
  8. package/dist/agent/tools.js +335 -0
  9. package/dist/auth/keychain.js +146 -0
  10. package/dist/auth/oauth.js +375 -0
  11. package/dist/auth/tokens.js +219 -0
  12. package/dist/cli.js +258 -0
  13. package/dist/config/paths.js +92 -0
  14. package/dist/config/pin.js +150 -0
  15. package/dist/config/settings.js +131 -0
  16. package/dist/grownups/panel.js +483 -0
  17. package/dist/learn/lessons.js +490 -0
  18. package/dist/learn/runner.js +193 -0
  19. package/dist/preview/server.js +407 -0
  20. package/dist/projects/create.js +103 -0
  21. package/dist/projects/ideas.js +182 -0
  22. package/dist/projects/quests.js +277 -0
  23. package/dist/projects/scaffolds/art.js +484 -0
  24. package/dist/projects/scaffolds/biggames.js +554 -0
  25. package/dist/projects/scaffolds/characters.js +580 -0
  26. package/dist/projects/scaffolds/games.js +516 -0
  27. package/dist/projects/scaffolds/index.js +24 -0
  28. package/dist/projects/scaffolds/music.js +528 -0
  29. package/dist/projects/scaffolds/pets.js +567 -0
  30. package/dist/projects/scaffolds/quizzes.js +757 -0
  31. package/dist/projects/scaffolds/stories.js +620 -0
  32. package/dist/projects/scaffolds/vendor/KAPLAY-LICENSE.txt +35 -0
  33. package/dist/projects/scaffolds/vendor/kaplay.mjs +57 -0
  34. package/dist/projects/scaffolds/websites.js +474 -0
  35. package/dist/projects/snapshots.js +203 -0
  36. package/dist/projects/store.js +325 -0
  37. package/dist/providers/errors.js +207 -0
  38. package/dist/providers/index.js +316 -0
  39. package/dist/providers/models.js +38 -0
  40. package/dist/safety/audit.js +195 -0
  41. package/dist/safety/blocks.js +29 -0
  42. package/dist/safety/classifier.js +337 -0
  43. package/dist/safety/codescan.js +168 -0
  44. package/dist/safety/guarddownload.js +79 -0
  45. package/dist/safety/guardrunner.js +125 -0
  46. package/dist/safety/localguard.js +227 -0
  47. package/dist/safety/modelstore.js +127 -0
  48. package/dist/safety/prefilter.js +214 -0
  49. package/dist/safety/session.js +118 -0
  50. package/dist/safety/taxonomy.js +246 -0
  51. package/dist/safety/textextract.js +193 -0
  52. package/dist/setup/launcher.js +65 -0
  53. package/dist/setup/wizard.js +469 -0
  54. package/dist/surfaces/chat.js +439 -0
  55. package/dist/surfaces/commands.js +206 -0
  56. package/dist/surfaces/home.js +438 -0
  57. package/dist/types.js +5 -0
  58. package/dist/ui/banner.js +35 -0
  59. package/dist/ui/celebrate.js +141 -0
  60. package/dist/ui/errors.js +97 -0
  61. package/dist/ui/mascot.js +223 -0
  62. package/dist/ui/text.js +156 -0
  63. package/dist/ui/theme.js +92 -0
  64. package/package.json +67 -0
@@ -0,0 +1,469 @@
1
+ /**
2
+ * The setup wizard: a parent flow (PIN, consent, AI provider, safety level)
3
+ * followed by a kid flow (nickname, AI disclosure, launcher, first game).
4
+ *
5
+ * Pure decision helpers are exported so tests cover the logic without
6
+ * driving the prompts. Cancel anywhere keeps only the steps that finished;
7
+ * the setup marker is written near the end, so the next run resumes here.
8
+ */
9
+ import * as p from '@clack/prompts';
10
+ import { loginWithChatGPT } from '../auth/oauth.js';
11
+ import { setSecret } from '../auth/keychain.js';
12
+ import { hasPin, markSetupComplete, setPin } from '../config/pin.js';
13
+ import { defaultSettings, loadSettings, saveSettings } from '../config/settings.js';
14
+ import { appendAudit } from '../safety/audit.js';
15
+ import { ensureGuardFetch } from '../safety/guarddownload.js';
16
+ import { guardModelReady } from '../safety/modelstore.js';
17
+ import { nameIsOkay } from '../safety/prefilter.js';
18
+ import { scaffoldById } from '../projects/scaffolds/index.js';
19
+ import { renderBanner } from '../ui/banner.js';
20
+ import { mascot } from '../ui/mascot.js';
21
+ import { style } from '../ui/theme.js';
22
+ import { T } from '../ui/text.js';
23
+ import { writeLauncher } from './launcher.js';
24
+ /** The provider picker rows. ChatGPT sign-in always leads. */
25
+ export function providerOptions() {
26
+ return [
27
+ {
28
+ value: 'openai-chatgpt',
29
+ label: 'ChatGPT sign-in',
30
+ hint: 'Sign in with your browser. No key needed.',
31
+ },
32
+ { value: 'anthropic', label: 'Claude API key' },
33
+ { value: 'openai-api', label: 'OpenAI API key' },
34
+ { value: 'xai', label: 'Grok API key', hint: 'Adults only. One extra step.' },
35
+ { value: 'skip', label: 'Skip for now', hint: 'You can add one later.' },
36
+ ];
37
+ }
38
+ /** Friendly display name for a provider. */
39
+ export function providerLabel(id) {
40
+ switch (id) {
41
+ case 'openai-chatgpt':
42
+ return 'ChatGPT sign-in';
43
+ case 'openai-api':
44
+ return 'OpenAI key';
45
+ case 'anthropic':
46
+ return 'Claude key';
47
+ case 'xai':
48
+ return 'Grok key';
49
+ }
50
+ }
51
+ /**
52
+ * Gate for storing a Grok key: the parent must explicitly confirm the
53
+ * 18+ API terms acknowledgment first. No confirmation, no key.
54
+ */
55
+ export function grokKeyAllowed(parentAckConfirmed) {
56
+ return parentAckConfirmed === true;
57
+ }
58
+ const NAME_STARTS = ['Super', 'Mega', 'Turbo', 'Cosmic', 'Pixel', 'Lucky'];
59
+ const NAME_ENDS = ['Quest', 'Dash', 'World', 'Party', 'Lab', 'Zone'];
60
+ /** Three distinct project name ideas built from a theme label. */
61
+ export function suggestProjectNames(themeLabel, rng = Math.random) {
62
+ const core = themeLabel.trim().replace(/\s+/g, ' ') || 'Game';
63
+ const pick = (pool) => pool[Math.min(pool.length - 1, Math.max(0, Math.floor(rng() * pool.length)))] ?? pool[0];
64
+ const candidates = [`${pick(NAME_STARTS)} ${core}`, `${core} ${pick(NAME_ENDS)}`, `My ${core}`];
65
+ const out = [];
66
+ for (const candidate of candidates) {
67
+ if (!out.includes(candidate)) {
68
+ out.push(candidate);
69
+ }
70
+ }
71
+ let n = 2;
72
+ while (out.length < 3) {
73
+ out.push(`${core} ${n}`);
74
+ n += 1;
75
+ }
76
+ return out.slice(0, 3);
77
+ }
78
+ function audit(event, excerpt) {
79
+ try {
80
+ appendAudit({ ts: new Date().toISOString(), layer: 'system', event, excerpt });
81
+ }
82
+ catch {
83
+ // The wizard never falls over because the audit disk write failed.
84
+ }
85
+ }
86
+ /** Cancel anywhere: exit kindly, keep what finished, resume next run. */
87
+ function bail() {
88
+ p.cancel('Okay! We can finish setup next time you start Termi.');
89
+ process.exit(0);
90
+ }
91
+ function ensure(value) {
92
+ if (p.isCancel(value)) {
93
+ bail();
94
+ }
95
+ return value;
96
+ }
97
+ export const KEY_ACCOUNT = {
98
+ 'openai-api': 'api-key-openai-api',
99
+ anthropic: 'api-key-anthropic',
100
+ xai: 'api-key-xai',
101
+ };
102
+ function pingTarget(id, key) {
103
+ switch (id) {
104
+ case 'openai-api':
105
+ return { url: 'https://api.openai.com/v1/models', headers: { authorization: `Bearer ${key}` } };
106
+ case 'anthropic':
107
+ return {
108
+ url: 'https://api.anthropic.com/v1/models',
109
+ headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01' },
110
+ };
111
+ case 'xai':
112
+ return { url: 'https://api.x.ai/v1/models', headers: { authorization: `Bearer ${key}` } };
113
+ }
114
+ }
115
+ /**
116
+ * Best-effort live key check. "bad" only on a clear 401/403.
117
+ * Offline or anything odd reports "unknown" and is treated as fine.
118
+ */
119
+ export async function validateApiKey(id, key, fetchImpl = globalThis.fetch) {
120
+ try {
121
+ const target = pingTarget(id, key);
122
+ const controller = new AbortController();
123
+ const timer = setTimeout(() => controller.abort(), 4000);
124
+ try {
125
+ const res = await fetchImpl(target.url, {
126
+ headers: target.headers,
127
+ signal: controller.signal,
128
+ });
129
+ if (res.status === 401 || res.status === 403) {
130
+ return 'bad';
131
+ }
132
+ return 'ok';
133
+ }
134
+ finally {
135
+ clearTimeout(timer);
136
+ }
137
+ }
138
+ catch {
139
+ return 'unknown';
140
+ }
141
+ }
142
+ async function addChatgptProvider(settings) {
143
+ const s = p.spinner();
144
+ s.start('Opening the browser so you can sign in...');
145
+ try {
146
+ const result = await loginWithChatGPT({
147
+ openBrowser: true,
148
+ onAuthorizeUrl: (url) => {
149
+ s.message('Waiting for the sign-in to finish in your browser...');
150
+ p.log.message(style.dim(`If the browser did not open, use this link:\n${url}`));
151
+ },
152
+ });
153
+ s.stop(`Signed in! Plan: ${result.planType}`);
154
+ audit('provider_change', 'added openai-chatgpt');
155
+ const configured = settings.configuredProviders.includes('openai-chatgpt')
156
+ ? settings.configuredProviders
157
+ : [...settings.configuredProviders, 'openai-chatgpt'];
158
+ return { ...settings, configuredProviders: configured };
159
+ }
160
+ catch {
161
+ s.stop('That sign-in did not finish. You can try again later.');
162
+ return null;
163
+ }
164
+ }
165
+ async function addKeyProvider(id, settings) {
166
+ let next = { ...settings };
167
+ if (id === 'xai' && !next.xaiParentAck) {
168
+ const agreed = await p.confirm({
169
+ message: `${T.wizard.xaiAck} Do you confirm this?`,
170
+ initialValue: false,
171
+ });
172
+ if (p.isCancel(agreed)) {
173
+ bail();
174
+ }
175
+ if (!grokKeyAllowed(agreed === true)) {
176
+ p.log.info('Skipped Grok. Pick another helper, or add Grok later.');
177
+ return null;
178
+ }
179
+ next = { ...next, xaiParentAck: true };
180
+ audit('settings_change', 'xai parent ack confirmed');
181
+ }
182
+ for (;;) {
183
+ const key = ensure(await p.password({
184
+ message: `Paste the ${providerLabel(id)} now.`,
185
+ validate: (value) => value && value.trim().length > 0 ? undefined : 'The key cannot be empty.',
186
+ })).trim();
187
+ const s = p.spinner();
188
+ s.start('Checking the key...');
189
+ const verdict = await validateApiKey(id, key);
190
+ if (verdict === 'bad') {
191
+ // A clearly rejected key is never saved or marked configured.
192
+ s.stop('That key did not work.');
193
+ const again = await p.confirm({ message: 'Try a different key?', initialValue: true });
194
+ if (p.isCancel(again)) {
195
+ bail();
196
+ }
197
+ if (again) {
198
+ continue;
199
+ }
200
+ return null;
201
+ }
202
+ setSecret(KEY_ACCOUNT[id], key);
203
+ s.stop('Key saved.');
204
+ break;
205
+ }
206
+ audit('provider_change', `added ${id}`);
207
+ const configured = next.configuredProviders.includes(id)
208
+ ? next.configuredProviders
209
+ : [...next.configuredProviders, id];
210
+ return { ...next, configuredProviders: configured };
211
+ }
212
+ /**
213
+ * One provider add flow, shared by the wizard and the grown-ups panel.
214
+ * Returns updated settings, or null only when the parent picked Skip.
215
+ * A failed sign-in or a declined acknowledgment loops back to the picker,
216
+ * so the parent is never dead-ended out of the provider step.
217
+ * Secrets persist immediately; the caller persists the settings.
218
+ */
219
+ export async function configureProvider(settings) {
220
+ for (;;) {
221
+ const choice = await p.select({
222
+ message: T.wizard.providerPick,
223
+ options: providerOptions().map((o) => ({
224
+ value: o.value,
225
+ label: o.label,
226
+ ...(o.hint !== undefined ? { hint: o.hint } : {}),
227
+ })),
228
+ initialValue: 'openai-chatgpt',
229
+ });
230
+ if (p.isCancel(choice)) {
231
+ bail();
232
+ }
233
+ if (choice === 'skip') {
234
+ return null;
235
+ }
236
+ const updated = choice === 'openai-chatgpt'
237
+ ? await addChatgptProvider(settings)
238
+ : await addKeyProvider(choice, settings);
239
+ if (updated !== null) {
240
+ return updated;
241
+ }
242
+ }
243
+ }
244
+ async function createPinStep() {
245
+ if (hasPin()) {
246
+ p.log.info('A grown-up PIN is already set. We will keep it.');
247
+ return;
248
+ }
249
+ for (;;) {
250
+ const first = ensure(await p.password({
251
+ message: T.wizard.pinCreate,
252
+ validate: (value) => value && value.trim().length >= 4 ? undefined : 'Use at least 4 characters.',
253
+ }));
254
+ const second = ensure(await p.password({ message: T.wizard.pinConfirm }));
255
+ if (first === second) {
256
+ setPin(first);
257
+ p.log.success('PIN saved.');
258
+ return;
259
+ }
260
+ p.log.warn('Those did not match. Let us try again.');
261
+ }
262
+ }
263
+ async function consentStep(settings) {
264
+ const ageBand = ensure(await p.select({
265
+ message: 'How old is your kid?',
266
+ options: [
267
+ { value: 'under13', label: 'Under 13' },
268
+ { value: 'teen', label: '13 or older' },
269
+ ],
270
+ initialValue: 'under13',
271
+ }));
272
+ const agreed = ensure(await p.confirm({ message: `${T.wizard.consentIntro} Do you agree?`, initialValue: true }));
273
+ if (!agreed) {
274
+ bail();
275
+ }
276
+ const attestedAt = new Date().toISOString();
277
+ try {
278
+ appendAudit({
279
+ ts: attestedAt,
280
+ layer: 'system',
281
+ event: 'consent',
282
+ excerpt: `parent consent, age band ${ageBand}`,
283
+ });
284
+ }
285
+ catch {
286
+ // Consent still counts; the audit line is best effort.
287
+ }
288
+ return { ...settings, ageBand, consentAttestedAt: attestedAt };
289
+ }
290
+ async function providerLoop(settings) {
291
+ let current = settings;
292
+ for (;;) {
293
+ const updated = await configureProvider(current);
294
+ if (updated === null) {
295
+ if (current.configuredProviders.length === 0) {
296
+ p.log.info(`${T.offline.noProvider} ${T.offline.stillWorks}`);
297
+ }
298
+ break;
299
+ }
300
+ current = updated;
301
+ const more = ensure(await p.confirm({ message: 'Add another AI helper account?', initialValue: false }));
302
+ if (!more) {
303
+ break;
304
+ }
305
+ }
306
+ if (current.configuredProviders.length === 1) {
307
+ return { ...current, activeProvider: current.configuredProviders[0] ?? null };
308
+ }
309
+ if (current.configuredProviders.length > 1) {
310
+ const active = ensure(await p.select({
311
+ message: 'Which one should Termi use?',
312
+ options: current.configuredProviders.map((id) => ({ value: id, label: providerLabel(id) })),
313
+ initialValue: current.configuredProviders[0],
314
+ }));
315
+ return { ...current, activeProvider: active };
316
+ }
317
+ return { ...current, activeProvider: null };
318
+ }
319
+ async function safetyStep(settings) {
320
+ const level = ensure(await p.select({
321
+ message: T.wizard.safetyPick,
322
+ options: [
323
+ { value: 'strict', label: 'Strict', hint: 'Best for most kids.' },
324
+ { value: 'standard', label: 'Standard' },
325
+ ],
326
+ initialValue: 'strict',
327
+ }));
328
+ return { ...settings, safetyLevel: level };
329
+ }
330
+ /**
331
+ * Offers the on-device safety checker download. Default is yes: the
332
+ * classifier setting ships on, and this step starts fetching its model file
333
+ * in the background so setup (and building) never waits on 623 MB. The
334
+ * pipeline hot-attaches the checker the moment the verified file lands; the
335
+ * home menu shows the progress bar until then. Declining turns the setting
336
+ * off; a failed or interrupted download resumes on the next start.
337
+ */
338
+ async function localGuardStep(settings) {
339
+ if (guardModelReady()) {
340
+ // Model already present: keep whatever the parent chose before. A
341
+ // wizard re-run must not silently flip a deliberate off back to on.
342
+ return settings;
343
+ }
344
+ const wants = ensure(await p.confirm({ message: T.wizard.guardOffer, initialValue: true }));
345
+ if (!wants) {
346
+ p.log.info(T.wizard.guardDeclined);
347
+ audit('settings_change', 'local classifier off (declined in setup)');
348
+ return { ...settings, localClassifier: false };
349
+ }
350
+ void ensureGuardFetch();
351
+ p.log.info(T.wizard.guardBackground);
352
+ return { ...settings, localClassifier: true };
353
+ }
354
+ async function kidNicknameStep(settings) {
355
+ console.log(mascot('happy'));
356
+ console.log(T.wizard.kidHello);
357
+ const nickname = ensure(await p.text({
358
+ message: T.wizard.nicknamePrompt,
359
+ placeholder: 'Like RocketFox or PixelPanda',
360
+ validate: (value) => {
361
+ const trimmed = (value ?? '').trim();
362
+ if (trimmed.length === 0)
363
+ return 'Pick any fun name. It cannot be empty.';
364
+ if (trimmed.length > 24)
365
+ return 'Keep it under 24 letters.';
366
+ if (!nameIsOkay(trimmed))
367
+ return 'That name will not work. Pick a made-up one.';
368
+ return undefined;
369
+ },
370
+ })).trim();
371
+ return { ...settings, kidNickname: nickname };
372
+ }
373
+ async function firstGameStep(settings) {
374
+ const wants = await p.confirm({ message: T.wizard.firstGameOffer, initialValue: true });
375
+ if (p.isCancel(wants) || !wants) {
376
+ return;
377
+ }
378
+ const games = scaffoldById('games');
379
+ if (games === undefined) {
380
+ return;
381
+ }
382
+ const themeId = await p.select({
383
+ message: 'Pick a style for your game.',
384
+ options: games.themes.map((t) => ({ value: t.id, label: `${t.emoji} ${t.label}` })),
385
+ });
386
+ if (p.isCancel(themeId)) {
387
+ return;
388
+ }
389
+ const theme = games.themes.find((t) => t.id === themeId);
390
+ if (theme === undefined) {
391
+ return;
392
+ }
393
+ const CUSTOM = '__custom__';
394
+ const suggestions = suggestProjectNames(theme.label);
395
+ const namePick = await p.select({
396
+ message: 'Pick a name for it.',
397
+ options: [
398
+ ...suggestions.map((n) => ({ value: n, label: n })),
399
+ { value: CUSTOM, label: 'Type my own name' },
400
+ ],
401
+ });
402
+ if (p.isCancel(namePick)) {
403
+ return;
404
+ }
405
+ let prettyName = namePick;
406
+ if (namePick === CUSTOM) {
407
+ const typed = await p.text({
408
+ message: 'What is its name?',
409
+ validate: (value) => {
410
+ const trimmed = (value ?? '').trim();
411
+ if (trimmed.length === 0)
412
+ return 'It needs a name.';
413
+ if (!nameIsOkay(trimmed))
414
+ return 'That name will not work. Try another one.';
415
+ return undefined;
416
+ },
417
+ });
418
+ if (p.isCancel(typed)) {
419
+ return;
420
+ }
421
+ prettyName = typed.trim();
422
+ }
423
+ try {
424
+ const create = await import('../projects/create.js');
425
+ const home = await import('../surfaces/home.js');
426
+ const made = create.createProject('games', theme.id, prettyName);
427
+ await home.awardBadge('first-project');
428
+ const starters = made.starterPrompts.slice(0, 2);
429
+ if (starters.length > 0) {
430
+ p.note(starters.map((line) => `- ${line}`).join('\n'), 'Try saying');
431
+ }
432
+ await home.openChatLoop(made.project, settings);
433
+ }
434
+ catch {
435
+ p.log.warn('I could not start the game yet. Try "termi new" next time.');
436
+ }
437
+ }
438
+ /** Runs the full setup wizard, parent flow then kid flow. */
439
+ export async function runWizard() {
440
+ console.log(renderBanner());
441
+ p.intro(T.wizard.parentIntro);
442
+ await createPinStep();
443
+ let settings = { ...loadSettings().settings };
444
+ if (settings.version !== 1) {
445
+ settings = defaultSettings();
446
+ }
447
+ settings = await consentStep(settings);
448
+ settings = await providerLoop(settings);
449
+ settings = await safetyStep(settings);
450
+ settings = await localGuardStep(settings);
451
+ settings = saveSettings(settings);
452
+ markSetupComplete();
453
+ p.note(T.wizard.handToKid, 'All set');
454
+ settings = await kidNicknameStep(settings);
455
+ settings = saveSettings(settings);
456
+ p.note(T.wizard.aiDisclosure, 'One thing to know');
457
+ const wantsLauncher = await p.confirm({
458
+ message: 'Make a Termi shortcut on the Desktop?',
459
+ initialValue: true,
460
+ });
461
+ if (!p.isCancel(wantsLauncher) && wantsLauncher) {
462
+ const written = writeLauncher();
463
+ if (written !== null) {
464
+ p.log.success(T.wizard.launcherMade);
465
+ }
466
+ }
467
+ await firstGameStep(settings);
468
+ p.outro('Setup is done. Happy building!');
469
+ }