icoa-cli 2.19.356 → 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 (75) hide show
  1. package/dist/commands/ai4ctf.js +1 -1
  2. package/dist/commands/ctf.js +787 -1
  3. package/dist/commands/ctf4ai-demo.js +1 -1
  4. package/dist/commands/ctf4vla.js +1 -1
  5. package/dist/commands/demo2.js +1502 -1
  6. package/dist/commands/exam.js +1 -1
  7. package/dist/commands/files.js +59 -1
  8. package/dist/commands/lang.js +202 -1
  9. package/dist/commands/log.js +171 -1
  10. package/dist/commands/shell.js +151 -1
  11. package/dist/commands/sim.js +389 -1
  12. package/dist/index.js +355 -1
  13. package/dist/lib/access.js +184 -1
  14. package/dist/lib/aienv.js +205 -1
  15. package/dist/lib/arena-submit.js +21 -1
  16. package/dist/lib/banner.js +31 -1
  17. package/dist/lib/budget.js +6 -1
  18. package/dist/lib/challenge-dir.js +16 -1
  19. package/dist/lib/colors.js +17 -1
  20. package/dist/lib/comms.js +212 -1
  21. package/dist/lib/config.js +93 -1
  22. package/dist/lib/countdown.js +43 -1
  23. package/dist/lib/country-lang.js +39 -1
  24. package/dist/lib/ctfd-client.js +417 -1
  25. package/dist/lib/demo-exam.js +478 -1
  26. package/dist/lib/demo-flags.js +27 -1
  27. package/dist/lib/demo-stats.js +62 -1
  28. package/dist/lib/demo2-progress.js +102 -1
  29. package/dist/lib/docker-probe.d.ts +45 -0
  30. package/dist/lib/docker-probe.js +118 -0
  31. package/dist/lib/editor-spawn.js +53 -1
  32. package/dist/lib/exam-client.js +54 -1
  33. package/dist/lib/exam-sandbox.js +201 -1
  34. package/dist/lib/exam-setup.js +36 -1
  35. package/dist/lib/exam-state.js +273 -1
  36. package/dist/lib/gemini.js +247 -1
  37. package/dist/lib/i18n.js +302 -1
  38. package/dist/lib/integrity-snapshot.js +88 -1
  39. package/dist/lib/interactive-spawn.js +55 -1
  40. package/dist/lib/ipynb-input.js +65 -1
  41. package/dist/lib/kernel-protocol.js +88 -1
  42. package/dist/lib/kernel.js +146 -2
  43. package/dist/lib/learn-curricula.js +309 -1
  44. package/dist/lib/learn-i18n.js +184 -1
  45. package/dist/lib/learn-input.js +101 -1
  46. package/dist/lib/learn-render.js +863 -1
  47. package/dist/lib/learn-state.js +103 -1
  48. package/dist/lib/log-sync.js +155 -1
  49. package/dist/lib/logger.js +49 -1
  50. package/dist/lib/main-rl.js +7 -1
  51. package/dist/lib/menu-nav.js +105 -1
  52. package/dist/lib/notebook-doc.js +137 -1
  53. package/dist/lib/open-file.js +55 -1
  54. package/dist/lib/paper-upgrade.js +119 -1
  55. package/dist/lib/platform.js +99 -1
  56. package/dist/lib/render-card.js +112 -1
  57. package/dist/lib/repl-asker.js +67 -1
  58. package/dist/lib/sample-runner.js +227 -1
  59. package/dist/lib/sandbox.d.ts +7 -1
  60. package/dist/lib/sandbox.js +144 -1
  61. package/dist/lib/shell-split.js +69 -1
  62. package/dist/lib/sim-cooldown.js +75 -1
  63. package/dist/lib/theme.js +119 -1
  64. package/dist/lib/token-format.js +74 -1
  65. package/dist/lib/tool-man.js +418 -1
  66. package/dist/lib/toolset-hash.js +48 -1
  67. package/dist/lib/translation.js +80 -1
  68. package/dist/lib/translations-fetcher.js +95 -1
  69. package/dist/lib/ui.js +99 -1
  70. package/dist/lib/update-check.js +114 -1
  71. package/dist/lib/version.js +24 -1
  72. package/dist/postinstall.js +48 -1
  73. package/dist/repl.js +2391 -1
  74. package/dist/types/index.js +63 -1
  75. package/package.json +1 -1
