icoa-cli 2.7.2 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -62,10 +62,20 @@ function printQuestionProgress(current, total, answered) {
62
62
  console.log();
63
63
  console.log(` ${bar} ${chalk.white.bold(`${current}`)}${chalk.gray(`/${total}`)} ${chalk.gray(`(${answered} answered)`)} ${chalk.gray(`${pct}%`)}`);
64
64
  }
65
+ function getHelpState(state) {
66
+ return {
67
+ used: state._helpUsed || 0,
68
+ max: state._helpMax || 5,
69
+ perQ: state._helpPerQ || {},
70
+ eliminated: state._eliminated || {},
71
+ };
72
+ }
65
73
  function printQuestion(q, answer) {
66
74
  const state = getExamState();
67
75
  const total = Number(state?.session.questionCount || 30);
68
76
  const answered = Object.keys(state?.answers || {}).length;
77
+ const help = getHelpState(state);
78
+ const eliminated = help.eliminated[q.number] || [];
69
79
  // Progress bar
70
80
  printQuestionProgress(q.number, total, answered);
71
81
  // Easter egg
@@ -78,20 +88,27 @@ function printQuestion(q, answer) {
78
88
  console.log(` ${q.text}`);
79
89
  console.log();
80
90
  for (const key of ['A', 'B', 'C', 'D']) {
91
+ const isEliminated = eliminated.includes(key);
81
92
  const selected = answer === key;
82
- const prefix = selected ? chalk.green.bold(` ▸ ${key}.`) : chalk.gray(` ${key}.`);
83
- const text = selected ? chalk.green.bold(q.options[key]) : chalk.white(q.options[key]);
84
- console.log(`${prefix} ${text}`);
93
+ if (isEliminated) {
94
+ console.log(chalk.gray.strikethrough(` ${key}. ${q.options[key]}`));
95
+ }
96
+ else if (selected) {
97
+ console.log(chalk.green.bold(` ▸ ${key}. ${q.options[key]}`));
98
+ }
99
+ else {
100
+ console.log(chalk.gray(` ${key}.`) + ' ' + chalk.white(q.options[key]));
101
+ }
85
102
  }
86
103
  console.log();
87
- // Navigation hint
88
- const nav = [];
89
- if (q.number > 1)
90
- nav.push('exam prev');
91
- if (q.number < total)
92
- nav.push('exam next');
93
- nav.push(`exam answer ${q.number} <A-D>`);
94
- console.log(chalk.gray(` ${nav.join(' · ')}`));
104
+ // Navigation + help hint
105
+ const remaining = help.max - help.used;
106
+ const helpHint = remaining > 0
107
+ ? chalk.yellow('help') + chalk.gray(` (${remaining}/${help.max})`)
108
+ : (help.max < 8 ? chalk.gray('help 0/5 — type ') + chalk.yellow('more help') : chalk.gray('help 0/8'));
109
+ console.log(chalk.gray(' Type ') + chalk.white('A') + chalk.gray('/') + chalk.white('B') + chalk.gray('/') + chalk.white('C') + chalk.gray('/') + chalk.white('D') + chalk.gray(' to answer · ') + helpHint +
110
+ (q.number > 1 ? chalk.gray(' · ') + chalk.white('prev') : '') +
111
+ (q.number < total ? chalk.gray(' · ') + chalk.white('next') : ''));
95
112
  }
96
113
  export function registerExamCommand(program) {
97
114
  const exam = program.command('exam').description('National selection exam');
@@ -210,6 +227,8 @@ export function registerExamCommand(program) {
210
227
  printError(`Question ${n} not found (1-${state.questions.length}).`);
211
228
  return;
212
229
  }
230
+ state._lastQ = num;
231
+ saveExamState(state);
213
232
  printQuestion(q, state.answers[num]);
214
233
  }
215
234
  else {
@@ -264,6 +283,140 @@ export function registerExamCommand(program) {
264
283
  const q = state.questions[prev - 1];
265
284
  printQuestion(q, state.answers[q.number]);
266
285
  });
286
+ // ─── exam help ───
287
+ exam
288
+ .command('help')
289
+ .description('Eliminate one wrong option (limited uses)')
290
+ .action(() => {
291
+ logCommand('exam help');
292
+ const state = getExamState();
293
+ if (!state) {
294
+ printError('No exam in progress.');
295
+ return;
296
+ }
297
+ const help = getHelpState(state);
298
+ const currentQ = state._lastQ || 1;
299
+ const qHelps = help.perQ[currentQ] || 0;
300
+ const eliminated = help.eliminated[currentQ] || [];
301
+ const q = state.questions.find((qq) => qq.number === currentQ);
302
+ if (!q)
303
+ return;
304
+ // Find the correct answer for demo
305
+ let correctAnswer = null;
306
+ if (state.session.examId === 'demo-free') {
307
+ import('../lib/demo-exam.js').then(({ DEMO_ANSWERS }) => {
308
+ correctAnswer = DEMO_ANSWERS[currentQ];
309
+ });
310
+ }
311
+ // Check: already used 2 helps on this question
312
+ if (qHelps >= 2) {
313
+ console.log();
314
+ console.log(chalk.yellow(' 🙈 I can\'t help you more on this one!'));
315
+ console.log(chalk.gray(' You already have a 50/50 — trust your instinct!'));
316
+ console.log();
317
+ printQuestion(q, state.answers[q.number]);
318
+ return;
319
+ }
320
+ // Check: only 1 option left (shouldn't happen with max 2 helps, but safety)
321
+ const remaining = ['A', 'B', 'C', 'D'].filter((k) => !eliminated.includes(k));
322
+ if (remaining.length <= 2) {
323
+ console.log();
324
+ console.log(chalk.yellow(' 😄 Only 2 options left — you got this!'));
325
+ console.log();
326
+ printQuestion(q, state.answers[q.number]);
327
+ return;
328
+ }
329
+ // Check budget
330
+ if (help.used >= help.max) {
331
+ if (help.max < 8) {
332
+ console.log();
333
+ console.log(chalk.yellow(` Help used: ${help.used}/${help.max}`));
334
+ console.log(chalk.white(' Need more? Type: ') + chalk.bold.yellow('more help') + chalk.gray(' (+3 bonus uses)'));
335
+ console.log();
336
+ }
337
+ else {
338
+ console.log();
339
+ console.log(chalk.gray(` All help used (${help.used}/${help.max}). You\'re on your own! 💪`));
340
+ console.log();
341
+ }
342
+ return;
343
+ }
344
+ // Eliminate one wrong option (async for demo answers)
345
+ const doEliminate = (correct) => {
346
+ const wrongRemaining = remaining.filter((k) => k !== correct && !eliminated.includes(k));
347
+ if (wrongRemaining.length === 0)
348
+ return;
349
+ // Pick random wrong option to eliminate
350
+ const toRemove = wrongRemaining[Math.floor(Math.random() * wrongRemaining.length)];
351
+ eliminated.push(toRemove);
352
+ // Update state
353
+ if (!help.eliminated[currentQ])
354
+ help.eliminated[currentQ] = [];
355
+ help.eliminated[currentQ] = eliminated;
356
+ help.perQ[currentQ] = qHelps + 1;
357
+ help.used++;
358
+ state._helpUsed = help.used;
359
+ state._helpMax = help.max;
360
+ state._helpPerQ = help.perQ;
361
+ state._eliminated = help.eliminated;
362
+ saveExamState(state);
363
+ console.log();
364
+ console.log(chalk.yellow(` 💡 Option ${toRemove} eliminated!`) + chalk.gray(` (help ${help.used}/${help.max})`));
365
+ printQuestion(q, state.answers[q.number]);
366
+ };
367
+ if (state.session.examId === 'demo-free') {
368
+ import('../lib/demo-exam.js').then(({ DEMO_ANSWERS }) => {
369
+ doEliminate(DEMO_ANSWERS[currentQ]);
370
+ });
371
+ }
372
+ else {
373
+ // For server exams, we don't know the answer — eliminate random non-selected option
374
+ const userAnswer = state.answers[currentQ];
375
+ const candidates = remaining.filter((k) => k !== userAnswer);
376
+ const toRemove = candidates[Math.floor(Math.random() * candidates.length)];
377
+ eliminated.push(toRemove);
378
+ if (!help.eliminated[currentQ])
379
+ help.eliminated[currentQ] = [];
380
+ help.eliminated[currentQ] = eliminated;
381
+ help.perQ[currentQ] = qHelps + 1;
382
+ help.used++;
383
+ state._helpUsed = help.used;
384
+ state._helpMax = help.max;
385
+ state._helpPerQ = help.perQ;
386
+ state._eliminated = help.eliminated;
387
+ saveExamState(state);
388
+ console.log();
389
+ console.log(chalk.yellow(` 💡 Option ${toRemove} eliminated!`) + chalk.gray(` (help ${help.used}/${help.max})`));
390
+ printQuestion(q, state.answers[q.number]);
391
+ }
392
+ });
393
+ // ─── exam more-help ───
394
+ exam
395
+ .command('more-help')
396
+ .description('Unlock 3 bonus help uses')
397
+ .action(() => {
398
+ logCommand('exam more-help');
399
+ const state = getExamState();
400
+ if (!state) {
401
+ printError('No exam in progress.');
402
+ return;
403
+ }
404
+ const help = getHelpState(state);
405
+ if (help.max >= 8) {
406
+ console.log(chalk.gray(' Bonus help already unlocked.'));
407
+ return;
408
+ }
409
+ if (help.used < help.max) {
410
+ console.log(chalk.gray(` You still have ${help.max - help.used} helps remaining. Use them first!`));
411
+ return;
412
+ }
413
+ state._helpMax = 8;
414
+ saveExamState(state);
415
+ console.log();
416
+ console.log(chalk.green(' 🎁 +3 bonus help unlocked! (3/8 remaining)'));
417
+ console.log(chalk.gray(' Type "help" on any question to use.'));
418
+ console.log();
419
+ });
267
420
  // ─── exam answer <n> <choice> ───
268
421
  exam
269
422
  .command('answer <n> <choice>')
@@ -500,17 +653,18 @@ export function registerExamCommand(program) {
500
653
  console.log();
501
654
  printHeader('ICOA Demo Exam — Free Practice');
502
655
  console.log();
503
- console.log(chalk.white(' This is a free practice exam to help you prepare.'));
504
- console.log(chalk.white(' 30 questions · 30 minutes · English'));
505
- console.log(chalk.white(' No account or login required.'));
656
+ console.log(chalk.white(' Free practice exam · No account needed'));
657
+ console.log(chalk.white(' 30 questions · 30 minutes · Single choice'));
506
658
  console.log();
507
659
  console.log(chalk.gray(' ┌─────────────────────────────────────────────────┐'));
508
- console.log(chalk.gray(' │') + chalk.yellow(' For real exams, your proctor will provide: ') + chalk.gray('│'));
509
- console.log(chalk.gray(' │') + chalk.white(' · Server URL ') + chalk.gray('│'));
510
- console.log(chalk.gray(' │') + chalk.white(' · Username & Password ') + chalk.gray('│'));
511
- console.log(chalk.gray(' │') + chalk.white(' · Exam ID ') + chalk.gray('│'));
660
+ console.log(chalk.gray(' │') + chalk.white(' Each question has 4 options (A/B/C/D) ') + chalk.gray('│'));
661
+ console.log(chalk.gray(' │') + chalk.white(' Type a letter to answer the current question ') + chalk.gray('│'));
662
+ console.log(chalk.gray(' │') + chalk.white(' Type ') + chalk.yellow('help') + chalk.white(' to eliminate a wrong option (5 uses) ') + chalk.gray('│'));
663
+ console.log(chalk.gray(' │') + chalk.white(' Type ') + chalk.yellow('next') + chalk.white('/') + chalk.yellow('prev') + chalk.white(' to navigate between questions ') + chalk.gray('│'));
512
664
  console.log(chalk.gray(' └─────────────────────────────────────────────────┘'));
513
665
  console.log();
666
+ console.log(chalk.gray(' For real exams, your proctor will provide credentials.'));
667
+ console.log();
514
668
  const proceed = await confirm({
515
669
  message: chalk.white('Start demo exam now?'),
516
670
  default: true,
package/dist/repl.js CHANGED
@@ -5,6 +5,7 @@ import { isConnected, getConfig, saveConfig } from './lib/config.js';
5
5
  import { isActivated, activateToken, isFreeCommand, isDeviceMatch, recordExit, recordResume, isFirstRunOrUpgrade, markVersionSeen } from './lib/access.js';
6
6
  import { setReplMode } from './lib/ui.js';
7
7
  import { isChatActive, handleChatMessage } from './commands/ai4ctf.js';
8
+ import { getExamState } from './lib/exam-state.js';
8
9
  import { resetTerminalTheme } from './lib/theme.js';
9
10
  import { ensureSandbox, runInSandbox, isDockerAvailable } from './lib/sandbox.js';
10
11
  import { logCommand } from './lib/logger.js';
@@ -236,12 +237,35 @@ export async function startRepl(program, resumeMode) {
236
237
  realExit(0);
237
238
  return;
238
239
  }
239
- // Help
240
+ // Help — during exam, route to exam help; otherwise show REPL help
240
241
  if (input === 'help' || input === '?') {
242
+ if (getExamState()) {
243
+ processing = true;
244
+ try {
245
+ await program.parseAsync(['node', 'icoa', 'exam', 'help']);
246
+ }
247
+ catch { }
248
+ processing = false;
249
+ rl.prompt();
250
+ return;
251
+ }
241
252
  printReplHelp(isActivated(), mode);
242
253
  rl.prompt();
243
254
  return;
244
255
  }
256
+ // "more help" — during exam, unlock bonus helps
257
+ if (input.toLowerCase() === 'more help') {
258
+ if (getExamState()) {
259
+ processing = true;
260
+ try {
261
+ await program.parseAsync(['node', 'icoa', 'exam', 'more-help']);
262
+ }
263
+ catch { }
264
+ processing = false;
265
+ rl.prompt();
266
+ return;
267
+ }
268
+ }
245
269
  // Clear
246
270
  if (input === 'clear' || input === 'cls') {
247
271
  console.clear();
@@ -271,6 +295,37 @@ export async function startRepl(program, resumeMode) {
271
295
  rl.prompt();
272
296
  return;
273
297
  }
298
+ // ─── Quick exam answer shortcuts ───
299
+ // "A" / "B" / "C" / "D" → answer current question
300
+ // "2 C" / "5 A" → answer specific question
301
+ const examState = getExamState();
302
+ if (examState) {
303
+ const upper = input.toUpperCase().trim();
304
+ // Single letter: A, B, C, D → answer current question
305
+ if (/^[ABCD]$/.test(upper)) {
306
+ const currentQ = examState._lastQ || 1;
307
+ processing = true;
308
+ try {
309
+ await program.parseAsync(['node', 'icoa', 'exam', 'answer', String(currentQ), upper]);
310
+ }
311
+ catch { }
312
+ processing = false;
313
+ rl.prompt();
314
+ return;
315
+ }
316
+ // "N X" pattern: e.g. "2 C", "15 A"
317
+ const match = upper.match(/^(\d+)\s+([ABCD])$/);
318
+ if (match) {
319
+ processing = true;
320
+ try {
321
+ await program.parseAsync(['node', 'icoa', 'exam', 'answer', match[1], match[2]]);
322
+ }
323
+ catch { }
324
+ processing = false;
325
+ rl.prompt();
326
+ return;
327
+ }
328
+ }
274
329
  const cmd = input.split(/\s+/)[0].toLowerCase();
275
330
  // ─── Mode-based command filtering ───
276
331
  const selectionCommands = ['join', 'exam', 'demo', 'next', 'prev', 'setup', 'lang', 'ref', 'ctf'];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "icoa-cli",
3
- "version": "2.7.2",
3
+ "version": "2.9.0",
4
4
  "description": "ICOA CLI — The world's first CLI-native CTF competition terminal",
5
5
  "type": "module",
6
6
  "bin": {