icoa-cli 2.19.355 → 2.19.357

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 (81) hide show
  1. package/dist/commands/ai4ctf.js +1 -1
  2. package/dist/commands/arena.js +1 -1
  3. package/dist/commands/ctf.js +787 -1
  4. package/dist/commands/ctf4ai-demo.js +1 -1
  5. package/dist/commands/ctf4vla.js +1 -1
  6. package/dist/commands/demo2.js +1502 -1
  7. package/dist/commands/exam.js +1 -1
  8. package/dist/commands/files.js +59 -1
  9. package/dist/commands/ipynb.d.ts +10 -4
  10. package/dist/commands/ipynb.js +1 -1
  11. package/dist/commands/lang.js +202 -1
  12. package/dist/commands/log.js +171 -1
  13. package/dist/commands/shell.d.ts +15 -0
  14. package/dist/commands/shell.js +151 -1
  15. package/dist/commands/sim.js +389 -1
  16. package/dist/index.js +355 -1
  17. package/dist/lib/access.js +184 -1
  18. package/dist/lib/aienv.js +205 -1
  19. package/dist/lib/arena-submit.js +21 -1
  20. package/dist/lib/banner.js +31 -1
  21. package/dist/lib/budget.js +6 -1
  22. package/dist/lib/challenge-dir.js +16 -1
  23. package/dist/lib/colors.js +17 -1
  24. package/dist/lib/comms.js +212 -1
  25. package/dist/lib/config.js +93 -1
  26. package/dist/lib/countdown.js +43 -1
  27. package/dist/lib/country-lang.js +39 -1
  28. package/dist/lib/ctfd-client.js +417 -1
  29. package/dist/lib/demo-exam.js +478 -1
  30. package/dist/lib/demo-flags.js +27 -1
  31. package/dist/lib/demo-stats.js +62 -1
  32. package/dist/lib/demo2-progress.js +102 -1
  33. package/dist/lib/docker-probe.d.ts +45 -0
  34. package/dist/lib/docker-probe.js +118 -0
  35. package/dist/lib/editor-spawn.d.ts +23 -0
  36. package/dist/lib/editor-spawn.js +53 -0
  37. package/dist/lib/exam-client.js +54 -1
  38. package/dist/lib/exam-sandbox.js +201 -1
  39. package/dist/lib/exam-setup.js +36 -1
  40. package/dist/lib/exam-state.js +273 -1
  41. package/dist/lib/gemini.js +247 -1
  42. package/dist/lib/i18n.js +302 -1
  43. package/dist/lib/integrity-snapshot.js +88 -1
  44. package/dist/lib/interactive-spawn.js +55 -1
  45. package/dist/lib/ipynb-input.js +65 -1
  46. package/dist/lib/kernel-protocol.js +88 -1
  47. package/dist/lib/kernel.js +146 -2
  48. package/dist/lib/learn-curricula.js +309 -1
  49. package/dist/lib/learn-i18n.js +184 -1
  50. package/dist/lib/learn-input.js +101 -1
  51. package/dist/lib/learn-render.js +863 -1
  52. package/dist/lib/learn-state.js +103 -1
  53. package/dist/lib/log-sync.js +155 -1
  54. package/dist/lib/logger.js +49 -1
  55. package/dist/lib/main-rl.js +7 -1
  56. package/dist/lib/menu-nav.js +105 -1
  57. package/dist/lib/notebook-doc.d.ts +38 -0
  58. package/dist/lib/notebook-doc.js +137 -0
  59. package/dist/lib/open-file.js +55 -1
  60. package/dist/lib/paper-upgrade.js +119 -1
  61. package/dist/lib/platform.js +99 -1
  62. package/dist/lib/render-card.js +112 -1
  63. package/dist/lib/repl-asker.js +67 -1
  64. package/dist/lib/sample-runner.js +227 -1
  65. package/dist/lib/sandbox.d.ts +25 -1
  66. package/dist/lib/sandbox.js +144 -1
  67. package/dist/lib/shell-split.js +69 -1
  68. package/dist/lib/sim-cooldown.js +75 -1
  69. package/dist/lib/theme.js +119 -1
  70. package/dist/lib/token-format.js +74 -1
  71. package/dist/lib/tool-man.js +418 -1
  72. package/dist/lib/toolset-hash.js +48 -1
  73. package/dist/lib/translation.js +80 -1
  74. package/dist/lib/translations-fetcher.js +95 -1
  75. package/dist/lib/ui.js +99 -1
  76. package/dist/lib/update-check.js +114 -1
  77. package/dist/lib/version.js +24 -1
  78. package/dist/postinstall.js +48 -1
  79. package/dist/repl.js +2391 -1
  80. package/dist/types/index.js +63 -1
  81. package/package.json +1 -1