@@ -1 +1,119 @@
1
- import chalk from"chalk";import{isNativeWindowsCmd as o,isInWSL as e,hasPython as l}from"./platform.js";export function showCToBUpgradePrompt(n,t){if(!function(o){return!!o&&/-2026-c$/i.test(o.trim())}(n))return;if(!t)return;const s=o(),a=e(),r=l();console.log(chalk.cyan(" ─────────────────────────────────────────────")),console.log(),console.log(chalk.bold.white(" ★ Ready for more? Paper B — K-12 with AI")),console.log(),console.log(chalk.gray(" Paper B adds ")+chalk.white("AI4CTF")+chalk.gray(" (chat with AI to find hidden flags) and ")+chalk.white("CTF4AI")+chalk.gray(" (break AI security).")),console.log(chalk.gray(" 150 points total · 90 minutes · same command interface you already know.")),console.log(),s?(console.log(chalk.bold.white(" To take Paper B on Windows, install WSL2 + Ubuntu 22:")),console.log(),console.log(chalk.white(" 1. Open PowerShell as Administrator")),console.log(chalk.gray(' (right-click PowerShell → "Run as administrator")')),console.log(chalk.white(" 2. Run: ")+chalk.cyan("wsl --install -d Ubuntu-22.04")),console.log(chalk.white(" 3. Reboot when prompted, create a Linux username + password")),console.log(chalk.white(" 4. Inside Ubuntu, install Node.js 22 and this CLI:")),console.log(chalk.gray(" ")+chalk.cyan("curl -fsSL https://deb.nodesource.com/setup_22.x | sudo bash -")),console.log(chalk.gray(" ")+chalk.cyan("sudo apt install -y nodejs")),console.log(chalk.gray(" ")+chalk.cyan("sudo npm install -g icoa-cli")),console.log(),console.log(chalk.gray(" Setup takes 30-60 min the first time. You only do this once."))):a?(console.log(chalk.bold.white(" You are on WSL2 — your setup is almost ready:")),console.log(),r?(console.log(chalk.green(" ✓ Python 3 already installed. You are ready.")),console.log()):(console.log(chalk.white(" Install Python 3 (for Paper B practical questions):")),console.log(chalk.gray(" ")+chalk.cyan("sudo apt install -y python3 python3-pip")),console.log())):"darwin"===process.platform?(console.log(chalk.bold.white(" On macOS, install Python 3 for Paper B practicals:")),console.log(),console.log(chalk.white(" With Homebrew:")),console.log(chalk.gray(" ")+chalk.cyan("brew install python@3.12")),console.log(),console.log(chalk.white(" Without Homebrew: download from ")+chalk.cyan.underline("https://python.org")),r&&(console.log(),console.log(chalk.green(" ✓ Python 3 already detected. You are ready."))),console.log()):(console.log(chalk.bold.white(" On Linux, install Python 3 for Paper B practicals:")),console.log(),console.log(chalk.white(" Ubuntu / Debian:")),console.log(chalk.gray(" ")+chalk.cyan("sudo apt install -y python3 python3-pip")),console.log(chalk.white(" Fedora / RHEL:")),console.log(chalk.gray(" ")+chalk.cyan("sudo dnf install -y python3 python3-pip")),r&&(console.log(),console.log(chalk.green(" ✓ Python 3 already detected. You are ready."))),console.log()),console.log(chalk.bold.white(" Then get a Paper B token from your organizer.")),console.log(chalk.gray(" Each token is one-shot and bound to one device.")),console.log(),console.log(chalk.gray(" Detailed step-by-step guide: ")+chalk.cyan.underline("https://icoa2026.au/selectionguide/")),console.log()}
1
+ /**
2
+ * Paper C → Paper B upgrade prompt.
3
+ *
4
+ * Shown after a successful Paper C submission. Paper C is the K-12 entry
5
+ * funnel (MCQ only, no tools). Paper B is the next step — adds AI4CTF
6
+ * and CTF4AI practical challenges for 150-point total. The upgrade path
7
+ * depends on which platform the student is on.
8
+ *
9
+ * Policy:
10
+ * - Only triggered after Paper C submission (examId ends in `-c` or
11
+ * starts with a country code + `-2026-c`).
12
+ * - Only triggered if student passed. Students who failed C should
13
+ * retry C before being pushed toward a harder paper.
14
+ * - Always points at https://icoa2026.au/selectionguide/ for detailed
15
+ * platform-specific install instructions, so the CLI copy stays
16
+ * concise and the canonical guide lives in one place.
17
+ * - Never prescribes a token — students must request one from their
18
+ * organizer. This prevents the CLI from implying self-service token
19
+ * issuance.
20
+ */
21
+ import chalk from 'chalk';
22
+ import { isNativeWindowsCmd, isInWSL, hasPython } from './platform.js';
23
+ /**
24
+ * True when the just-submitted exam is a C paper (MCQ entry funnel).
25
+ * Matches `ua-2026-c`, `pe-2026-c`, `cn-2026-c`, etc. Case-insensitive.
26
+ */
27
+ function isCPaper(examId) {
28
+ if (!examId)
29
+ return false;
30
+ return /-2026-c$/i.test(examId.trim());
31
+ }
32
+ /**
33
+ * Renders the C→B upgrade prompt to stdout. No-op if conditions aren't met.
34
+ *
35
+ * @param examId The submitted exam ID (from state.session.examId before clear)
36
+ * @param passed Whether the student met the passing bar
37
+ */
38
+ export function showCToBUpgradePrompt(examId, passed) {
39
+ if (!isCPaper(examId))
40
+ return;
41
+ if (!passed)
42
+ return;
43
+ const onCmd = isNativeWindowsCmd();
44
+ const onWSL = isInWSL();
45
+ const pyReady = hasPython();
46
+ console.log(chalk.cyan(' ─────────────────────────────────────────────'));
47
+ console.log();
48
+ console.log(chalk.bold.white(' ★ Ready for more? Paper B — K-12 with AI'));
49
+ console.log();
50
+ console.log(chalk.gray(' Paper B adds ') +
51
+ chalk.white('AI4CTF') +
52
+ chalk.gray(' (chat with AI to find hidden flags) and ') +
53
+ chalk.white('CTF4AI') +
54
+ chalk.gray(' (break AI security).'));
55
+ console.log(chalk.gray(' 150 points total · 90 minutes · same command interface you already know.'));
56
+ console.log();
57
+ if (onCmd) {
58
+ // Windows cmd / PowerShell — needs WSL2 path for full Paper B
59
+ console.log(chalk.bold.white(' To take Paper B on Windows, install WSL2 + Ubuntu 22:'));
60
+ console.log();
61
+ console.log(chalk.white(' 1. Open PowerShell as Administrator'));
62
+ console.log(chalk.gray(' (right-click PowerShell → "Run as administrator")'));
63
+ console.log(chalk.white(' 2. Run: ') + chalk.cyan('wsl --install -d Ubuntu-22.04'));
64
+ console.log(chalk.white(' 3. Reboot when prompted, create a Linux username + password'));
65
+ console.log(chalk.white(' 4. Inside Ubuntu, install Node.js 22 and this CLI:'));
66
+ console.log(chalk.gray(' ') + chalk.cyan('curl -fsSL https://deb.nodesource.com/setup_22.x | sudo bash -'));
67
+ console.log(chalk.gray(' ') + chalk.cyan('sudo apt install -y nodejs'));
68
+ console.log(chalk.gray(' ') + chalk.cyan('sudo npm install -g icoa-cli'));
69
+ console.log();
70
+ console.log(chalk.gray(' Setup takes 30-60 min the first time. You only do this once.'));
71
+ }
72
+ else if (onWSL) {
73
+ // Already on WSL2 — just need Python for practicals
74
+ console.log(chalk.bold.white(' You are on WSL2 — your setup is almost ready:'));
75
+ console.log();
76
+ if (!pyReady) {
77
+ console.log(chalk.white(' Install Python 3 (for Paper B practical questions):'));
78
+ console.log(chalk.gray(' ') + chalk.cyan('sudo apt install -y python3 python3-pip'));
79
+ console.log();
80
+ }
81
+ else {
82
+ console.log(chalk.green(' ✓ Python 3 already installed. You are ready.'));
83
+ console.log();
84
+ }
85
+ }
86
+ else if (process.platform === 'darwin') {
87
+ // macOS
88
+ console.log(chalk.bold.white(' On macOS, install Python 3 for Paper B practicals:'));
89
+ console.log();
90
+ console.log(chalk.white(' With Homebrew:'));
91
+ console.log(chalk.gray(' ') + chalk.cyan('brew install python@3.12'));
92
+ console.log();
93
+ console.log(chalk.white(' Without Homebrew: download from ') + chalk.cyan.underline('https://python.org'));
94
+ if (pyReady) {
95
+ console.log();
96
+ console.log(chalk.green(' ✓ Python 3 already detected. You are ready.'));
97
+ }
98
+ console.log();
99
+ }
100
+ else {
101
+ // Linux native (not WSL)
102
+ console.log(chalk.bold.white(' On Linux, install Python 3 for Paper B practicals:'));
103
+ console.log();
104
+ console.log(chalk.white(' Ubuntu / Debian:'));
105
+ console.log(chalk.gray(' ') + chalk.cyan('sudo apt install -y python3 python3-pip'));
106
+ console.log(chalk.white(' Fedora / RHEL:'));
107
+ console.log(chalk.gray(' ') + chalk.cyan('sudo dnf install -y python3 python3-pip'));
108
+ if (pyReady) {
109
+ console.log();
110
+ console.log(chalk.green(' ✓ Python 3 already detected. You are ready.'));
111
+ }
112
+ console.log();
113
+ }
114
+ console.log(chalk.bold.white(' Then get a Paper B token from your organizer.'));
115
+ console.log(chalk.gray(' Each token is one-shot and bound to one device.'));
116
+ console.log();
117
+ console.log(chalk.gray(' Detailed step-by-step guide: ') + chalk.cyan.underline('https://icoa2026.au/selectionguide/'));
118
+ console.log();
119
+ }
@@ -1 +1,99 @@
1
- import{execFileSync as o}from"node:child_process";import{lstatSync as t,realpathSync as n,readFileSync as r}from"node:fs";import{dirname as e}from"node:path";export function isInWSL(){return!(!process.env.WSL_DISTRO_NAME&&!process.env.WSLENV)}export function isNativeWindowsCmd(){return"win32"===process.platform&&!isInWSL()}export function hasPython(){const t=[["python3",["--version"]],["python",["--version"]],["py",["-3","--version"]]];for(const[n,r]of t)try{return o(n,r,{encoding:"utf-8",timeout:1500,stdio:["ignore","pipe","ignore"]}),!0}catch{}return!1}export function detectIcoaInstalls(){if(isNativeWindowsCmd())return[];let i=[];try{i=o("which",["-a","icoa"],{encoding:"utf-8",timeout:1500,stdio:["ignore","pipe","ignore"]}).trim().split("\n").filter(Boolean)}catch{return[]}return i.map(o=>{const i={path:o};try{let s=o;t(o).isSymbolicLink()&&(s=n(o));const c=e(s),p=e(c);i.pkgDir=p;const a=JSON.parse(r(`${p}/package.json`,"utf-8"));i.version=a.version}catch{}return i})}
1
+ /**
2
+ * Platform detection primitives — 3 exported probes:
3
+ * isInWSL() — running inside a WSL distribution
4
+ * isNativeWindowsCmd() — cmd.exe or PowerShell, NOT WSL
5
+ * hasPython() — any python3 / python / py binary reachable
6
+ *
7
+ * Callers (repl.ts menu, exam.ts setup gate, exam.ts peek logic) inline
8
+ * the policy decisions — e.g. `!isNativeWindowsCmd()` for "show exam setup"
9
+ * and `isNativeWindowsCmd()` for "recommend C paper". This file stays as a
10
+ * thin detection layer; policy lives with its caller.
11
+ *
12
+ * Context: Windows middle-school students (12-14 y/o) are an expected cohort
13
+ * for ICOA 2026. Observed in-field: students blocked by WSL install complexity.
14
+ * Windows native cmd + Node.js handles MCQ + AI chat but not Q31-36 practical
15
+ * (Unix grep / strings / pwntools). Decision (v2.19.97 + v2.19.110): C paper
16
+ * is the cmd / setup-less entry funnel. See the peek-based gate in exam.ts.
17
+ */
18
+ import { execFileSync } from 'node:child_process';
19
+ import { lstatSync, realpathSync, readFileSync } from 'node:fs';
20
+ import { dirname } from 'node:path';
21
+ /**
22
+ * Heuristic: true when running inside a WSL distribution. Node.js reports
23
+ * `process.platform === 'linux'` in that case, but WSL sets specific env vars.
24
+ */
25
+ export function isInWSL() {
26
+ return !!(process.env.WSL_DISTRO_NAME || process.env.WSLENV);
27
+ }
28
+ /**
29
+ * True when the user is on Windows native (cmd.exe or PowerShell), NOT a
30
+ * WSL distribution.
31
+ */
32
+ export function isNativeWindowsCmd() {
33
+ return process.platform === 'win32' && !isInWSL();
34
+ }
35
+ /**
36
+ * Probe whether any Python 3 binary is reachable. Silent — no stderr leaks
37
+ * to the user. Used to decide whether Q31-35 practical questions are even
38
+ * theoretically doable on this machine.
39
+ *
40
+ * Uses execFileSync (no shell) so probe failures don't leak cmd.exe's
41
+ * "not recognized as internal or external command" messages to stdout.
42
+ */
43
+ export function hasPython() {
44
+ const probes = [
45
+ ['python3', ['--version']],
46
+ ['python', ['--version']],
47
+ ['py', ['-3', '--version']],
48
+ ];
49
+ for (const [bin, args] of probes) {
50
+ try {
51
+ execFileSync(bin, args, {
52
+ encoding: 'utf-8',
53
+ timeout: 1500,
54
+ stdio: ['ignore', 'pipe', 'ignore'],
55
+ });
56
+ return true;
57
+ }
58
+ catch {
59
+ /* try next */
60
+ }
61
+ }
62
+ return false;
63
+ }
64
+ export function detectIcoaInstalls() {
65
+ if (isNativeWindowsCmd())
66
+ return []; // `which -a` not on Windows native
67
+ let paths = [];
68
+ try {
69
+ const out = execFileSync('which', ['-a', 'icoa'], {
70
+ encoding: 'utf-8',
71
+ timeout: 1500,
72
+ stdio: ['ignore', 'pipe', 'ignore'],
73
+ }).trim();
74
+ paths = out.split('\n').filter(Boolean);
75
+ }
76
+ catch {
77
+ return [];
78
+ }
79
+ return paths.map((p) => {
80
+ const out = { path: p };
81
+ try {
82
+ let target = p;
83
+ if (lstatSync(p).isSymbolicLink())
84
+ target = realpathSync(p);
85
+ // target is .../node_modules/icoa-cli/dist/index.js
86
+ // pkgDir is .../node_modules/icoa-cli
87
+ // package.json is .../node_modules/icoa-cli/package.json
88
+ const distDir = dirname(target); // .../icoa-cli/dist
89
+ const pkgDir = dirname(distDir); // .../icoa-cli
90
+ out.pkgDir = pkgDir;
91
+ const pkg = JSON.parse(readFileSync(`${pkgDir}/package.json`, 'utf-8'));
92
+ out.version = pkg.version;
93
+ }
94
+ catch {
95
+ /* fall through with version undefined */
96
+ }
97
+ return out;
98
+ });
99
+ }
@@ -1 +1,112 @@
1
- import chalk from"chalk";export function detectCaps(){const t=process.stdout.columns||80,o=process.env.LANG||process.env.LC_ALL||"",n=/utf-?8/i.test(o)||""===o||"C.UTF-8"===o;return"ascii"!==(process.env.ICOA_RENDER||"").toLowerCase()&&"C"!==o&&"POSIX"!==o&&n?{tier:"T1",cols:t,utf8:n}:{tier:"T0",cols:t,utf8:!1}}function t(t,o){return"T0"===o.tier&&t.art_t0||t.width_cols&&t.width_cols>o.cols-6&&t.art_t0?t.art_t0:t.art??t.art_t0??""}function o(t){try{const o=t.split(".").reduce((t,o)=>t[o],chalk);return"function"==typeof o?o:chalk.gray}catch{return chalk.gray}}function n(t,n){let c="";for(const e of t){const t=" "===e?void 0:n[e];c+=t?o(t)(e):chalk.gray(e)}return c}export function renderCharts(o,c,e=detectCaps(),r={}){if(!o||0===o.length)return;if(!c)return;const s=!1!==r.frameLeft,i=s?chalk.cyan(" │ "):" ",l=s?chalk.cyan(" │"):"";let f=0;for(const r of o){const o=c[r];if(!o)continue;if("code"===o.type){const t=o.code??"";if(""===t.trim())continue;console.log(l);for(const o of t.split("\n"))console.log(i+chalk.cyan(o));f++;continue}const s=t(o,e);if(""===s)continue;const a=chalk.level>0&&o.color_map&&Object.keys(o.color_map).length>0;console.log(l);for(const t of s.split("\n"))console.log(i+(a?n(t,o.color_map):chalk.gray(t)));f++}f>0&&console.log(l)}
1
+ /**
2
+ * Chart rendering helper — pulls ASCII chart art out of a Curriculum.charts
3
+ * lookup and prints it between a card's title and body.
4
+ *
5
+ * Design contract: [[reference-chart-lang-neutral]]
6
+ * - Fixed slot order: title → charts → body
7
+ * - Charts are English-only and never translated; 22 languages share one art.
8
+ * - Card stores `chart_ids: string[]`; art lives in `curriculum.charts[id]`.
9
+ *
10
+ * Capability picker (pilot scope = T0 / T1):
11
+ * - T0 pure-ASCII fallback — picked when LANG=C / non-UTF-8 / chart wider than
12
+ * available columns / explicit user opt-out
13
+ * - T1 Unicode-safe default — used on the 4 baseline terminals (macOS Terminal,
14
+ * gnome-terminal, WSL2 Windows Terminal, Chromebook
15
+ * Crostini); see CLAUDE.md cross-platform baseline.
16
+ *
17
+ * Output respects the learn-render frame style: each line gets a `│ ` left
18
+ * border so charts visually sit inside the same three-sided frame as body text.
19
+ */
20
+ import chalk from 'chalk';
21
+ export function detectCaps() {
22
+ const cols = process.stdout.columns || 80;
23
+ const lang = process.env.LANG || process.env.LC_ALL || '';
24
+ const utf8 = /utf-?8/i.test(lang) || lang === '' || lang === 'C.UTF-8';
25
+ // ICOA_RENDER=ascii forces T0 (also documented in [[reference-rendering-tiers]]).
26
+ const override = (process.env.ICOA_RENDER || '').toLowerCase();
27
+ if (override === 'ascii' || lang === 'C' || lang === 'POSIX' || !utf8) {
28
+ return { tier: 'T0', cols, utf8: false };
29
+ }
30
+ return { tier: 'T1', cols, utf8 };
31
+ }
32
+ function pickArt(chart, caps) {
33
+ // Width fit: if T1 art is wider than the terminal, fall back to T0 — same
34
+ // idea as the spike-rich-display fallback.
35
+ if (caps.tier === 'T0' && chart.art_t0)
36
+ return chart.art_t0;
37
+ if (chart.width_cols && chart.width_cols > caps.cols - 6 && chart.art_t0) {
38
+ return chart.art_t0;
39
+ }
40
+ return chart.art ?? chart.art_t0 ?? '';
41
+ }
42
+ // Resolve a (possibly dotted, e.g. "bold.green") chalk colour spec into a
43
+ // styling function. Falls back to gray for unknown specs.
44
+ function chalkChain(spec) {
45
+ try {
46
+ const fn = spec.split('.').reduce((acc, part) => acc[part], chalk);
47
+ return typeof fn === 'function' ? fn : chalk.gray;
48
+ }
49
+ catch {
50
+ return chalk.gray;
51
+ }
52
+ }
53
+ // Apply a char→colour map to one art line. Unmapped chars (and spaces) stay
54
+ // gray — matches the pre-color_map look. ANSI escapes are zero-width so this
55
+ // never disturbs alignment. Caller gates on chalk.level > 0.
56
+ function colorizeArtLine(line, colorMap) {
57
+ let out = '';
58
+ for (const ch of line) {
59
+ const spec = ch === ' ' ? undefined : colorMap[ch];
60
+ out += spec ? chalkChain(spec)(ch) : chalk.gray(ch);
61
+ }
62
+ return out;
63
+ }
64
+ /**
65
+ * Render chart art for a list of chart_ids. Each chart prints on its own
66
+ * "block" with a blank separator line above. Lines are left-padded with the
67
+ * three-sided-frame left bar to match learn-render body style.
68
+ *
69
+ * Pass `frameLeft = false` for standalone (non-card-frame) contexts.
70
+ */
71
+ export function renderCharts(chartIds, charts, caps = detectCaps(), opts = {}) {
72
+ if (!chartIds || chartIds.length === 0)
73
+ return;
74
+ if (!charts)
75
+ return;
76
+ const frameLeft = opts.frameLeft !== false;
77
+ const leftBar = frameLeft ? chalk.cyan(' │ ') : ' ';
78
+ const leftBarEmpty = frameLeft ? chalk.cyan(' │') : '';
79
+ let rendered = 0;
80
+ for (const id of chartIds) {
81
+ const chart = charts[id];
82
+ if (!chart)
83
+ continue; // silent miss — server denormalisation may have lagged
84
+ // Code-as-insight (6th genre): monospace CODE-LOCK render — cyan, no wrap,
85
+ // no width-fit (≤~20 ASCII lines, English comments → renders everywhere).
86
+ if (chart.type === 'code') {
87
+ const code = chart.code ?? '';
88
+ if (code.trim() === '')
89
+ continue;
90
+ console.log(leftBarEmpty);
91
+ for (const line of code.split('\n')) {
92
+ console.log(leftBar + chalk.cyan(line));
93
+ }
94
+ rendered++;
95
+ continue;
96
+ }
97
+ // Chart genres: pick T0/T1 art, then optionally apply color_map (render
98
+ // layer only, after selection — escapes stay zero-width). Mono terminals
99
+ // (chalk.level === 0, e.g. NO_COLOR / piped) skip colour automatically.
100
+ const art = pickArt(chart, caps);
101
+ if (art === '')
102
+ continue;
103
+ const useColor = chalk.level > 0 && chart.color_map && Object.keys(chart.color_map).length > 0;
104
+ console.log(leftBarEmpty); // blank line above each chart
105
+ for (const line of art.split('\n')) {
106
+ console.log(leftBar + (useColor ? colorizeArtLine(line, chart.color_map) : chalk.gray(line)));
107
+ }
108
+ rendered++;
109
+ }
110
+ if (rendered > 0)
111
+ console.log(leftBarEmpty); // single trailing blank
112
+ }
@@ -1 +1,67 @@
1
- import{Interface as e,createInterface as o}from"node:readline";import{getMainRl as n}from"./main-rl.js";export function makeReplAsker(){const r=n(),s=null!==r,t=s?r.listeners("line").slice():[];s&&r.removeAllListeners("line");const l=s?r:o({input:process.stdin,output:process.stdout}),i=s?l.prompt:null;return s&&(l.prompt=e.prototype.prompt),{ask:e=>new Promise(o=>{const n=e=>{l.removeListener("close",r),o(e)},r=()=>{l.removeListener("line",n),o(null)};l.once("line",n),l.once("close",r),l.setPrompt(e),l.prompt()}),close:()=>{if(s){i&&(r.prompt=i),r.removeAllListeners("line");for(const e of t)r.on("line",e)}else l.close()}}}
1
+ /**
2
+ * BUG-008-safe `question()`-style asker for standalone Commander sub-flows that
3
+ * are ALSO dispatched bare from inside the main REPL (e.g. `arena`, `eai`).
4
+ *
5
+ * The problem: the main REPL holds a `terminal: true` readline.Interface on
6
+ * process.stdin. A sub-flow that creates its OWN `createInterface` makes a SECOND
7
+ * interface read stdin — both echo every keystroke ("a" → "aa") and both fire
8
+ * 'line'. `pause()` does NOT help (the new rl's constructor calls resume()).
9
+ *
10
+ * The fix (listener-swap, per CLAUDE.md BUG-008): when a main REPL rl exists,
11
+ * REUSE it — detach its 'line' listeners, drive it ourselves question-by-question
12
+ * with one-shot listeners, then restore the saved listeners on close. No second
13
+ * readline is ever created inside the REPL. When there is no main rl (the flow
14
+ * was launched as `icoa <cmd>` from the system shell), create a standalone one.
15
+ *
16
+ * The asker resolves to `null` when the stream closes (EOF / Ctrl-D) so callers
17
+ * can exit cleanly instead of crashing on "readline was closed".
18
+ */
19
+ import { Interface as ReadlineInterface, createInterface } from 'node:readline';
20
+ import { getMainRl } from './main-rl.js';
21
+ export function makeReplAsker() {
22
+ const mainRl = getMainRl();
23
+ const usingMainRl = mainRl !== null;
24
+ const savedMainListeners = usingMainRl
25
+ ? mainRl.listeners('line').slice()
26
+ : [];
27
+ if (usingMainRl)
28
+ mainRl.removeAllListeners('line');
29
+ const rl = usingMainRl
30
+ ? mainRl
31
+ : createInterface({ input: process.stdin, output: process.stdout });
32
+ // The main REPL monkey-patches rl.prompt() (src/repl.ts) to force
33
+ // computePrompt() for any mode it doesn't recognize — which would clobber our
34
+ // sub-flow prompt, showing e.g. `icoa 2026>` instead of `icoa ctf4eai>`. Swap
35
+ // in the NATIVE readline prompt for the session so our setPrompt() sticks, and
36
+ // restore the patched one on close() so the REPL prompt behaves again after.
37
+ const savedPrompt = usingMainRl ? rl.prompt : null;
38
+ if (usingMainRl)
39
+ rl.prompt = ReadlineInterface.prototype.prompt;
40
+ const ask = (q) => new Promise((resolve) => {
41
+ const onLine = (line) => {
42
+ rl.removeListener('close', onClose);
43
+ resolve(line);
44
+ };
45
+ const onClose = () => {
46
+ rl.removeListener('line', onLine);
47
+ resolve(null);
48
+ };
49
+ rl.once('line', onLine);
50
+ rl.once('close', onClose);
51
+ rl.setPrompt(q);
52
+ rl.prompt();
53
+ });
54
+ const close = () => {
55
+ if (usingMainRl) {
56
+ if (savedPrompt)
57
+ mainRl.prompt = savedPrompt;
58
+ mainRl.removeAllListeners('line');
59
+ for (const l of savedMainListeners)
60
+ mainRl.on('line', l);
61
+ }
62
+ else {
63
+ rl.close();
64
+ }
65
+ };
66
+ return { ask, close };
67
+ }
@@ -1 +1,227 @@
1
- import{chmodSync as e,existsSync as n,mkdirSync as t,readFileSync as s,writeFileSync as r}from"node:fs";import{homedir as a}from"node:os";import{join as i}from"node:path";const o=i(a(),".icoa","bin");export const RUNNER_PY=i(o,"icoa-judge.py");const p=i(o,"icoa-judge");function l(e){try{return(s(e,"utf-8").split("\n",2)[1]||"").includes("ICOA-RUNNER v1")}catch{return!1}}export function placeSampleRunner(){try{if(n(RUNNER_PY)&&l(RUNNER_PY))return{ok:!0,path:RUNNER_PY,updated:!1};if(t(o,{recursive:!0}),r(RUNNER_PY,'#!/usr/bin/env python3\n# ICOA-RUNNER v1\n"""icoa-judge — tiny local sample-test runner for the ioipy Python informatics track.\n\nRuns YOUR Python solution against sample input/expected-output pairs, compares stdout,\nand reports PASS / WRONG / TLE / RUNTIME-ERROR plus the wall-clock time of each run.\n\nThis is a self-check on the SAMPLE cases only a rough "is it correct and fast enough?"\nfeel. It is NOT the real judge (no hidden tests, no authoritative time/memory limit).\n\nUSAGE\n icoa-judge SOLUTION.py [SAMPLES]\n SAMPLES can be:\n - a directory holding 1.in/1.out, 2.in/2.out, ... (also accepts .ans/.expected/.exp)\n - a single case.in (its expected output is the sibling case.out)\n - two files: case.in case.out\n - omitted: auto-search ./samples, ./tests, a samples/ next to SOLUTION.py, or *.in here\n\nOPTIONS\n --tl SECONDS per-case soft time limit (default 5.0 — Python-friendly; a TLE here is a\n hint, not a verdict; the real limit is the contest\'s)\n --py CMD interpreter for your solution (default: python3; try --py pypy3 for speed)\n --raw exact byte compare (default: whitespace-normalized — trailing spaces and\n trailing blank lines are ignored, the usual contest checker behaviour)\n"""\nimport argparse, glob, os, subprocess, sys, time\n\nOUT_EXTS = (".out", ".ans", ".expected", ".exp")\nC = sys.stdout.isatty()\ndef col(s, c): return f"\\033[{c}m{s}\\033[0m" if C else s\ndef green(s): return col(s, "32")\ndef red(s): return col(s, "31")\ndef yellow(s): return col(s, "33")\ndef gray(s): return col(s, "90")\n\ndef find_expected(in_path):\n stem = in_path[:-3] if in_path.endswith(".in") else os.path.splitext(in_path)[0]\n for ext in OUT_EXTS:\n if os.path.exists(stem + ext):\n return stem + ext\n return None\n\ndef collect_cases(args):\n sols_dir = os.path.dirname(os.path.abspath(args.solution)) or "."\n # explicit: two files (in + out)\n if args.samples and args.expected:\n return [(args.samples, args.expected)]\n # explicit: a single .in file\n if args.samples and os.path.isfile(args.samples) and args.samples.endswith(".in"):\n exp = find_expected(args.samples)\n return [(args.samples, exp)] if exp else []\n # explicit: a directory\n if args.samples and os.path.isdir(args.samples):\n search = [args.samples]\n else:\n search = ["samples", "tests", os.path.join(sols_dir, "samples"), "."]\n for d in search:\n if not os.path.isdir(d):\n continue\n ins = sorted(glob.glob(os.path.join(d, "*.in")))\n cases = [(i, find_expected(i)) for i in ins]\n cases = [(i, o) for (i, o) in cases if o]\n if cases:\n return cases\n return []\n\ndef normalize(text, raw):\n if raw:\n return text\n lines = [ln.rstrip() for ln in text.replace("\\r\\n", "\\n").split("\\n")]\n while lines and lines[-1] == "":\n lines.pop()\n return "\\n".join(lines)\n\ndef first_diff(exp, got):\n e, g = exp.split("\\n"), got.split("\\n")\n for i in range(max(len(e), len(g))):\n ev = e[i] if i < len(e) else "<no line>"\n gv = g[i] if i < len(g) else "<no line>"\n if ev != gv:\n return i + 1, ev, gv\n return None\n\ndef run_case(py, solution, in_path, tl):\n with open(in_path, "rb") as f:\n data = f.read()\n t0 = time.perf_counter()\n try:\n p = subprocess.run([py, solution], input=data, stdout=subprocess.PIPE,\n stderr=subprocess.PIPE, timeout=tl)\n except subprocess.TimeoutExpired:\n return ("TLE", time.perf_counter() - t0, "", "")\n except FileNotFoundError:\n sys.exit(red(f"interpreter not found: {py} (install it, or pass --py python3)"))\n dt = time.perf_counter() - t0\n if p.returncode != 0:\n err = p.stderr.decode("utf-8", "replace").strip().splitlines()\n return ("RE", dt, p.stdout.decode("utf-8", "replace"), (err[-1] if err else f"exit {p.returncode}"))\n return ("OK", dt, p.stdout.decode("utf-8", "replace"), "")\n\ndef main():\n ap = argparse.ArgumentParser(prog="icoa-judge", add_help=True)\n ap.add_argument("solution")\n ap.add_argument("samples", nargs="?")\n ap.add_argument("expected", nargs="?")\n ap.add_argument("--tl", type=float, default=5.0)\n ap.add_argument("--py", default="python3")\n ap.add_argument("--raw", action="store_true")\n args = ap.parse_args()\n\n if not os.path.isfile(args.solution):\n sys.exit(red(f"solution file not found: {args.solution}"))\n cases = collect_cases(args)\n if not cases:\n sys.exit(yellow("no sample cases found. Put 1.in/1.out next to your solution (or in "\n "./samples), or pass a directory / a case.in case.out pair."))\n\n print(gray(f"icoa-judge — {args.py} · {len(cases)} sample case(s) · TL {args.tl:g}s "\n f"(sample self-check, not the real judge)"))\n passed = 0\n slow = False\n for idx, (in_path, exp_path) in enumerate(cases, 1):\n name = os.path.basename(in_path)\n status, dt, got, extra = run_case(args.py, args.solution, in_path, args.tl)\n ms = f"{dt * 1000:7.1f} ms"\n if status == "TLE":\n print(f" {red(\'TLE \')} {name:<16} {gray(\'> \' + format(args.tl, \'g\') + \'s\')}")\n slow = True\n continue\n if status == "RE":\n print(f" {red(\'ERR \')} {name:<16} {gray(ms)} {red(extra)}")\n continue\n exp = normalize(open(exp_path, encoding=\'utf-8\', errors=\'replace\').read(), args.raw)\n out = normalize(got, args.raw)\n if out == exp:\n print(f" {green(\'PASS\')} {name:<16} {gray(ms)}")\n passed += 1\n if dt > 0.5 * args.tl:\n slow = True\n else:\n d = first_diff(exp, out)\n where = f"line {d[0]}" if d else "output"\n print(f" {red(\'WRONG\')} {name:<16} {gray(ms)} {gray(where)}")\n if d:\n print(gray(f" expected: {d[1][:70]}"))\n print(gray(f" got: {d[2][:70]}"))\n\n print()\n tag = green("ALL PASS") if passed == len(cases) else yellow(f"{passed}/{len(cases)} passed")\n print(f" {tag}")\n if slow and args.py != "pypy3":\n print(gray(" (some runs were slow — if the real judge TLEs, try --py pypy3, faster I/O, "\n "or an iterative form. Sample timing is only a rough guide.)"))\n sys.exit(0 if passed == len(cases) else 1)\n\nif __name__ == "__main__":\n main()\n',"utf-8"),"win32"!==process.platform){try{e(RUNNER_PY,493)}catch{}r(p,`#!/bin/sh\nexec python3 "${RUNNER_PY}" "$@"\n`,"utf-8");try{e(p,493)}catch{}}return{ok:!0,path:RUNNER_PY,updated:!0}}catch(e){return{ok:!1,path:RUNNER_PY,updated:!1,why:e instanceof Error?e.message:String(e)}}}export function sampleRunnerInstalled(){return n(RUNNER_PY)&&l(RUNNER_PY)}
1
+ // Local sample-test runner for the ioipy (Python informatics) track — Hard Rule §4.
2
+ //
3
+ // A tiny STDLIB-ONLY Python script shipped to the student's machine by `env setup`.
4
+ // It runs their Python solution against sample input/output pairs, diffs stdout against
5
+ // the expected output (whitespace-normalized), and prints a rough wall-clock time so they
6
+ // get a *feel* for whether a solution is fast enough. It is deliberately NOT the
7
+ // server-authoritative online judge (that hidden-test layer is the deferred §3 project) —
8
+ // it only checks the SAMPLE cases the student already has, which is exactly what the learn
9
+ // cards (P2+) ask them to do ("implement in Python and run it on the provided samples").
10
+ //
11
+ // The script content travels as a string in the compiled JS, so there is no asset to
12
+ // bundle / resolve at runtime. `placeSampleRunner()` writes it to ~/.icoa/bin/.
13
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
14
+ import { homedir } from 'node:os';
15
+ import { join } from 'node:path';
16
+ // Bump when the embedded script below changes, so an existing stale copy is rewritten.
17
+ const SAMPLE_RUNNER_VERSION = '1';
18
+ const RUNNER_DIR = join(homedir(), '.icoa', 'bin');
19
+ export const RUNNER_PY = join(RUNNER_DIR, 'icoa-judge.py');
20
+ const RUNNER_SHIM = join(RUNNER_DIR, 'icoa-judge');
21
+ // The shipped runner. Pure Python 3 stdlib (subprocess, argparse, time, glob, sys, os).
22
+ // `# ICOA-RUNNER vN` on line 2 lets us detect/refresh a stale copy.
23
+ const JUDGE_SCRIPT = `#!/usr/bin/env python3
24
+ # ICOA-RUNNER v${SAMPLE_RUNNER_VERSION}
25
+ """icoa-judge — tiny local sample-test runner for the ioipy Python informatics track.
26
+
27
+ Runs YOUR Python solution against sample input/expected-output pairs, compares stdout,
28
+ and reports PASS / WRONG / TLE / RUNTIME-ERROR plus the wall-clock time of each run.
29
+
30
+ This is a self-check on the SAMPLE cases only — a rough "is it correct and fast enough?"
31
+ feel. It is NOT the real judge (no hidden tests, no authoritative time/memory limit).
32
+
33
+ USAGE
34
+ icoa-judge SOLUTION.py [SAMPLES]
35
+ SAMPLES can be:
36
+ - a directory holding 1.in/1.out, 2.in/2.out, ... (also accepts .ans/.expected/.exp)
37
+ - a single case.in (its expected output is the sibling case.out)
38
+ - two files: case.in case.out
39
+ - omitted: auto-search ./samples, ./tests, a samples/ next to SOLUTION.py, or *.in here
40
+
41
+ OPTIONS
42
+ --tl SECONDS per-case soft time limit (default 5.0 — Python-friendly; a TLE here is a
43
+ hint, not a verdict; the real limit is the contest's)
44
+ --py CMD interpreter for your solution (default: python3; try --py pypy3 for speed)
45
+ --raw exact byte compare (default: whitespace-normalized — trailing spaces and
46
+ trailing blank lines are ignored, the usual contest checker behaviour)
47
+ """
48
+ import argparse, glob, os, subprocess, sys, time
49
+
50
+ OUT_EXTS = (".out", ".ans", ".expected", ".exp")
51
+ C = sys.stdout.isatty()
52
+ def col(s, c): return f"\\033[{c}m{s}\\033[0m" if C else s
53
+ def green(s): return col(s, "32")
54
+ def red(s): return col(s, "31")
55
+ def yellow(s): return col(s, "33")
56
+ def gray(s): return col(s, "90")
57
+
58
+ def find_expected(in_path):
59
+ stem = in_path[:-3] if in_path.endswith(".in") else os.path.splitext(in_path)[0]
60
+ for ext in OUT_EXTS:
61
+ if os.path.exists(stem + ext):
62
+ return stem + ext
63
+ return None
64
+
65
+ def collect_cases(args):
66
+ sols_dir = os.path.dirname(os.path.abspath(args.solution)) or "."
67
+ # explicit: two files (in + out)
68
+ if args.samples and args.expected:
69
+ return [(args.samples, args.expected)]
70
+ # explicit: a single .in file
71
+ if args.samples and os.path.isfile(args.samples) and args.samples.endswith(".in"):
72
+ exp = find_expected(args.samples)
73
+ return [(args.samples, exp)] if exp else []
74
+ # explicit: a directory
75
+ if args.samples and os.path.isdir(args.samples):
76
+ search = [args.samples]
77
+ else:
78
+ search = ["samples", "tests", os.path.join(sols_dir, "samples"), "."]
79
+ for d in search:
80
+ if not os.path.isdir(d):
81
+ continue
82
+ ins = sorted(glob.glob(os.path.join(d, "*.in")))
83
+ cases = [(i, find_expected(i)) for i in ins]
84
+ cases = [(i, o) for (i, o) in cases if o]
85
+ if cases:
86
+ return cases
87
+ return []
88
+
89
+ def normalize(text, raw):
90
+ if raw:
91
+ return text
92
+ lines = [ln.rstrip() for ln in text.replace("\\r\\n", "\\n").split("\\n")]
93
+ while lines and lines[-1] == "":
94
+ lines.pop()
95
+ return "\\n".join(lines)
96
+
97
+ def first_diff(exp, got):
98
+ e, g = exp.split("\\n"), got.split("\\n")
99
+ for i in range(max(len(e), len(g))):
100
+ ev = e[i] if i < len(e) else "<no line>"
101
+ gv = g[i] if i < len(g) else "<no line>"
102
+ if ev != gv:
103
+ return i + 1, ev, gv
104
+ return None
105
+
106
+ def run_case(py, solution, in_path, tl):
107
+ with open(in_path, "rb") as f:
108
+ data = f.read()
109
+ t0 = time.perf_counter()
110
+ try:
111
+ p = subprocess.run([py, solution], input=data, stdout=subprocess.PIPE,
112
+ stderr=subprocess.PIPE, timeout=tl)
113
+ except subprocess.TimeoutExpired:
114
+ return ("TLE", time.perf_counter() - t0, "", "")
115
+ except FileNotFoundError:
116
+ sys.exit(red(f"interpreter not found: {py} (install it, or pass --py python3)"))
117
+ dt = time.perf_counter() - t0
118
+ if p.returncode != 0:
119
+ err = p.stderr.decode("utf-8", "replace").strip().splitlines()
120
+ return ("RE", dt, p.stdout.decode("utf-8", "replace"), (err[-1] if err else f"exit {p.returncode}"))
121
+ return ("OK", dt, p.stdout.decode("utf-8", "replace"), "")
122
+
123
+ def main():
124
+ ap = argparse.ArgumentParser(prog="icoa-judge", add_help=True)
125
+ ap.add_argument("solution")
126
+ ap.add_argument("samples", nargs="?")
127
+ ap.add_argument("expected", nargs="?")
128
+ ap.add_argument("--tl", type=float, default=5.0)
129
+ ap.add_argument("--py", default="python3")
130
+ ap.add_argument("--raw", action="store_true")
131
+ args = ap.parse_args()
132
+
133
+ if not os.path.isfile(args.solution):
134
+ sys.exit(red(f"solution file not found: {args.solution}"))
135
+ cases = collect_cases(args)
136
+ if not cases:
137
+ sys.exit(yellow("no sample cases found. Put 1.in/1.out next to your solution (or in "
138
+ "./samples), or pass a directory / a case.in case.out pair."))
139
+
140
+ print(gray(f"icoa-judge — {args.py} · {len(cases)} sample case(s) · TL {args.tl:g}s "
141
+ f"(sample self-check, not the real judge)"))
142
+ passed = 0
143
+ slow = False
144
+ for idx, (in_path, exp_path) in enumerate(cases, 1):
145
+ name = os.path.basename(in_path)
146
+ status, dt, got, extra = run_case(args.py, args.solution, in_path, args.tl)
147
+ ms = f"{dt * 1000:7.1f} ms"
148
+ if status == "TLE":
149
+ print(f" {red('TLE ')} {name:<16} {gray('> ' + format(args.tl, 'g') + 's')}")
150
+ slow = True
151
+ continue
152
+ if status == "RE":
153
+ print(f" {red('ERR ')} {name:<16} {gray(ms)} {red(extra)}")
154
+ continue
155
+ exp = normalize(open(exp_path, encoding='utf-8', errors='replace').read(), args.raw)
156
+ out = normalize(got, args.raw)
157
+ if out == exp:
158
+ print(f" {green('PASS')} {name:<16} {gray(ms)}")
159
+ passed += 1
160
+ if dt > 0.5 * args.tl:
161
+ slow = True
162
+ else:
163
+ d = first_diff(exp, out)
164
+ where = f"line {d[0]}" if d else "output"
165
+ print(f" {red('WRONG')} {name:<16} {gray(ms)} {gray(where)}")
166
+ if d:
167
+ print(gray(f" expected: {d[1][:70]}"))
168
+ print(gray(f" got: {d[2][:70]}"))
169
+
170
+ print()
171
+ tag = green("ALL PASS") if passed == len(cases) else yellow(f"{passed}/{len(cases)} passed")
172
+ print(f" {tag}")
173
+ if slow and args.py != "pypy3":
174
+ print(gray(" (some runs were slow — if the real judge TLEs, try --py pypy3, faster I/O, "
175
+ "or an iterative form. Sample timing is only a rough guide.)"))
176
+ sys.exit(0 if passed == len(cases) else 1)
177
+
178
+ if __name__ == "__main__":
179
+ main()
180
+ `;
181
+ function isCurrent(path) {
182
+ try {
183
+ const head = readFileSync(path, 'utf-8').split('\n', 2)[1] || '';
184
+ return head.includes(`ICOA-RUNNER v${SAMPLE_RUNNER_VERSION}`);
185
+ }
186
+ catch {
187
+ return false;
188
+ }
189
+ }
190
+ /**
191
+ * Write the runner to ~/.icoa/bin/icoa-judge.py (+ a `icoa-judge` shell shim on POSIX).
192
+ * Idempotent: rewrites only if missing or stale (older version line). Stdlib-only —
193
+ * needs nothing installed beyond a working python3 the student already has.
194
+ */
195
+ export function placeSampleRunner() {
196
+ try {
197
+ if (existsSync(RUNNER_PY) && isCurrent(RUNNER_PY)) {
198
+ return { ok: true, path: RUNNER_PY, updated: false };
199
+ }
200
+ mkdirSync(RUNNER_DIR, { recursive: true });
201
+ writeFileSync(RUNNER_PY, JUDGE_SCRIPT, 'utf-8');
202
+ if (process.platform !== 'win32') {
203
+ try {
204
+ chmodSync(RUNNER_PY, 0o755);
205
+ }
206
+ catch {
207
+ /* non-fatal */
208
+ }
209
+ // Convenience shim so the student can type `icoa-judge ...` once ~/.icoa/bin is on PATH.
210
+ writeFileSync(RUNNER_SHIM, `#!/bin/sh\nexec python3 "${RUNNER_PY}" "$@"\n`, 'utf-8');
211
+ try {
212
+ chmodSync(RUNNER_SHIM, 0o755);
213
+ }
214
+ catch {
215
+ /* non-fatal */
216
+ }
217
+ }
218
+ return { ok: true, path: RUNNER_PY, updated: true };
219
+ }
220
+ catch (e) {
221
+ return { ok: false, path: RUNNER_PY, updated: false, why: e instanceof Error ? e.message : String(e) };
222
+ }
223
+ }
224
+ /** Is a current copy of the runner present? (for `env` status display) */
225
+ export function sampleRunnerInstalled() {
226
+ return existsSync(RUNNER_PY) && isCurrent(RUNNER_PY);
227
+ }