icoa-cli 2.19.43 → 2.19.45

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.
@@ -226,12 +226,34 @@ function getHelpState(state) {
226
226
  eliminated: state._eliminated || {},
227
227
  };
228
228
  }
229
+ // Track whether the practical section intro has been shown this session
230
+ let _practicalIntroShown = false;
229
231
  function printQuestion(q, answer) {
230
232
  const state = getExamState();
231
233
  const total = Number(state?.session.questionCount || 30);
232
234
  const answered = Object.keys(state?.answers || {}).length;
233
235
  const help = getHelpState(state);
234
236
  const eliminated = help.eliminated[q.number] || [];
237
+ const isPractical = q.type === 'ai4ctf' || q.type === 'ctf4ai' || (q.options && !q.options.A && !q.options.B);
238
+ // Show practical section intro once when first entering Q31+
239
+ if (isPractical && !_practicalIntroShown && state?.session.examId !== 'demo-free') {
240
+ _practicalIntroShown = true;
241
+ console.log();
242
+ console.log(chalk.cyan(' ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
243
+ console.log(chalk.bold.white(' Practical Section — Python Required'));
244
+ console.log(chalk.cyan(' ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
245
+ console.log();
246
+ console.log(chalk.white(' Three ways to run Python:'));
247
+ console.log();
248
+ console.log(chalk.yellow(' 1.') + chalk.white(' One-liner: ') + chalk.green('!python3 -c "print(1+1)"'));
249
+ console.log(chalk.yellow(' 2.') + chalk.white(' Interactive: ') + chalk.green('!python3') + chalk.gray(' → >>> import struct ...'));
250
+ console.log(chalk.yellow(' 3.') + chalk.white(' Script: ') + chalk.green("!cat << 'EOF' > s.py") + chalk.gray(' → ') + chalk.green('!python3 s.py'));
251
+ console.log();
252
+ console.log(chalk.white(' Need help? Type ') + chalk.bold.cyan('hint') + chalk.white(' to ask AI (25K tokens per section)'));
253
+ console.log(chalk.white(' Submit flag: ') + chalk.green('exam answer <n> ICOA{your_flag}'));
254
+ console.log(chalk.cyan(' ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
255
+ console.log();
256
+ }
235
257
  // Progress bar
236
258
  printQuestionProgress(q.number, total, answered);
237
259
  // Easter egg (position calculated by percentage of total)
@@ -242,22 +264,43 @@ function printQuestion(q, answer) {
242
264
  console.log();
243
265
  if (q.category)
244
266
  console.log(chalk.cyan(` [${q.category}]`));
245
- console.log(chalk.bold.white(` Q${q.number}. `) + chalk.white(q.text));
246
- console.log();
247
- for (const key of ['A', 'B', 'C', 'D']) {
248
- const isEliminated = eliminated.includes(key);
249
- const selected = answer === key;
250
- if (isEliminated) {
251
- console.log(chalk.gray.strikethrough(` ${key}. ${q.options[key]}`) + chalk.red(` (${t('wrong')})`));
267
+ if (q.points && q.points > 2) {
268
+ console.log(chalk.cyan(` [${q.points} points]`));
269
+ }
270
+ if (isPractical) {
271
+ // Practical question display
272
+ const displayText = q.description || q.text;
273
+ console.log(chalk.bold.white(` Q${q.number}. `) + chalk.white(displayText.split('\n')[0]));
274
+ // Show remaining lines with proper indentation
275
+ const lines = displayText.split('\n').slice(1);
276
+ for (const line of lines) {
277
+ console.log(chalk.white(` ${line}`));
252
278
  }
253
- else if (selected) {
254
- console.log(chalk.green.bold(` ▸ ${key}. ${q.options[key]}`));
279
+ console.log();
280
+ if (answer) {
281
+ console.log(chalk.green(` Your answer: ${answer}`));
282
+ console.log();
255
283
  }
256
- else {
257
- console.log(chalk.gray(` ${key}.`) + ' ' + chalk.white(q.options[key]));
284
+ }
285
+ else {
286
+ // MCQ display
287
+ console.log(chalk.bold.white(` Q${q.number}. `) + chalk.white(q.text));
288
+ console.log();
289
+ for (const key of ['A', 'B', 'C', 'D']) {
290
+ const isEliminated = eliminated.includes(key);
291
+ const selected = answer === key;
292
+ if (isEliminated) {
293
+ console.log(chalk.gray.strikethrough(` ${key}. ${q.options[key]}`) + chalk.red(` (${t('wrong')})`));
294
+ }
295
+ else if (selected) {
296
+ console.log(chalk.green.bold(` ▸ ${key}. ${q.options[key]}`));
297
+ }
298
+ else {
299
+ console.log(chalk.gray(` ${key}.`) + ' ' + chalk.white(q.options[key]));
300
+ }
258
301
  }
302
+ console.log();
259
303
  }
260
- console.log();
261
304
  // Q2 tutorial hint
262
305
  const isDemo = state?.session.examId === 'demo-free';
263
306
  const q2Helps = help.perQ[2] || 0;
@@ -265,14 +308,21 @@ function printQuestion(q, answer) {
265
308
  console.log(chalk.yellow.bold(` ${t('qTutorial')}`));
266
309
  console.log();
267
310
  }
268
- // Full menu on every question
311
+ // Full menu
269
312
  const remaining = help.max - help.used;
270
- const helpLabel = remaining > 0
271
- ? chalk.gray(`${t('helpRemove')} `) + chalk.yellow(`(${remaining}/${help.max})`)
272
- : (help.max < 8 ? chalk.gray(`${t('helpUsedUp')}`) : chalk.gray(`${t('helpAllUsed')} (${help.used}/${help.max})`));
273
313
  console.log(chalk.gray(' ─────────────────────────────────────────'));
274
- console.log(chalk.yellow(' A/B/C/D') + chalk.gray(` ${t('answerThis')}`));
275
- console.log(chalk.yellow(' help') + ' ' + helpLabel);
314
+ if (isPractical) {
315
+ console.log(chalk.yellow(' exam answer <n> ICOA{...}') + chalk.gray(' submit flag'));
316
+ console.log(chalk.yellow(' hint') + chalk.gray(' ask AI for help'));
317
+ console.log(chalk.yellow(' !python3') + chalk.gray(' start Python'));
318
+ }
319
+ else {
320
+ const helpLabel = remaining > 0
321
+ ? chalk.gray(`${t('helpRemove')} `) + chalk.yellow(`(${remaining}/${help.max})`)
322
+ : (help.max < 8 ? chalk.gray(`${t('helpUsedUp')}`) : chalk.gray(`${t('helpAllUsed')} (${help.used}/${help.max})`));
323
+ console.log(chalk.yellow(' A/B/C/D') + chalk.gray(` ${t('answerThis')}`));
324
+ console.log(chalk.yellow(' help') + ' ' + helpLabel);
325
+ }
276
326
  console.log(chalk.yellow(' next') + chalk.gray(' / ') + chalk.yellow('prev') + chalk.gray(` ${t('htpNav')}`));
277
327
  console.log(chalk.yellow(` exam q 1..${total}`) + chalk.gray(` ${t('htpJump')}`));
278
328
  console.log(chalk.yellow(' exam review') + chalk.gray(` ${t('htpReview')}`));
@@ -648,9 +698,10 @@ export function registerExamCommand(program) {
648
698
  });
649
699
  // ─── exam answer <n> <choice> ───
650
700
  exam
651
- .command('answer <n> <choice>')
652
- .description('Answer question N with choice A/B/C/D')
653
- .action(async (n, choice) => {
701
+ .command('answer <n> <choice...>')
702
+ .description('Answer question N (A/B/C/D for MCQ, ICOA{flag} for practical)')
703
+ .action(async (n, choiceParts) => {
704
+ const choice = choiceParts.join(' ');
654
705
  logCommand(`exam answer ${n} ${choice}`);
655
706
  const state = getExamState();
656
707
  if (!state) {
@@ -665,10 +716,23 @@ export function registerExamCommand(program) {
665
716
  printError(`Question ${n} not found (1-${state.questions.length}).`);
666
717
  return;
667
718
  }
668
- const c = choice.toUpperCase();
669
- if (!['A', 'B', 'C', 'D'].includes(c)) {
670
- printError('Choice must be A, B, C, or D.');
671
- return;
719
+ // Detect question type: practical (ai4ctf/ctf4ai) or MCQ
720
+ const isPractical = q.type === 'ai4ctf' || q.type === 'ctf4ai' || (q.options && !q.options.A && !q.options.B);
721
+ let c;
722
+ if (isPractical) {
723
+ // Accept flag format: ICOA{...} or any string
724
+ c = choice.trim();
725
+ if (!c) {
726
+ printError('Please provide your flag: exam answer <n> ICOA{your_flag}');
727
+ return;
728
+ }
729
+ }
730
+ else {
731
+ c = choice.toUpperCase();
732
+ if (!['A', 'B', 'C', 'D'].includes(c)) {
733
+ printError('Choice must be A, B, C, or D.');
734
+ return;
735
+ }
672
736
  }
673
737
  // Q2 tutorial: must use help first before answering
674
738
  if (state.session.examId === 'demo-free' && num === 2) {
@@ -1066,7 +1130,8 @@ export function registerExamCommand(program) {
1066
1130
  .description('Enter exam with access token (no login needed)')
1067
1131
  .action(async (code) => {
1068
1132
  logCommand(`exam token ${code}`);
1069
- const existing = getExamState();
1133
+ const { getRealExamState } = await import('../lib/exam-state.js');
1134
+ const existing = getRealExamState();
1070
1135
  if (existing) {
1071
1136
  printWarning(`Exam "${existing.session.examName}" is already in progress.`);
1072
1137
  printInfo('Use "exam review" or "exam submit" first.');
@@ -1191,17 +1256,11 @@ export function registerExamCommand(program) {
1191
1256
  }
1192
1257
  const DEMO_QUESTIONS = pickDemoQuestions(DEMO_PICK_SIZE);
1193
1258
  const DEMO_SESSION = getLocalizedDemoSession();
1194
- const existing = getExamState();
1195
- if (existing) {
1196
- if (existing.session.examId === 'demo-free') {
1197
- // Demo: always restart fresh
1198
- clearExamState();
1199
- }
1200
- else {
1201
- printWarning(`Exam "${existing.session.examName}" is in progress.`);
1202
- printInfo('Submit it first: exam submit');
1203
- return;
1204
- }
1259
+ // Demo uses separate state file — doesn't conflict with real exam
1260
+ const { getDemoState, clearExamState: clearState } = await import('../lib/exam-state.js');
1261
+ const existingDemo = getDemoState();
1262
+ if (existingDemo) {
1263
+ clearState('demo-free');
1205
1264
  }
1206
1265
  console.log();
1207
1266
  printHeader('ICOA Demo Exam — Free Practice');
@@ -1437,6 +1496,42 @@ export function registerExamCommand(program) {
1437
1496
  else {
1438
1497
  printWarning(`${installed.length}/${PACKAGES.length} packages installed. ${failed.length} failed: ${failed.join(', ')}`);
1439
1498
  }
1499
+ // Python usage tutorial for beginners
1500
+ console.log();
1501
+ console.log(chalk.cyan(' ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
1502
+ console.log(chalk.bold.white(' How to use Python in ICOA CLI'));
1503
+ console.log(chalk.cyan(' ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
1504
+ console.log();
1505
+ console.log(chalk.bold.yellow(' Method 1: One-liner') + chalk.gray(' (quick tasks)'));
1506
+ console.log(chalk.white(' Just add ! before the command:'));
1507
+ console.log(chalk.green(' !python3 -c "import base64; print(base64.b64decode(\'SGVsbG8=\'))"'));
1508
+ console.log(chalk.gray(' → b\'Hello\''));
1509
+ console.log();
1510
+ console.log(chalk.bold.yellow(' Method 2: Interactive Python') + chalk.gray(' (explore & experiment)'));
1511
+ console.log(chalk.white(' Start a Python session, type commands one by one:'));
1512
+ console.log(chalk.green(' !python3'));
1513
+ console.log(chalk.gray(' >>> from Crypto.Cipher import AES'));
1514
+ console.log(chalk.gray(' >>> key = bytes.fromhex("49434f41...")'));
1515
+ console.log(chalk.gray(' >>> cipher = AES.new(key, AES.MODE_CBC, iv)'));
1516
+ console.log(chalk.gray(' >>> print(cipher.decrypt(data))'));
1517
+ console.log(chalk.gray(' >>> exit()'));
1518
+ console.log();
1519
+ console.log(chalk.bold.yellow(' Method 3: Script file') + chalk.gray(' (complex solutions)'));
1520
+ console.log(chalk.white(' Write a .py file, then run it:'));
1521
+ console.log(chalk.green(" !cat << 'EOF' > solve.py"));
1522
+ console.log(chalk.gray(' from pwn import xor'));
1523
+ console.log(chalk.gray(' ct = bytes.fromhex("0a2b0e1c...")'));
1524
+ console.log(chalk.gray(' key = xor(ct[:5], b"ICOA{")'));
1525
+ console.log(chalk.gray(' print(xor(ct, key).decode())'));
1526
+ console.log(chalk.green(' EOF'));
1527
+ console.log(chalk.green(' !python3 solve.py'));
1528
+ console.log();
1529
+ console.log(chalk.bold.yellow(' AI Assistant'));
1530
+ console.log(chalk.white(' During the exam, type ') + chalk.bold.cyan('hint') + chalk.white(' to ask AI for help:'));
1531
+ console.log(chalk.gray(' "How do I decrypt AES-CBC in Python?"'));
1532
+ console.log(chalk.gray(' "Show me how to use struct.unpack"'));
1533
+ console.log(chalk.gray(' AI budget: 25K tokens for AI4CTF + 25K for CTF4AI'));
1534
+ console.log(chalk.cyan(' ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
1440
1535
  console.log();
1441
1536
  console.log(chalk.white(' Next step: ') + chalk.bold.cyan('exam <token>'));
1442
1537
  console.log();
@@ -1,7 +1,9 @@
1
1
  import type { ExamState } from '../types/index.js';
2
2
  export declare function getExamState(): ExamState | null;
3
+ export declare function getDemoState(): ExamState | null;
4
+ export declare function getRealExamState(): ExamState | null;
3
5
  export declare function saveExamState(state: ExamState): void;
4
- export declare function clearExamState(): void;
6
+ export declare function clearExamState(examId?: string): void;
5
7
  export declare function isExamActive(): boolean;
6
8
  export declare function getExamDeadline(): Date | null;
7
9
  export declare function addInteraction(interaction: import('../types/index.js').ExamInteraction): void;
@@ -1,10 +1,49 @@
1
1
  import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { getIcoaDir } from './config.js';
4
+ // Demo and real exam use separate state files so they don't block each other
4
5
  function stateFile() {
5
6
  return join(getIcoaDir(), 'exam-state.json');
6
7
  }
8
+ function demoStateFile() {
9
+ return join(getIcoaDir(), 'demo-state.json');
10
+ }
11
+ // Internal: pick the right file based on examId
12
+ function resolveStateFile(examId) {
13
+ return examId === 'demo-free' ? demoStateFile() : stateFile();
14
+ }
7
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();
37
+ if (!existsSync(f))
38
+ return null;
39
+ try {
40
+ return JSON.parse(readFileSync(f, 'utf-8'));
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ }
46
+ export function getRealExamState() {
8
47
  const f = stateFile();
9
48
  if (!existsSync(f))
10
49
  return null;
@@ -16,12 +55,24 @@ export function getExamState() {
16
55
  }
17
56
  }
18
57
  export function saveExamState(state) {
19
- writeFileSync(stateFile(), JSON.stringify(state, null, 2));
58
+ const f = resolveStateFile(state.session.examId);
59
+ writeFileSync(f, JSON.stringify(state, null, 2));
20
60
  }
21
- export function clearExamState() {
22
- const f = stateFile();
23
- if (existsSync(f))
24
- unlinkSync(f);
61
+ export function clearExamState(examId) {
62
+ if (examId) {
63
+ const f = resolveStateFile(examId);
64
+ if (existsSync(f))
65
+ unlinkSync(f);
66
+ }
67
+ else {
68
+ // Clear the currently active exam
69
+ const state = getExamState();
70
+ if (state) {
71
+ const f = resolveStateFile(state.session.examId);
72
+ if (existsSync(f))
73
+ unlinkSync(f);
74
+ }
75
+ }
25
76
  }
26
77
  export function isExamActive() {
27
78
  return getExamState() !== null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "icoa-cli",
3
- "version": "2.19.43",
3
+ "version": "2.19.45",
4
4
  "description": "ICOA CLI — The world's first CLI-native CTF competition terminal",
5
5
  "type": "module",
6
6
  "bin": {