@@ -1 +1,103 @@
1
- import{existsSync as e,mkdirSync as t,readFileSync as r,writeFileSync as a}from"node:fs";import{homedir as n}from"node:os";import{join as o}from"node:path";const s=o(n(),".icoa"),c=o(s,"learn-state.json");export function loadLearnState(){if(!e(c))return null;try{return JSON.parse(r(c,"utf-8"))}catch{return null}}export function saveLearnState(r){e(s)||t(s,{recursive:!0}),r.lastSeenAt=(new Date).toISOString(),a(c,JSON.stringify(r,null,2))}export function newLearnState(e,t,r){const a=(new Date).toISOString();return{token:e,curriculumId:t,currentCard:1,totalCards:r,startedAt:a,lastSeenAt:a,streakDays:1,longestStreak:1,cardsCompleted:[],mcqResults:{},checkResults:{},practicalsCompleted:[],bookmarks:[],achievements:[],totalSecondsActive:0}}export function updateStreak(e){const t=new Date(e.lastSeenAt),r=new Date,a=Math.floor(t.getTime()/864e5),n=Math.floor(r.getTime()/864e5)-a;0!==n&&(1===n?(e.streakDays+=1,e.streakDays>e.longestStreak&&(e.longestStreak=e.streakDays)):e.streakDays=1)}export function markCardComplete(e,t){e.cardsCompleted.includes(t)||e.cardsCompleted.push(t)}export function recordMCQ(e,t,r){e.mcqResults[String(t)]=r}export function recordCheck(e,t,r){e.checkResults||(e.checkResults={}),e.checkResults[String(t)]=r}export function markPracticalComplete(e,t){e.practicalsCompleted.includes(t)||e.practicalsCompleted.push(t)}export function addAchievement(e,t){e.achievements.includes(t)||e.achievements.push(t)}
1
+ /**
2
+ * Learn-mode state persistence.
3
+ *
4
+ * Local file: ~/.icoa/learn-state.json
5
+ * Schema designed to mirror server-side learn_progress table for Phase 1 sync.
6
+ *
7
+ * Unlike exam state, learn state ENCOURAGES multi-device: same token can be
8
+ * resumed on any machine (Phase 1 server-sync), no device-hash binding.
9
+ */
10
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
11
+ import { homedir } from 'node:os';
12
+ import { join } from 'node:path';
13
+ const STATE_DIR = join(homedir(), '.icoa');
14
+ const STATE_PATH = join(STATE_DIR, 'learn-state.json');
15
+ function ensureDir() {
16
+ if (!existsSync(STATE_DIR))
17
+ mkdirSync(STATE_DIR, { recursive: true });
18
+ }
19
+ export function loadLearnState() {
20
+ if (!existsSync(STATE_PATH))
21
+ return null;
22
+ try {
23
+ return JSON.parse(readFileSync(STATE_PATH, 'utf-8'));
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ export function saveLearnState(state) {
30
+ ensureDir();
31
+ state.lastSeenAt = new Date().toISOString();
32
+ writeFileSync(STATE_PATH, JSON.stringify(state, null, 2));
33
+ }
34
+ export function newLearnState(token, curriculumId, totalCards) {
35
+ const now = new Date().toISOString();
36
+ return {
37
+ token,
38
+ curriculumId,
39
+ currentCard: 1,
40
+ totalCards,
41
+ startedAt: now,
42
+ lastSeenAt: now,
43
+ streakDays: 1,
44
+ longestStreak: 1,
45
+ cardsCompleted: [],
46
+ mcqResults: {},
47
+ checkResults: {},
48
+ practicalsCompleted: [],
49
+ bookmarks: [],
50
+ achievements: [],
51
+ totalSecondsActive: 0,
52
+ };
53
+ }
54
+ /**
55
+ * Update streak based on last-seen-at vs today.
56
+ * - Same day: streak unchanged
57
+ * - Next day: streak + 1
58
+ * - Gap > 1 day: streak resets to 1
59
+ */
60
+ export function updateStreak(state) {
61
+ const last = new Date(state.lastSeenAt);
62
+ const now = new Date();
63
+ // Compare calendar days (UTC for simplicity)
64
+ const lastDay = Math.floor(last.getTime() / 86400000);
65
+ const nowDay = Math.floor(now.getTime() / 86400000);
66
+ const diff = nowDay - lastDay;
67
+ if (diff === 0)
68
+ return;
69
+ if (diff === 1) {
70
+ state.streakDays += 1;
71
+ if (state.streakDays > state.longestStreak)
72
+ state.longestStreak = state.streakDays;
73
+ }
74
+ else {
75
+ state.streakDays = 1; // reset
76
+ }
77
+ }
78
+ export function markCardComplete(state, cardNumber) {
79
+ // Records completion only — does NOT advance currentCard. The REPL's `ok`
80
+ // handler is the sole place that advances, to keep card flow predictable
81
+ // (no double-jumps from card N to N+2).
82
+ if (!state.cardsCompleted.includes(cardNumber)) {
83
+ state.cardsCompleted.push(cardNumber);
84
+ }
85
+ }
86
+ export function recordMCQ(state, cardNumber, result) {
87
+ state.mcqResults[String(cardNumber)] = result;
88
+ }
89
+ export function recordCheck(state, cardNumber, result) {
90
+ if (!state.checkResults)
91
+ state.checkResults = {};
92
+ state.checkResults[String(cardNumber)] = result;
93
+ }
94
+ export function markPracticalComplete(state, cardNumber) {
95
+ if (!state.practicalsCompleted.includes(cardNumber)) {
96
+ state.practicalsCompleted.push(cardNumber);
97
+ }
98
+ }
99
+ export function addAchievement(state, badge) {
100
+ if (!state.achievements.includes(badge)) {
101
+ state.achievements.push(badge);
102
+ }
103
+ }
@@ -1 +1,155 @@
1
- import{readFileSync as t,existsSync as n,writeFileSync as e,chmodSync as s}from"node:fs";import{join as i}from"node:path";import{getIcoaDir as o,getConfig as c}from"./config.js";import{getDeviceFingerprint as r}from"./access.js";import{getCliVersion as a}from"./version.js";const l=()=>i(o(),"sync-state.json");let u=null;function f(t){const n=l();e(n,JSON.stringify(t,null,2));try{s(n,384)}catch{}}async function y(){const e=c(),s=e.ctfdUrl||"https://practice.icoa2026.au",u=i(o(),"session.log");if(!n(u))return;const y=function(){const e=l();if(!n(e))return{lastSyncedLine:0,lastSyncAt:null,syncCount:0,failCount:0,seq:0};try{const n=JSON.parse(t(e,"utf-8"));return{lastSyncedLine:n.lastSyncedLine??0,lastSyncAt:n.lastSyncAt??null,syncCount:n.syncCount??0,failCount:n.failCount??0,seq:n.seq??0}}catch{return{lastSyncedLine:0,lastSyncAt:null,syncCount:0,failCount:0,seq:0}}}(),d=t(u,"utf-8").trim().split("\n").filter(Boolean);d.length<y.lastSyncedLine&&(y.lastSyncedLine=0);const p=d.slice(y.lastSyncedLine);if(0===p.length)return;const S={"Content-Type":"application/json"};let m;if(e.token)S.Authorization=`Token ${e.token}`,m=`user:${e.userId??"unknown"}`;else{const t=e.deviceFingerprint||r();S["X-Device-Fingerprint"]=t,m=`demo:${t.slice(0,12)}`}const g=y.seq??0,h=p.map((t,n)=>{let e;try{e=JSON.parse(t)}catch{e={raw:t}}return{...e,_seq:g+n+1}}),C=a(),I=h.map(t=>({...t,_cli:C})),L={identity:m,userId:e.userId,userName:e.userName,teamId:e.teamId,sessionId:e.sessionId,deviceFingerprint:e.deviceFingerprint||r(),lang:e.language||"en",clientVersion:C,timestamp:(new Date).toISOString(),seqRange:{from:g+1,to:g+h.length},entries:I};try{const t=new URL("/api/icoa/audit",s).href;(await fetch(t,{method:"POST",headers:S,body:JSON.stringify(L),signal:AbortSignal.timeout(1e4)})).ok?(y.lastSyncedLine=d.length,y.lastSyncAt=(new Date).toISOString(),y.syncCount++,y.seq=g+h.length,f(y)):(y.failCount++,f(y))}catch{y.failCount++,f(y)}}export function startLogSync(){setTimeout(()=>y(),5e3),u=setInterval(()=>y(),3e4)}export function stopLogSync(){u&&(clearInterval(u),u=null),y()}
1
+ import { readFileSync, existsSync, writeFileSync, chmodSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { getIcoaDir, getConfig } from './config.js';
4
+ import { getDeviceFingerprint } from './access.js';
5
+ import { getCliVersion } from './version.js';
6
+ // Every 30s, post new lines from ~/.icoa/session.log to the server audit
7
+ // endpoint. Two auth modes:
8
+ //
9
+ // 1. Logged-in user (CTFd token present) → Authorization: Token <token>,
10
+ // stored on the server as /opt/CTFd/audit/user-<userId>.jsonl
11
+ // 2. Anonymous demo user (no token) → X-Device-Fingerprint: <hash>,
12
+ // stored as /opt/CTFd/audit/demo-<fingerprint>.jsonl
13
+ //
14
+ // The demo path is what lets us observe the full prompt/command history of
15
+ // users who never log in (the main demo audience).
16
+ //
17
+ // V7 fix (v2.19.181): Each entry carries a monotonic `seq` counter that
18
+ // only increments (never resets, never decreases). Server stores last seq
19
+ // per identity. Deleting session.log + sync-state.json no longer hides
20
+ // activity — the seq jump tells the server a chunk was wiped.
21
+ const SYNC_INTERVAL = 30_000;
22
+ const DEFAULT_SERVER = 'https://practice.icoa2026.au';
23
+ const SYNC_STATE_FILE = () => join(getIcoaDir(), 'sync-state.json');
24
+ let syncTimer = null;
25
+ function getSyncState() {
26
+ const file = SYNC_STATE_FILE();
27
+ if (!existsSync(file))
28
+ return { lastSyncedLine: 0, lastSyncAt: null, syncCount: 0, failCount: 0, seq: 0 };
29
+ try {
30
+ const parsed = JSON.parse(readFileSync(file, 'utf-8'));
31
+ return {
32
+ lastSyncedLine: parsed.lastSyncedLine ?? 0,
33
+ lastSyncAt: parsed.lastSyncAt ?? null,
34
+ syncCount: parsed.syncCount ?? 0,
35
+ failCount: parsed.failCount ?? 0,
36
+ seq: parsed.seq ?? 0,
37
+ };
38
+ }
39
+ catch {
40
+ return { lastSyncedLine: 0, lastSyncAt: null, syncCount: 0, failCount: 0, seq: 0 };
41
+ }
42
+ }
43
+ function saveSyncState(state) {
44
+ const f = SYNC_STATE_FILE();
45
+ writeFileSync(f, JSON.stringify(state, null, 2));
46
+ try {
47
+ chmodSync(f, 0o600);
48
+ }
49
+ catch {
50
+ /* ignore Windows / restricted FS */
51
+ }
52
+ }
53
+ async function syncLogs() {
54
+ const config = getConfig();
55
+ const serverUrl = config.ctfdUrl || DEFAULT_SERVER;
56
+ const logPath = join(getIcoaDir(), 'session.log');
57
+ if (!existsSync(logPath))
58
+ return;
59
+ const state = getSyncState();
60
+ const allLines = readFileSync(logPath, 'utf-8').trim().split('\n').filter(Boolean);
61
+ // V7 monotonic guard: if the log shrank below our last-synced position
62
+ // (file was deleted/truncated), our cursor would otherwise stay ahead of
63
+ // the new content forever. Detect the shrink and resume from 0, but the
64
+ // server-side `seq` counter keeps climbing, so the gap is still evident
65
+ // to whoever audits the JSONL.
66
+ if (allLines.length < state.lastSyncedLine) {
67
+ state.lastSyncedLine = 0;
68
+ }
69
+ const newLines = allLines.slice(state.lastSyncedLine);
70
+ if (newLines.length === 0)
71
+ return;
72
+ // Pick auth mode: token for logged-in users, device fingerprint for anon.
73
+ const headers = { 'Content-Type': 'application/json' };
74
+ let identity;
75
+ if (config.token) {
76
+ headers.Authorization = `Token ${config.token}`;
77
+ identity = `user:${config.userId ?? 'unknown'}`;
78
+ }
79
+ else {
80
+ const fp = config.deviceFingerprint || getDeviceFingerprint();
81
+ headers['X-Device-Fingerprint'] = fp;
82
+ identity = `demo:${fp.slice(0, 12)}`;
83
+ }
84
+ // Tag each entry with a monotonic seq number. The seq counter lives in
85
+ // sync-state.json and only ever increments (V7 fix). If the contestant
86
+ // deletes session.log to hide activity, the next batch starts at seq=N+k
87
+ // and the server sees the jump.
88
+ const baseSeq = state.seq ?? 0;
89
+ const taggedEntries = newLines.map((line, idx) => {
90
+ let parsed;
91
+ try {
92
+ parsed = JSON.parse(line);
93
+ }
94
+ catch {
95
+ parsed = { raw: line };
96
+ }
97
+ return { ...parsed, _seq: baseSeq + idx + 1 };
98
+ });
99
+ const cliVersion = getCliVersion();
100
+ // V2.19.185: stamp every entry with the CLI version. Lets future forensic
101
+ // audits answer "which CLI version did this contestant run?" — see the
102
+ // Paper E dispatcher post-mortem (cross-country-audit-2026-05-23.pdf §6
103
+ // Gap #2). Top-level field also kept for backward search ergonomics.
104
+ const entriesWithVersion = taggedEntries.map((e) => ({ ...e, _cli: cliVersion }));
105
+ const payload = {
106
+ identity,
107
+ userId: config.userId,
108
+ userName: config.userName,
109
+ teamId: config.teamId,
110
+ sessionId: config.sessionId,
111
+ deviceFingerprint: config.deviceFingerprint || getDeviceFingerprint(),
112
+ lang: config.language || 'en',
113
+ clientVersion: cliVersion,
114
+ timestamp: new Date().toISOString(),
115
+ seqRange: { from: baseSeq + 1, to: baseSeq + taggedEntries.length },
116
+ entries: entriesWithVersion,
117
+ };
118
+ try {
119
+ const url = new URL('/api/icoa/audit', serverUrl).href;
120
+ const res = await fetch(url, {
121
+ method: 'POST',
122
+ headers,
123
+ body: JSON.stringify(payload),
124
+ signal: AbortSignal.timeout(10_000),
125
+ });
126
+ if (res.ok) {
127
+ state.lastSyncedLine = allLines.length;
128
+ state.lastSyncAt = new Date().toISOString();
129
+ state.syncCount++;
130
+ state.seq = baseSeq + taggedEntries.length;
131
+ saveSyncState(state);
132
+ }
133
+ else {
134
+ state.failCount++;
135
+ saveSyncState(state);
136
+ }
137
+ }
138
+ catch {
139
+ // Silent fail — network issues shouldn't break the CLI
140
+ state.failCount++;
141
+ saveSyncState(state);
142
+ }
143
+ }
144
+ export function startLogSync() {
145
+ setTimeout(() => syncLogs(), 5_000);
146
+ syncTimer = setInterval(() => syncLogs(), SYNC_INTERVAL);
147
+ }
148
+ export function stopLogSync() {
149
+ if (syncTimer) {
150
+ clearInterval(syncTimer);
151
+ syncTimer = null;
152
+ }
153
+ // Final sync before exit
154
+ syncLogs();
155
+ }
@@ -1 +1,49 @@
1
- import{appendFileSync as t,readFileSync as n,existsSync as o}from"node:fs";import{join as e}from"node:path";import{getConfig as i,getIcoaDir as s}from"./config.js";function r(){return e(s(),"session.log")}function m(n){try{t(r(),`${JSON.stringify(n)}\n`)}catch{}}export function logCommand(t){const n=i();m({timestamp:(new Date).toISOString(),level:"command",input:t,sessionId:n.sessionId})}export function logSubmission(t,n){const o=i();m({timestamp:(new Date).toISOString(),level:"submit",input:n,challengeId:t,sessionId:o.sessionId})}export function getSessionLog(){const t=r();if(!o(t))return[];try{return n(t,"utf-8").trim().split("\n").filter(Boolean).map(t=>JSON.parse(t))}catch{return[]}}
1
+ import { appendFileSync, readFileSync, existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { getConfig, getIcoaDir } from './config.js';
4
+ function getLogPath() {
5
+ return join(getIcoaDir(), 'session.log');
6
+ }
7
+ function logEntry(entry) {
8
+ try {
9
+ appendFileSync(getLogPath(), `${JSON.stringify(entry)}\n`);
10
+ }
11
+ catch {
12
+ // Silently fail — logging should never break the CLI
13
+ }
14
+ }
15
+ export function logCommand(command) {
16
+ const config = getConfig();
17
+ logEntry({
18
+ timestamp: new Date().toISOString(),
19
+ level: 'command',
20
+ input: command,
21
+ sessionId: config.sessionId,
22
+ });
23
+ }
24
+ export function logSubmission(challengeId, flag) {
25
+ const config = getConfig();
26
+ logEntry({
27
+ timestamp: new Date().toISOString(),
28
+ level: 'submit',
29
+ input: flag,
30
+ challengeId,
31
+ sessionId: config.sessionId,
32
+ });
33
+ }
34
+ export function getSessionLog() {
35
+ const logPath = getLogPath();
36
+ if (!existsSync(logPath))
37
+ return [];
38
+ try {
39
+ const raw = readFileSync(logPath, 'utf-8');
40
+ return raw
41
+ .trim()
42
+ .split('\n')
43
+ .filter(Boolean)
44
+ .map((line) => JSON.parse(line));
45
+ }
46
+ catch {
47
+ return [];
48
+ }
49
+ }
@@ -1 +1,7 @@
1
- let n=null;export function setMainRl(t){n=t}export function getMainRl(){return n}
1
+ let _mainRl = null;
2
+ export function setMainRl(rl) {
3
+ _mainRl = rl;
4
+ }
5
+ export function getMainRl() {
6
+ return _mainRl;
7
+ }
@@ -1 +1,105 @@
1
- import{spawn as e}from"node:child_process";import chalk from"chalk";export const RELAUNCH_CODE=75;export function printMenuHint(){console.log(chalk.gray(" 💡 Type ")+chalk.bold.cyan("menu")+chalk.gray(" anytime to return to the main menu (works from any screen)"))}export function menuHintInline(){return chalk.cyan("menu")+chalk.gray(" returns home")}export function returnToMainMenu(o){if(console.log(),console.log(chalk.gray(" Returning to main menu...")),console.log(),o?.removeAllListeners)try{o.removeAllListeners("close")}catch{}if(o)try{o.close()}catch{}try{process.stdin.isTTY&&process.stdin.setRawMode&&process.stdin.setRawMode(!1)}catch{}"1"===process.env.ICOA_SUPERVISED&&process.exit(75);const n=e(process.argv[0],process.argv.slice(1),{stdio:"inherit",env:process.env});n.on("exit",e=>process.exit(e??0)),n.on("error",e=>{console.error(chalk.yellow(` Could not respawn CLI: ${e.message}`)),process.exit(1)})}
1
+ /**
2
+ * Global "back to boot screen" navigation.
3
+ *
4
+ * The CLI has several sub-REPLs (ai4ctf, ctf4ai, ctf4vla, learn, demo2,
5
+ * exam REPL inside Selection mode) that own input handling once entered.
6
+ * `menu` is the universal escape hatch: from anywhere in the CLI, typing
7
+ * it returns the user to the launch banner + mode-selector picker.
8
+ *
9
+ * Implementation: re-execs the current node binary with the same argv,
10
+ * inheriting stdio. The new process re-runs startRepl from the top, which
11
+ * always shows the mode selector. The parent exits with the child's code.
12
+ *
13
+ * For real-exam state, the caller should gate via `menu confirm` to avoid
14
+ * surprise exits during a graded session. Exam progress is auto-saved on
15
+ * every answer, so even an unconfirmed exit is non-destructive — the
16
+ * confirmation is purely UX hygiene.
17
+ */
18
+ import { spawn } from 'node:child_process';
19
+ import chalk from 'chalk';
20
+ /**
21
+ * Exit code the worker uses to ask the supervisor (in index.ts) to relaunch a
22
+ * fresh CLI process. Picked from the unreserved 64–113 sysexits range so it
23
+ * can't collide with a normal `process.exit(0|1)`. See index.ts for the
24
+ * supervisor loop that consumes it.
25
+ */
26
+ export const RELAUNCH_CODE = 75;
27
+ /**
28
+ * One-line standalone hint for sub-REPLs and mode landing screens.
29
+ * Prints a friendly cue that `menu` is always available.
30
+ */
31
+ export function printMenuHint() {
32
+ console.log(chalk.gray(' 💡 Type ') +
33
+ chalk.bold.cyan('menu') +
34
+ chalk.gray(' anytime to return to the main menu (works from any screen)'));
35
+ }
36
+ /**
37
+ * Inline fragment for embedding in existing Tip / footer lines.
38
+ * Returns a chalk-styled string like: menu returns home
39
+ * so callers can splice it with ` · ` separators.
40
+ */
41
+ export function menuHintInline() {
42
+ return chalk.cyan('menu') + chalk.gray(' returns home');
43
+ }
44
+ export function returnToMainMenu(rl) {
45
+ console.log();
46
+ console.log(chalk.gray(' Returning to main menu...'));
47
+ console.log();
48
+ // Critical: the parent REPL registers an rl.on('close') handler that
49
+ // calls realExit(0). If we close rl with that handler still attached,
50
+ // the parent process dies BEFORE the child finishes starting — user
51
+ // ends up back at their shell instead of at the mode selector.
52
+ if (rl?.removeAllListeners) {
53
+ try {
54
+ rl.removeAllListeners('close');
55
+ }
56
+ catch {
57
+ // Not an EventEmitter — fine
58
+ }
59
+ }
60
+ if (rl) {
61
+ try {
62
+ rl.close();
63
+ }
64
+ catch {
65
+ // Already closed — fine
66
+ }
67
+ }
68
+ // Restore cooked terminal mode before we exit/respawn so the next process
69
+ // inherits a clean TTY. Without this a lingering raw-mode flag from this
70
+ // process's readline/inquirer could corrupt keystroke handling in the fresh
71
+ // boot (was a contributor to the "learn → lan" dropped-character bug).
72
+ try {
73
+ if (process.stdin.isTTY && process.stdin.setRawMode) {
74
+ process.stdin.setRawMode(false);
75
+ }
76
+ }
77
+ catch {
78
+ // Not a TTY / no raw mode — fine.
79
+ }
80
+ // Preferred path: a supervisor process (index.ts) is wrapping this worker.
81
+ // Exit with the relaunch sentinel and let the supervisor start exactly ONE
82
+ // fresh process. This replaces the old spawn-and-stay-alive approach, where
83
+ // every `menu` spawned a child while the parent kept running — after K menu
84
+ // bounces you had K+1 node processes all holding the inherited TTY, which
85
+ // slowed the CLI to a crawl and dropped keystrokes (a 4-deep process chain
86
+ // was observed in prod, 2026-06-04). With the supervisor, only one worker is
87
+ // ever live, so navigation stays fast no matter how many times you bounce.
88
+ if (process.env.ICOA_SUPERVISED === '1') {
89
+ process.exit(RELAUNCH_CODE);
90
+ }
91
+ // Fallback (no supervisor — e.g. a non-TTY/piped run, or a one-shot
92
+ // invocation the supervisor skips): keep the original respawn so behavior is
93
+ // unchanged for any path the supervisor doesn't cover. stdio:'inherit'
94
+ // shares the TTY; the parent waits on the child's exit to keep the OS TTY
95
+ // ownership chain intact.
96
+ const child = spawn(process.argv[0], process.argv.slice(1), {
97
+ stdio: 'inherit',
98
+ env: process.env,
99
+ });
100
+ child.on('exit', (code) => process.exit(code ?? 0));
101
+ child.on('error', (err) => {
102
+ console.error(chalk.yellow(` Could not respawn CLI: ${err.message}`));
103
+ process.exit(1);
104
+ });
105
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * notebook-doc — the pure cell model behind `icoa ipynb`'s cell-stepper
3
+ * (cells / show / edit / run / save). nbformat v4 is plain JSON, so parse and
4
+ * serialize live here with zero dependencies; keeping the module pure (no I/O,
5
+ * no chalk) means the whole document lifecycle is unit-tested without a kernel.
6
+ *
7
+ * Round-trip safety: cells loaded from disk keep their original outputs in
8
+ * `rawOutputs` and are written back verbatim on save unless the cell was
9
+ * re-run this session (then the fresh FoldedCell result wins).
10
+ */
11
+ import type { FoldedCell } from './kernel-protocol.js';
12
+ export type CellKind = 'code' | 'markdown';
13
+ export interface NbCell {
14
+ kind: CellKind;
15
+ source: string;
16
+ /** Result of running the cell THIS session; null = not run yet. */
17
+ result: FoldedCell | null;
18
+ /** nbformat outputs as loaded from disk (preserved on save if not re-run). */
19
+ rawOutputs?: unknown[];
20
+ rawExecCount?: number | null;
21
+ }
22
+ /** Parse a .ipynb (nbformat v4) JSON string. Raw cells are dropped. */
23
+ export declare function parseIpynb(json: string): NbCell[];
24
+ /**
25
+ * Split a Python script into cells on `# %%` markers (jupytext percent
26
+ * format; the arena starter uses the same markers). Marker lines are
27
+ * separators and are dropped. No markers → the whole file is one cell.
28
+ */
29
+ export declare function parsePercentPy(text: string): NbCell[];
30
+ /** Serialize cells to a Jupyter-openable nbformat v4.5 JSON string. */
31
+ export declare function serializeIpynb(cells: NbCell[]): string;
32
+ /** One-char run state for the `cells` listing. */
33
+ export declare function statusChar(cell: NbCell): string;
34
+ /** First non-blank line, trimmed and truncated to `width` (with ellipsis). */
35
+ export declare function cellPreview(cell: NbCell, width: number): string;
36
+ /** Parse `run` arguments: '3' → [3], '1-5' → [1..5], 'all' → [1..count].
37
+ * 1-based, validated against count; null = not a valid range. */
38
+ export declare function parseRunRange(arg: string, count: number): number[] | null;
@@ -0,0 +1,137 @@
1
+ /**
2
+ * notebook-doc — the pure cell model behind `icoa ipynb`'s cell-stepper
3
+ * (cells / show / edit / run / save). nbformat v4 is plain JSON, so parse and
4
+ * serialize live here with zero dependencies; keeping the module pure (no I/O,
5
+ * no chalk) means the whole document lifecycle is unit-tested without a kernel.
6
+ *
7
+ * Round-trip safety: cells loaded from disk keep their original outputs in
8
+ * `rawOutputs` and are written back verbatim on save unless the cell was
9
+ * re-run this session (then the fresh FoldedCell result wins).
10
+ */
11
+ /** Parse a .ipynb (nbformat v4) JSON string. Raw cells are dropped. */
12
+ export function parseIpynb(json) {
13
+ let obj;
14
+ try {
15
+ obj = JSON.parse(json);
16
+ }
17
+ catch {
18
+ throw new Error('not valid JSON');
19
+ }
20
+ const cellsRaw = obj?.cells;
21
+ if (!Array.isArray(cellsRaw))
22
+ throw new Error('not a Jupyter notebook (no cells array)');
23
+ const cells = [];
24
+ for (const c of cellsRaw) {
25
+ if (!c || typeof c !== 'object')
26
+ continue;
27
+ const t = c.cell_type;
28
+ if (t !== 'code' && t !== 'markdown')
29
+ continue;
30
+ const srcRaw = c.source;
31
+ const source = Array.isArray(srcRaw) ? srcRaw.join('') : typeof srcRaw === 'string' ? srcRaw : '';
32
+ const cell = { kind: t, source: source.replace(/\s+$/, ''), result: null };
33
+ if (t === 'code') {
34
+ const outs = c.outputs;
35
+ if (Array.isArray(outs) && outs.length > 0)
36
+ cell.rawOutputs = outs;
37
+ const ec = c.execution_count;
38
+ cell.rawExecCount = typeof ec === 'number' ? ec : null;
39
+ }
40
+ cells.push(cell);
41
+ }
42
+ return cells;
43
+ }
44
+ /**
45
+ * Split a Python script into cells on `# %%` markers (jupytext percent
46
+ * format; the arena starter uses the same markers). Marker lines are
47
+ * separators and are dropped. No markers → the whole file is one cell.
48
+ */
49
+ export function parsePercentPy(text) {
50
+ const cells = [];
51
+ let cur = [];
52
+ const flush = () => {
53
+ const src = cur.join('\n').replace(/^\n+/, '').replace(/\s+$/, '');
54
+ if (src)
55
+ cells.push({ kind: 'code', source: src, result: null });
56
+ cur = [];
57
+ };
58
+ for (const line of text.split('\n')) {
59
+ if (/^#\s*%%/.test(line))
60
+ flush();
61
+ else
62
+ cur.push(line);
63
+ }
64
+ flush();
65
+ return cells;
66
+ }
67
+ /** Map one cell's outputs to nbformat: fresh result wins over disk outputs. */
68
+ function outputsToNbformat(cell) {
69
+ if (cell.result) {
70
+ const outs = [];
71
+ for (const o of cell.result.outputs) {
72
+ if (o.kind === 'stream')
73
+ outs.push({ output_type: 'stream', name: o.name, text: o.text });
74
+ else if (o.kind === 'result')
75
+ outs.push({
76
+ output_type: 'execute_result',
77
+ execution_count: cell.result.execCount,
78
+ data: o.data ?? { 'text/plain': o.text },
79
+ metadata: {},
80
+ });
81
+ else if (o.kind === 'display')
82
+ outs.push({ output_type: 'display_data', data: o.data, metadata: {} });
83
+ else
84
+ outs.push({ output_type: 'error', ename: o.ename, evalue: o.evalue, traceback: o.traceback });
85
+ }
86
+ return { outputs: outs, execution_count: cell.result.execCount };
87
+ }
88
+ return { outputs: cell.rawOutputs ?? [], execution_count: cell.rawExecCount ?? null };
89
+ }
90
+ /** Serialize cells to a Jupyter-openable nbformat v4.5 JSON string. */
91
+ export function serializeIpynb(cells) {
92
+ const nb = {
93
+ nbformat: 4,
94
+ nbformat_minor: 5,
95
+ metadata: {
96
+ kernelspec: { display_name: 'Python 3', language: 'python', name: 'python3' },
97
+ language_info: { name: 'python', version: '3.12' },
98
+ },
99
+ cells: cells.map((c) => c.kind === 'markdown'
100
+ ? { cell_type: 'markdown', metadata: {}, source: c.source }
101
+ : { cell_type: 'code', metadata: {}, source: c.source, ...outputsToNbformat(c) }),
102
+ };
103
+ return `${JSON.stringify(nb, null, 1)}\n`;
104
+ }
105
+ /** One-char run state for the `cells` listing. */
106
+ export function statusChar(cell) {
107
+ if (cell.kind === 'markdown')
108
+ return 'md';
109
+ if (cell.result === null)
110
+ return '○';
111
+ return cell.result.ok ? '✓' : '✗';
112
+ }
113
+ /** First non-blank line, trimmed and truncated to `width` (with ellipsis). */
114
+ export function cellPreview(cell, width) {
115
+ const first = cell.source.split('\n').find((l) => l.trim() !== '') ?? '';
116
+ const t = first.trim();
117
+ if (t.length <= width)
118
+ return t;
119
+ return `${t.slice(0, Math.max(0, width - 1))}…`;
120
+ }
121
+ /** Parse `run` arguments: '3' → [3], '1-5' → [1..5], 'all' → [1..count].
122
+ * 1-based, validated against count; null = not a valid range. */
123
+ export function parseRunRange(arg, count) {
124
+ const a = arg.trim().toLowerCase();
125
+ if (!a)
126
+ return null;
127
+ if (a === 'all')
128
+ return Array.from({ length: count }, (_, i) => i + 1);
129
+ const m = a.match(/^(\d+)(?:-(\d+))?$/);
130
+ if (!m)
131
+ return null;
132
+ const from = Number(m[1]);
133
+ const to = m[2] ? Number(m[2]) : from;
134
+ if (from < 1 || to < from || to > count)
135
+ return null;
136
+ return Array.from({ length: to - from + 1 }, (_, i) => from + i);
137
+ }
@@ -1 +1,55 @@
1
- import{spawn as e,execFileSync as r}from"node:child_process";export function openFileCommand(e,r=process.platform,n=process.env){return"darwin"===r?{cmd:"open",args:[e]}:"win32"===r?{cmd:"cmd.exe",args:["/c","start","",e]}:function(e){return!(!e.WSL_DISTRO_NAME&&!e.WSL_INTEROP)}(n)?{cmd:"explorer.exe",args:[e]}:{cmd:"xdg-open",args:[e]}}export function openFile(n){const{cmd:o,args:t}=openFileCommand(n);let c=t;if("explorer.exe"===o)try{const e=r("wslpath",["-w",n],{encoding:"utf-8"}).trim();e&&(c=[e])}catch{}try{return e(o,c,{stdio:"ignore",detached:!0}).unref(),!0}catch{return!1}}
1
+ /**
2
+ * Cross-platform "open this file in the OS default app".
3
+ *
4
+ * Pure core `openFileCommand(path, platform, env)` → {cmd, args} is unit-testable
5
+ * across all four baseline targets without spawning; `openFile(path)` is the thin
6
+ * spawn wrapper. Extracted from arena.ts so `ipynb` (auto-open saved figures) and
7
+ * `arena` (open the downloaded baseline) share ONE opener — notably the WSL branch:
8
+ * xdg-open silently fails under WSL, so the file must be handed to Windows via
9
+ * explorer.exe — AND the Linux path must be translated to a Windows path first
10
+ * (explorer.exe can't resolve `/home/...`), else it "opens" nothing.
11
+ */
12
+ import { spawn, execFileSync } from 'node:child_process';
13
+ /** True when running under WSL. WSL2 sets WSL_DISTRO_NAME; WSL_INTEROP is also
14
+ * present and survives some shells/sudo invocations that drop WSL_DISTRO_NAME. */
15
+ function isWsl(env) {
16
+ return !!(env.WSL_DISTRO_NAME || env.WSL_INTEROP);
17
+ }
18
+ /** Decide the OS "open" command for `path`. Pure — platform + env are injected
19
+ * so every baseline target (macOS / Linux / WSL / Windows) is unit-testable. */
20
+ export function openFileCommand(path, platform = process.platform, env = process.env) {
21
+ if (platform === 'darwin')
22
+ return { cmd: 'open', args: [path] };
23
+ if (platform === 'win32')
24
+ return { cmd: 'cmd.exe', args: ['/c', 'start', '', path] };
25
+ // WSL has no Linux GUI — xdg-open silently fails. Hand the file to Windows.
26
+ if (isWsl(env))
27
+ return { cmd: 'explorer.exe', args: [path] };
28
+ return { cmd: 'xdg-open', args: [path] };
29
+ }
30
+ /** Open `path` in the OS default app (detached). Returns false if spawn throws. */
31
+ export function openFile(path) {
32
+ const { cmd, args } = openFileCommand(path);
33
+ let finalArgs = args;
34
+ // explorer.exe cannot resolve a Linux path (`/home/...`). Translate it to a
35
+ // Windows path (`\\wsl.localhost\<distro>\...`) via wslpath so the Windows
36
+ // default app actually opens the file. Without this, view "succeeds" silently
37
+ // but nothing appears — the WSL bug students hit.
38
+ if (cmd === 'explorer.exe') {
39
+ try {
40
+ const win = execFileSync('wslpath', ['-w', path], { encoding: 'utf-8' }).trim();
41
+ if (win)
42
+ finalArgs = [win];
43
+ }
44
+ catch {
45
+ /* wslpath unavailable — fall back to the raw path */
46
+ }
47
+ }
48
+ try {
49
+ spawn(cmd, finalArgs, { stdio: 'ignore', detached: true }).unref();
50
+ return true;
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ }