icoa-cli 2.19.66 → 2.19.68

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.
@@ -1898,6 +1898,52 @@ export function registerExamCommand(program) {
1898
1898
  console.log(chalk.gray(' Expected: 1-2 minutes · ~150MB disk'));
1899
1899
  console.log();
1900
1900
  // Step 1: Check Python 3.12+
1901
+ const printPythonInstallGuide = (reason, currentVersion) => {
1902
+ const platform = process.platform;
1903
+ console.log();
1904
+ if (reason === 'missing') {
1905
+ printError('Python 3 not found.');
1906
+ }
1907
+ else {
1908
+ printError(`Python ${currentVersion} found, but 3.12+ required for the exam.`);
1909
+ }
1910
+ console.log();
1911
+ console.log(chalk.bold.white(' How to install Python 3.12:'));
1912
+ console.log();
1913
+ if (platform === 'darwin') {
1914
+ console.log(chalk.yellow(' macOS (Homebrew, recommended):'));
1915
+ console.log(chalk.green(' brew install python@3.12'));
1916
+ console.log();
1917
+ console.log(chalk.gray(' Or download installer:'));
1918
+ console.log(chalk.gray(' https://www.python.org/downloads/macos/'));
1919
+ }
1920
+ else if (platform === 'linux') {
1921
+ console.log(chalk.yellow(' Ubuntu / Debian (deadsnakes PPA):'));
1922
+ console.log(chalk.green(' sudo add-apt-repository ppa:deadsnakes/ppa -y'));
1923
+ console.log(chalk.green(' sudo apt update'));
1924
+ console.log(chalk.green(' sudo apt install -y python3.12 python3.12-venv python3.12-distutils'));
1925
+ console.log(chalk.gray(' sudo update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.12 1'));
1926
+ console.log();
1927
+ console.log(chalk.yellow(' Fedora / RHEL:'));
1928
+ console.log(chalk.green(' sudo dnf install -y python3.12'));
1929
+ console.log();
1930
+ console.log(chalk.yellow(' Arch / Manjaro:'));
1931
+ console.log(chalk.green(' sudo pacman -S python'));
1932
+ }
1933
+ else if (platform === 'win32') {
1934
+ console.log(chalk.yellow(' Windows (winget, recommended):'));
1935
+ console.log(chalk.green(' winget install Python.Python.3.12'));
1936
+ console.log();
1937
+ console.log(chalk.gray(' Or download installer:'));
1938
+ console.log(chalk.gray(' https://www.python.org/downloads/windows/'));
1939
+ }
1940
+ else {
1941
+ console.log(chalk.gray(' https://www.python.org/downloads/'));
1942
+ }
1943
+ console.log();
1944
+ console.log(chalk.white(' After installing, run: ') + chalk.bold.cyan('exam setup'));
1945
+ console.log();
1946
+ };
1901
1947
  const pythonBin = 'python3';
1902
1948
  let pythonVersion = '';
1903
1949
  try {
@@ -1905,15 +1951,13 @@ export function registerExamCommand(program) {
1905
1951
  pythonVersion = raw.replace('Python ', '');
1906
1952
  const parts = pythonVersion.split('.').map(Number);
1907
1953
  if (parts[0] < 3 || (parts[0] === 3 && parts[1] < 12)) {
1908
- printError(`Python ${pythonVersion} found, but 3.12+ required.`);
1909
- printInfo('Install: https://www.python.org/downloads/');
1954
+ printPythonInstallGuide('too_old', pythonVersion);
1910
1955
  return;
1911
1956
  }
1912
1957
  console.log(chalk.green(` ✓ Python ${pythonVersion}`));
1913
1958
  }
1914
1959
  catch {
1915
- printError('Python 3 not found.');
1916
- printInfo('Install: https://www.python.org/downloads/');
1960
+ printPythonInstallGuide('missing');
1917
1961
  return;
1918
1962
  }
1919
1963
  // Step 2: Check pip
@@ -1,7 +1,7 @@
1
1
  import type { ExamState } from '../types/index.js';
2
- export declare function getExamState(): ExamState | null;
3
- export declare function getDemoState(): ExamState | null;
4
2
  export declare function getRealExamState(): ExamState | null;
3
+ export declare function getDemoState(): ExamState | null;
4
+ export declare function getExamState(): ExamState | null;
5
5
  export declare function saveExamState(state: ExamState): void;
6
6
  export declare function clearExamState(examId?: string): void;
7
7
  export declare function isExamActive(): boolean;
@@ -12,28 +12,8 @@ function demoStateFile() {
12
12
  function resolveStateFile(examId) {
13
13
  return examId === 'demo-free' ? demoStateFile() : stateFile();
14
14
  }
15
- export function getExamState() {
16
- // Check both files, return the most recently active one
17
- const files = [stateFile(), demoStateFile()];
18
- let latest = null;
19
- let latestTime = 0;
20
- for (const f of files) {
21
- if (!existsSync(f))
22
- continue;
23
- try {
24
- const state = JSON.parse(readFileSync(f, 'utf-8'));
25
- const t = new Date(state.session.confirmedAt || state.session.startedAt).getTime();
26
- if (t > latestTime) {
27
- latest = state;
28
- latestTime = t;
29
- }
30
- }
31
- catch { }
32
- }
33
- return latest;
34
- }
35
- export function getDemoState() {
36
- const f = demoStateFile();
15
+ export function getRealExamState() {
16
+ const f = stateFile();
37
17
  if (!existsSync(f))
38
18
  return null;
39
19
  try {
@@ -43,8 +23,8 @@ export function getDemoState() {
43
23
  return null;
44
24
  }
45
25
  }
46
- export function getRealExamState() {
47
- const f = stateFile();
26
+ export function getDemoState() {
27
+ const f = demoStateFile();
48
28
  if (!existsSync(f))
49
29
  return null;
50
30
  try {
@@ -54,6 +34,18 @@ export function getRealExamState() {
54
34
  return null;
55
35
  }
56
36
  }
37
+ export function getExamState() {
38
+ // Real exam ALWAYS takes priority over demo when both exist.
39
+ // Reasoning: real exam is token-gated, has a timer, and represents a serious
40
+ // commitment. Demo is casual practice. If a real exam is in progress, all
41
+ // shared commands (exam q N, exam answer, ai4ctf, ctf4ai) must target the
42
+ // real exam — never silently fall back to demo questions, even if demo was
43
+ // run more recently. (Demo→Exam→Finals progression principle, exam.md §0)
44
+ const real = getRealExamState();
45
+ if (real)
46
+ return real;
47
+ return getDemoState();
48
+ }
57
49
  export function saveExamState(state) {
58
50
  const f = resolveStateFile(state.session.examId);
59
51
  writeFileSync(f, JSON.stringify(state, null, 2));
package/dist/repl.js CHANGED
@@ -6,7 +6,7 @@ import { isActivated, activateToken, isFreeCommand, isDeviceMatch, recordExit, r
6
6
  import { setReplMode } from './lib/ui.js';
7
7
  import { isChatActive, handleChatMessage } from './commands/ai4ctf.js';
8
8
  import { isCtf4aiActive, handleCtf4aiMessage } from './commands/ctf4ai-demo.js';
9
- import { getExamState } from './lib/exam-state.js';
9
+ import { getExamState, getRealExamState, getDemoState } from './lib/exam-state.js';
10
10
  import { getDemoStats } from './lib/demo-stats.js';
11
11
  import { isExamSetupComplete } from './lib/exam-setup.js';
12
12
  import { DEMO_PICK_SIZE, DEMO_POOL_SIZE } from './lib/demo-exam.js';
@@ -17,6 +17,16 @@ import { startLogSync, stopLogSync } from './lib/log-sync.js';
17
17
  import { existsSync, mkdirSync } from 'node:fs';
18
18
  import { join } from 'node:path';
19
19
  import { homedir } from 'node:os';
20
+ // Compute the REPL prompt based on current state.
21
+ // Real exam wins over demo (matches getExamState() priority). Chat modes have
22
+ // their own prompts and are handled separately.
23
+ function computePrompt() {
24
+ if (getRealExamState())
25
+ return chalk.cyan('exam> ');
26
+ if (getDemoState())
27
+ return chalk.yellow('demo> ');
28
+ return chalk.green('icoa> ');
29
+ }
20
30
  // Competition workspace — all system commands restricted here
21
31
  const WORKSPACE = join(homedir(), 'icoa-workspace');
22
32
  function ensureWorkspace() {
@@ -334,19 +344,29 @@ export async function startRepl(program, resumeMode) {
334
344
  const rl = createInterface({
335
345
  input: process.stdin,
336
346
  output: process.stdout,
337
- prompt: chalk.green('icoa> '),
347
+ prompt: computePrompt(),
338
348
  terminal: true,
339
349
  });
340
350
  let processing = false;
341
351
  setReplMode(true);
342
352
  startLogSync();
353
+ // Wrap rl.prompt() so it always picks up current state (icoa/demo/exam mode).
354
+ // This keeps the prompt accurate after exam token entry, exam submit, demo finish, etc.
355
+ // Chat modes (ai4ctf/ctf4ai) set their own prompt via setPrompt and bypass this.
356
+ const _origPrompt = rl.prompt.bind(rl);
357
+ rl.prompt = (preserveCursor) => {
358
+ if (!isChatActive() && !isCtf4aiActive()) {
359
+ rl.setPrompt(computePrompt());
360
+ }
361
+ _origPrompt(preserveCursor);
362
+ };
343
363
  rl.prompt();
344
364
  rl.on('line', async (line) => {
345
365
  if (processing)
346
366
  return;
347
367
  const input = line.trim();
348
368
  if (!input) {
349
- rl.setPrompt(isChatActive() ? chalk.magenta('ai4ctf> ') : chalk.green('icoa> '));
369
+ rl.setPrompt(isChatActive() ? chalk.magenta('ai4ctf> ') : computePrompt());
350
370
  rl.prompt();
351
371
  return;
352
372
  }
@@ -356,7 +376,7 @@ export async function startRepl(program, resumeMode) {
356
376
  const result = await handleChatMessage(input);
357
377
  processing = false;
358
378
  if (result === 'exit') {
359
- rl.setPrompt(chalk.green('icoa> '));
379
+ rl.setPrompt(computePrompt());
360
380
  }
361
381
  rl.prompt();
362
382
  return;
@@ -367,7 +387,7 @@ export async function startRepl(program, resumeMode) {
367
387
  const result = await handleCtf4aiMessage(input);
368
388
  processing = false;
369
389
  if (result === 'exit' || result === 'solved') {
370
- rl.setPrompt(chalk.green('icoa> '));
390
+ rl.setPrompt(computePrompt());
371
391
  }
372
392
  rl.prompt();
373
393
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "icoa-cli",
3
- "version": "2.19.66",
3
+ "version": "2.19.68",
4
4
  "description": "ICOA CLI — The world's first CLI-native CTF competition terminal",
5
5
  "type": "module",
6
6
  "bin": {