icoa-cli 2.3.3 → 2.5.3

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.
@@ -12,7 +12,7 @@ export function registerConnectCommand(program) {
12
12
  logCommand(`connect ${id}`);
13
13
  const config = getConfig();
14
14
  if (!isConnected()) {
15
- printError('Not connected. Run: icoa ctf join <url>');
15
+ printError('Not connected. Run: join <url>');
16
16
  return;
17
17
  }
18
18
  const client = new CTFdClient(config.ctfdUrl, config.token);
@@ -1,5 +1,5 @@
1
1
  import chalk from 'chalk';
2
- import { input, select } from '@inquirer/prompts';
2
+ import { input } from '@inquirer/prompts';
3
3
  import { CTFdClient } from '../lib/ctfd-client.js';
4
4
  import { getConfig, saveConfig, getBudget } from '../lib/config.js';
5
5
  import { logCommand, logSubmission } from '../lib/logger.js';
@@ -30,45 +30,38 @@ export function registerCtfCommands(program) {
30
30
  logCommand(`ctf join ${url}`);
31
31
  console.log();
32
32
  printInfo(`Connecting to ${chalk.bold(url)}`);
33
- const authMethod = await select({
34
- message: 'Authentication method:',
35
- choices: [
36
- { name: 'Access Token (from CTFd Settings)', value: 'token' },
37
- { name: 'Username & Password', value: 'login' },
38
- ],
39
- });
33
+ const username = await input({ message: 'Username:' });
34
+ const password = await input({ message: 'Password:' });
40
35
  let token = '';
41
36
  let sessionCookie = '';
42
37
  let csrfNonce = '';
43
- if (authMethod === 'token') {
44
- token = await input({ message: 'Enter your CTFd Access Token:' });
45
- }
46
- else {
47
- const username = await input({ message: 'Username:' });
48
- const password = await input({ message: 'Password:' });
49
- const spinner = createSpinner('Logging in...');
50
- spinner.start();
51
- try {
52
- const client = new CTFdClient(url, '');
53
- const result = await client.loginWithCredentials(username, password);
54
- token = result.token;
55
- sessionCookie = result.session;
56
- csrfNonce = result.csrf;
57
- spinner.succeed('Login successful');
38
+ const spinner = createSpinner('Logging in...');
39
+ spinner.start();
40
+ try {
41
+ const client = new CTFdClient(url, '');
42
+ const result = await client.loginWithCredentials(username, password);
43
+ token = result.token;
44
+ sessionCookie = result.session;
45
+ csrfNonce = result.csrf;
46
+ if (token) {
47
+ spinner.succeed('Login successful API token auto-generated');
58
48
  }
59
- catch (err) {
60
- spinner.fail('Login failed');
61
- printError(err.message);
62
- return;
49
+ else {
50
+ spinner.succeed('Login successful (session mode)');
63
51
  }
64
52
  }
53
+ catch (err) {
54
+ spinner.fail('Login failed');
55
+ printError(err.message);
56
+ return;
57
+ }
65
58
  // Test connection
66
- const spinner = createSpinner('Testing connection...');
67
- spinner.start();
59
+ const spinner2 = createSpinner('Testing connection...');
60
+ spinner2.start();
68
61
  try {
69
62
  const client = new CTFdClient(url, token, sessionCookie, csrfNonce);
70
63
  const user = await client.testConnection();
71
- spinner.succeed('Connected successfully');
64
+ spinner2.succeed('Connected successfully');
72
65
  console.log();
73
66
  printKeyValue('User', user.name);
74
67
  printKeyValue('Score', String(user.score || 0));
@@ -85,6 +78,7 @@ export function registerCtfCommands(program) {
85
78
  userName: user.name,
86
79
  teamId: user.team_id,
87
80
  sessionCookie: sessionCookie,
81
+ country: user.country || '',
88
82
  };
89
83
  if (meta.start) {
90
84
  configUpdate.competitionStartsAt = new Date(meta.start * 1000).toISOString();
@@ -119,10 +113,15 @@ export function registerCtfCommands(program) {
119
113
  });
120
114
  }
121
115
  console.log();
122
- printSuccess('Connection saved. You are ready to compete!');
116
+ printSuccess('Connection saved. You are ready!');
117
+ console.log();
118
+ console.log(chalk.gray(' Next:'));
119
+ console.log(chalk.white(' exam list ') + chalk.gray('View available exams'));
120
+ console.log(chalk.white(' challenges ') + chalk.gray('View CTF challenges'));
121
+ console.log(chalk.white(' status ') + chalk.gray('Check score & budget'));
123
122
  }
124
123
  catch (err) {
125
- spinner.fail('Connection failed');
124
+ spinner2.fail('Connection failed');
126
125
  printError(err.message);
127
126
  }
128
127
  });
@@ -258,7 +257,7 @@ export function registerCtfCommands(program) {
258
257
  // Show files
259
258
  if (challenge.files && challenge.files.length > 0) {
260
259
  console.log();
261
- printInfo(`Files (${challenge.files.length}): use ${chalk.white(`icoa files ${id}`)} to download`);
260
+ printInfo(`Files (${challenge.files.length}): use ${chalk.white(`files ${id}`)} to download`);
262
261
  }
263
262
  // Show hints
264
263
  if (challenge.hints && challenge.hints.length > 0) {
@@ -267,11 +266,11 @@ export function registerCtfCommands(program) {
267
266
  // Show connection info
268
267
  if (challenge.connection_info) {
269
268
  console.log();
270
- printInfo(`Quick connect: ${chalk.white(`icoa connect ${id}`)}`);
269
+ printInfo(`Quick connect: ${chalk.white(`connect ${id}`)}`);
271
270
  }
272
271
  // Show helpful next actions
273
272
  console.log();
274
- console.log(chalk.gray(` Next: icoa hint "how to approach this?" | icoa ctf submit ${id} "icoa{flag}"`));
273
+ console.log(chalk.gray(` Next: hint "how to approach this?" | submit ${id} "icoa{flag}"`));
275
274
  }
276
275
  catch (err) {
277
276
  spinner.fail('Failed to load challenge');
@@ -0,0 +1,2 @@
1
+ import { Command } from 'commander';
2
+ export declare function registerExamCommand(program: Command): void;
@@ -0,0 +1,376 @@
1
+ import chalk from 'chalk';
2
+ import { confirm } from '@inquirer/prompts';
3
+ import { ExamClient } from '../lib/exam-client.js';
4
+ import { getConfig } from '../lib/config.js';
5
+ import { getExamState, saveExamState, clearExamState, getExamDeadline } from '../lib/exam-state.js';
6
+ import { logCommand } from '../lib/logger.js';
7
+ import { printSuccess, printError, printWarning, printInfo, printTable, printHeader, printKeyValue, createSpinner, formatCountdown, } from '../lib/ui.js';
8
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
9
+ function drawProgress(percent, label) {
10
+ const width = 30;
11
+ const filled = Math.round((percent / 100) * width);
12
+ const empty = width - filled;
13
+ const bar = chalk.green('█'.repeat(filled)) + chalk.gray('░'.repeat(empty));
14
+ const pct = String(percent).padStart(3) + '%';
15
+ process.stdout.write(`\r ${bar} ${pct} ${chalk.gray(label)}`);
16
+ }
17
+ function requireExamConnection() {
18
+ const config = getConfig();
19
+ if (!config.ctfdUrl || !config.token) {
20
+ printError('Not connected. Run: join <url>');
21
+ return null;
22
+ }
23
+ return new ExamClient(config.ctfdUrl, config.token);
24
+ }
25
+ function checkTimeRemaining() {
26
+ const deadline = getExamDeadline();
27
+ if (!deadline)
28
+ return true;
29
+ if (new Date() >= deadline) {
30
+ printError('Time expired! Use "exam submit" to submit your answers.');
31
+ return false;
32
+ }
33
+ return true;
34
+ }
35
+ function printTimeRemaining() {
36
+ const deadline = getExamDeadline();
37
+ if (deadline) {
38
+ const now = new Date();
39
+ if (now < deadline) {
40
+ printKeyValue('Time Remaining', chalk.yellow.bold(formatCountdown(deadline)));
41
+ }
42
+ else {
43
+ printKeyValue('Time', chalk.red.bold('EXPIRED'));
44
+ }
45
+ }
46
+ }
47
+ function printQuestion(q, answer) {
48
+ const state = getExamState();
49
+ const total = state?.session.questionCount || '?';
50
+ console.log();
51
+ console.log(chalk.bold.white(` Q${q.number}/${total}`) + (q.category ? chalk.gray(` [${q.category}]`) : ''));
52
+ console.log(` ${q.text}`);
53
+ console.log();
54
+ for (const key of ['A', 'B', 'C', 'D']) {
55
+ const selected = answer === key;
56
+ const prefix = selected ? chalk.green.bold(` ▸ ${key}.`) : chalk.gray(` ${key}.`);
57
+ const text = selected ? chalk.green.bold(q.options[key]) : chalk.white(q.options[key]);
58
+ console.log(`${prefix} ${text}`);
59
+ }
60
+ console.log();
61
+ }
62
+ export function registerExamCommand(program) {
63
+ const exam = program.command('exam').description('National selection exam');
64
+ // ─── exam list ───
65
+ exam
66
+ .command('list')
67
+ .description('List available exams')
68
+ .action(async () => {
69
+ logCommand('exam list');
70
+ const client = requireExamConnection();
71
+ if (!client)
72
+ return;
73
+ const spinner = createSpinner('Loading exams...');
74
+ spinner.start();
75
+ try {
76
+ const exams = await client.getExams();
77
+ spinner.stop();
78
+ if (!exams || exams.length === 0) {
79
+ printInfo('No exams available.');
80
+ return;
81
+ }
82
+ printHeader('Available Exams');
83
+ const rows = exams.map((e) => {
84
+ const statusColor = e.status === 'submitted' ? chalk.green
85
+ : e.status === 'in_progress' ? chalk.yellow
86
+ : chalk.white;
87
+ return [
88
+ chalk.white(e.id),
89
+ e.name,
90
+ chalk.cyan(e.country),
91
+ String(e.questionCount),
92
+ `${e.durationMinutes} min`,
93
+ statusColor(e.status),
94
+ ];
95
+ });
96
+ printTable(['ID', 'Name', 'Country', 'Questions', 'Duration', 'Status'], rows);
97
+ console.log();
98
+ console.log(chalk.gray(' Start an exam: exam start <id>'));
99
+ }
100
+ catch (err) {
101
+ spinner.fail('Failed to load exams');
102
+ printError(err.message);
103
+ }
104
+ });
105
+ // ─── exam start <id> ───
106
+ exam
107
+ .command('start <id>')
108
+ .description('Start an exam (begins timer)')
109
+ .action(async (examId) => {
110
+ logCommand(`exam start ${examId}`);
111
+ // Check if already in an exam
112
+ const existing = getExamState();
113
+ if (existing) {
114
+ printWarning(`Exam "${existing.session.examName}" is already in progress.`);
115
+ printInfo('Use "exam review" to check progress or "exam submit" to submit.');
116
+ return;
117
+ }
118
+ const client = requireExamConnection();
119
+ if (!client)
120
+ return;
121
+ const proceed = await confirm({
122
+ message: 'Start the exam? The timer will begin immediately.',
123
+ default: true,
124
+ });
125
+ if (!proceed)
126
+ return;
127
+ console.log();
128
+ try {
129
+ // Step 1: Connect
130
+ drawProgress(0, 'Connecting to exam server...');
131
+ const { session, questions } = await client.startExam(examId);
132
+ // Step 2-4: Visual loading steps
133
+ drawProgress(30, 'Loading questions...');
134
+ await sleep(200);
135
+ drawProgress(60, 'Preparing exam environment...');
136
+ await sleep(200);
137
+ drawProgress(90, 'Starting timer...');
138
+ await sleep(150);
139
+ saveExamState({ session, questions, answers: {} });
140
+ drawProgress(100, 'Ready!');
141
+ console.log();
142
+ console.log();
143
+ printHeader(session.examName);
144
+ printKeyValue('Questions', String(session.questionCount));
145
+ printKeyValue('Duration', `${session.durationMinutes} minutes`);
146
+ printKeyValue('Country', session.country);
147
+ printTimeRemaining();
148
+ // Show first question
149
+ if (questions.length > 0) {
150
+ printQuestion(questions[0]);
151
+ }
152
+ console.log(chalk.gray(' Commands: exam q <n> | exam answer <n> <A-D> | exam review | exam submit'));
153
+ }
154
+ catch (err) {
155
+ console.log();
156
+ printError(err.message);
157
+ }
158
+ });
159
+ // ─── exam q [n] ───
160
+ exam
161
+ .command('q [n]')
162
+ .description('View question (or all questions)')
163
+ .action((n) => {
164
+ logCommand(`exam q ${n || 'all'}`);
165
+ const state = getExamState();
166
+ if (!state) {
167
+ printError('No exam in progress. Use "exam start <id>" to begin.');
168
+ return;
169
+ }
170
+ printTimeRemaining();
171
+ if (n) {
172
+ const num = parseInt(n);
173
+ const q = state.questions.find((q) => q.number === num);
174
+ if (!q) {
175
+ printError(`Question ${n} not found (1-${state.questions.length}).`);
176
+ return;
177
+ }
178
+ printQuestion(q, state.answers[num]);
179
+ }
180
+ else {
181
+ // Show all questions summary
182
+ printHeader(`${state.session.examName}`);
183
+ const answered = Object.keys(state.answers).length;
184
+ printKeyValue('Progress', `${answered}/${state.session.questionCount} answered`);
185
+ console.log();
186
+ for (const q of state.questions) {
187
+ const ans = state.answers[q.number];
188
+ const status = ans ? chalk.green(`[${ans}]`) : chalk.gray('[ ]');
189
+ const cat = q.category ? chalk.gray(` [${q.category}]`) : '';
190
+ console.log(` ${status} ${chalk.white(`Q${q.number}`)}${cat} ${chalk.gray(q.text.substring(0, 60))}${q.text.length > 60 ? '...' : ''}`);
191
+ }
192
+ console.log();
193
+ }
194
+ });
195
+ // ─── exam answer <n> <choice> ───
196
+ exam
197
+ .command('answer <n> <choice>')
198
+ .description('Answer question N with choice A/B/C/D')
199
+ .action((n, choice) => {
200
+ logCommand(`exam answer ${n} ${choice}`);
201
+ const state = getExamState();
202
+ if (!state) {
203
+ printError('No exam in progress. Use "exam start <id>" to begin.');
204
+ return;
205
+ }
206
+ if (!checkTimeRemaining())
207
+ return;
208
+ const num = parseInt(n);
209
+ const q = state.questions.find((q) => q.number === num);
210
+ if (!q) {
211
+ printError(`Question ${n} not found (1-${state.questions.length}).`);
212
+ return;
213
+ }
214
+ const c = choice.toUpperCase();
215
+ if (!['A', 'B', 'C', 'D'].includes(c)) {
216
+ printError('Choice must be A, B, C, or D.');
217
+ return;
218
+ }
219
+ state.answers[num] = c;
220
+ saveExamState(state);
221
+ const answered = Object.keys(state.answers).length;
222
+ const total = state.session.questionCount;
223
+ printSuccess(`Q${num}: ${c} saved (${answered}/${total} answered)`);
224
+ if (num < state.questions.length) {
225
+ console.log(chalk.gray(` Next: exam q ${num + 1}`));
226
+ }
227
+ else if (answered === total) {
228
+ console.log(chalk.gray(' All answered! Use: exam review'));
229
+ }
230
+ });
231
+ // ─── exam review ───
232
+ exam
233
+ .command('review')
234
+ .description('Review all answers before submitting')
235
+ .action(() => {
236
+ logCommand('exam review');
237
+ const state = getExamState();
238
+ if (!state) {
239
+ printError('No exam in progress.');
240
+ return;
241
+ }
242
+ printHeader(`Review: ${state.session.examName}`);
243
+ printTimeRemaining();
244
+ console.log();
245
+ const cols = 6;
246
+ let line = ' ';
247
+ for (const q of state.questions) {
248
+ const ans = state.answers[q.number];
249
+ if (ans) {
250
+ line += chalk.green(`Q${String(q.number).padStart(2)} [${ans}] `);
251
+ }
252
+ else {
253
+ line += chalk.yellow(`Q${String(q.number).padStart(2)} [ ] `);
254
+ }
255
+ if (q.number % cols === 0) {
256
+ console.log(line);
257
+ line = ' ';
258
+ }
259
+ }
260
+ if (line.trim())
261
+ console.log(line);
262
+ const answered = Object.keys(state.answers).length;
263
+ const total = state.session.questionCount;
264
+ const unanswered = total - answered;
265
+ console.log();
266
+ printKeyValue('Answered', chalk.green.bold(`${answered}/${total}`));
267
+ if (unanswered > 0) {
268
+ const missing = state.questions
269
+ .filter((q) => !state.answers[q.number])
270
+ .map((q) => q.number)
271
+ .join(', ');
272
+ printKeyValue('Unanswered', chalk.yellow(`${unanswered} (Q${missing})`));
273
+ }
274
+ console.log();
275
+ console.log(chalk.gray(' Ready? Use "exam submit" to submit for grading.'));
276
+ });
277
+ // ─── exam submit ───
278
+ exam
279
+ .command('submit')
280
+ .description('Submit exam for grading')
281
+ .action(async () => {
282
+ logCommand('exam submit');
283
+ const state = getExamState();
284
+ if (!state) {
285
+ printError('No exam in progress.');
286
+ return;
287
+ }
288
+ const answered = Object.keys(state.answers).length;
289
+ const total = state.session.questionCount;
290
+ const unanswered = total - answered;
291
+ let msg = `Submit ${answered}/${total} answers?`;
292
+ if (unanswered > 0) {
293
+ msg += chalk.yellow(` ${unanswered} unanswered.`);
294
+ }
295
+ msg += ' This cannot be undone.';
296
+ const proceed = await confirm({ message: msg, default: false });
297
+ if (!proceed)
298
+ return;
299
+ const client = requireExamConnection();
300
+ if (!client)
301
+ return;
302
+ console.log();
303
+ try {
304
+ drawProgress(0, 'Uploading answers...');
305
+ const result = await client.submitExam(state.session.examId, state.answers);
306
+ drawProgress(50, 'Grading...');
307
+ await sleep(300);
308
+ drawProgress(100, 'Complete!');
309
+ console.log();
310
+ clearExamState();
311
+ console.log();
312
+ printHeader('Exam Result');
313
+ printKeyValue('Score', chalk.bold(`${result.score}/${result.total}`));
314
+ printKeyValue('Percentage', chalk.bold(`${result.percentage}%`));
315
+ printKeyValue('Status', result.passed ? chalk.green.bold('PASSED') : chalk.red.bold('NOT PASSED'));
316
+ console.log();
317
+ }
318
+ catch (err) {
319
+ console.log();
320
+ printError(err.message);
321
+ }
322
+ });
323
+ // ─── exam result ───
324
+ exam
325
+ .command('result [id]')
326
+ .description('View exam result')
327
+ .action(async (examId) => {
328
+ logCommand(`exam result ${examId || ''}`);
329
+ const client = requireExamConnection();
330
+ if (!client)
331
+ return;
332
+ if (!examId) {
333
+ const state = getExamState();
334
+ if (state) {
335
+ examId = state.session.examId;
336
+ }
337
+ else {
338
+ printError('Specify exam ID: exam result <id>');
339
+ return;
340
+ }
341
+ }
342
+ const spinner = createSpinner('Loading result...');
343
+ spinner.start();
344
+ try {
345
+ const result = await client.getResult(examId);
346
+ spinner.succeed('Result loaded');
347
+ console.log();
348
+ printHeader(result.examName);
349
+ printKeyValue('Score', chalk.bold(`${result.score}/${result.total}`));
350
+ printKeyValue('Percentage', chalk.bold(`${result.percentage}%`));
351
+ printKeyValue('Status', result.passed ? chalk.green.bold('PASSED') : chalk.red.bold('NOT PASSED'));
352
+ printKeyValue('Submitted', result.submittedAt);
353
+ console.log();
354
+ }
355
+ catch (err) {
356
+ spinner.fail('Failed to load result');
357
+ printError(err.message);
358
+ }
359
+ });
360
+ // Default action: show status or help
361
+ exam.action(() => {
362
+ logCommand('exam');
363
+ const state = getExamState();
364
+ if (state) {
365
+ printHeader(`In Progress: ${state.session.examName}`);
366
+ printTimeRemaining();
367
+ const answered = Object.keys(state.answers).length;
368
+ printKeyValue('Progress', `${answered}/${state.session.questionCount} answered`);
369
+ console.log();
370
+ console.log(chalk.gray(' exam q [n] | exam answer <n> <A-D> | exam review | exam submit'));
371
+ }
372
+ else {
373
+ printInfo('No exam in progress. Use "exam list" to see available exams.');
374
+ }
375
+ });
376
+ }
@@ -13,7 +13,7 @@ export function registerFilesCommand(program) {
13
13
  logCommand(`files ${id}`);
14
14
  const config = getConfig();
15
15
  if (!isConnected()) {
16
- printError('Not connected. Run: icoa ctf join <url>');
16
+ printError('Not connected. Run: join <url>');
17
17
  return;
18
18
  }
19
19
  const client = new CTFdClient(config.ctfdUrl, config.token);
@@ -44,8 +44,8 @@ export function registerRefCommand(program) {
44
44
  const rows = files.map((f) => [chalk.white(f)]);
45
45
  printTable(['Topic'], rows);
46
46
  console.log();
47
- console.log(chalk.gray(` Usage: icoa ref <topic>`));
48
- console.log(chalk.gray(` Example: icoa ref python`));
47
+ console.log(chalk.gray(` Usage: ref <topic>`));
48
+ console.log(chalk.gray(` Example: ref python`));
49
49
  console.log();
50
50
  return;
51
51
  }
@@ -53,7 +53,7 @@ export function registerRefCommand(program) {
53
53
  const filePath = join(refsDir, `${topic}.txt`);
54
54
  if (!existsSync(filePath)) {
55
55
  printError(`Reference not found: ${topic}`);
56
- printInfo('Run "icoa ref" to see available topics.');
56
+ printInfo('Run "ref" to see available topics.');
57
57
  return;
58
58
  }
59
59
  const content = readFileSync(filePath, 'utf-8');
@@ -1,6 +1,6 @@
1
1
  import chalk from 'chalk';
2
2
  import { input, select } from '@inquirer/prompts';
3
- import { getConfig } from '../lib/config.js';
3
+ import { getConfig, saveConfig } from '../lib/config.js';
4
4
  import { setApiKey } from '../lib/gemini.js';
5
5
  import { printSuccess, printError, printInfo, printHeader, printKeyValue } from '../lib/ui.js';
6
6
  import { logCommand } from '../lib/logger.js';
@@ -18,11 +18,13 @@ export function registerSetupCommand(program) {
18
18
  printKeyValue('CTFd Token', config.token ? chalk.green('Configured') : chalk.gray('Not configured'));
19
19
  printKeyValue('Gemini API Key', config.geminiApiKey || process.env.GEMINI_API_KEY ? chalk.green('Configured') : chalk.gray('Not configured'));
20
20
  printKeyValue('Language', config.language);
21
+ printKeyValue('Mode', config.mode || chalk.gray('Not set'));
21
22
  printKeyValue('Session ID', config.sessionId.substring(0, 8) + '...');
22
23
  console.log();
23
24
  const action = await select({
24
25
  message: 'What would you like to configure?',
25
26
  choices: [
27
+ { name: 'Switch Mode', value: 'mode' },
26
28
  { name: 'Gemini API Key', value: 'gemini' },
27
29
  { name: 'CTFd Connection', value: 'ctfd' },
28
30
  { name: 'Reset Hint Budget', value: 'budget' },
@@ -31,6 +33,19 @@ export function registerSetupCommand(program) {
31
33
  ],
32
34
  });
33
35
  switch (action) {
36
+ case 'mode': {
37
+ const newMode = await select({
38
+ message: 'Select mode:',
39
+ choices: [
40
+ { name: 'National Selection — Exam only, lightweight', value: 'selection' },
41
+ { name: 'International Olympiad — Full CTF with AI assistance', value: 'olympiad' },
42
+ { name: 'National/Regional Partner — Organizer management', value: 'organizer' },
43
+ ],
44
+ });
45
+ saveConfig({ mode: newMode });
46
+ printSuccess(`Mode switched to: ${newMode}. Restart ICOA CLI to apply.`);
47
+ break;
48
+ }
34
49
  case 'gemini': {
35
50
  console.log();
36
51
  printInfo('Get your API key from: https://aistudio.google.com/apikey');
@@ -46,7 +61,7 @@ export function registerSetupCommand(program) {
46
61
  break;
47
62
  }
48
63
  case 'ctfd': {
49
- printInfo('Use "icoa ctf join <url>" to connect to a CTFd instance.');
64
+ printInfo('Use "join <url>" to connect to a CTFd instance.');
50
65
  break;
51
66
  }
52
67
  case 'budget': {
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@ import { registerLangCommand } from './commands/lang.js';
13
13
  import { registerSetupCommand } from './commands/setup.js';
14
14
  import { registerEnvCommand } from './commands/env.js';
15
15
  import { registerAi4ctfCommand } from './commands/ai4ctf.js';
16
+ import { registerExamCommand } from './commands/exam.js';
16
17
  import { getConfig, saveConfig } from './lib/config.js';
17
18
  import { startRepl } from './repl.js';
18
19
  import { setTerminalTheme } from './lib/theme.js';
@@ -37,7 +38,7 @@ ${LINE}
37
38
  ${chalk.white('Sydney, Australia')} ${chalk.gray('Jun 27 - Jul 2, 2026')}
38
39
  ${chalk.cyan.underline('https://icoa2026.au')}
39
40
 
40
- ${chalk.gray('CLI-Native Competition Terminal v2.3.3')}
41
+ ${chalk.gray('CLI-Native Competition Terminal v2.5.3')}
41
42
 
42
43
  ${LINE}
43
44
  `;
@@ -83,6 +84,7 @@ registerLangCommand(program);
83
84
  registerSetupCommand(program);
84
85
  registerEnvCommand(program);
85
86
  registerAi4ctfCommand(program);
87
+ registerExamCommand(program);
86
88
  // Hidden command: switch AI model
87
89
  program
88
90
  .command('model', { hidden: true })
@@ -0,0 +1,14 @@
1
+ import type { ExamListItem, ExamQuestion, ExamSession, ExamResult } from '../types/index.js';
2
+ export declare class ExamClient {
3
+ private baseUrl;
4
+ private token;
5
+ constructor(baseUrl: string, token: string);
6
+ private request;
7
+ getExams(): Promise<ExamListItem[]>;
8
+ startExam(examId: string): Promise<{
9
+ session: ExamSession;
10
+ questions: ExamQuestion[];
11
+ }>;
12
+ submitExam(examId: string, answers: Record<number, string>): Promise<ExamResult>;
13
+ getResult(examId: string): Promise<ExamResult>;
14
+ }
@@ -0,0 +1,41 @@
1
+ export class ExamClient {
2
+ baseUrl;
3
+ token;
4
+ constructor(baseUrl, token) {
5
+ this.baseUrl = baseUrl.replace(/\/+$/, '');
6
+ this.token = token;
7
+ }
8
+ async request(method, path, body) {
9
+ const url = `${this.baseUrl}:9090/api/icoa/exams${path}`;
10
+ const res = await fetch(url, {
11
+ method,
12
+ headers: {
13
+ Authorization: `Token ${this.token}`,
14
+ 'Content-Type': 'application/json',
15
+ },
16
+ body: body ? JSON.stringify(body) : undefined,
17
+ signal: AbortSignal.timeout(10000),
18
+ });
19
+ if (!res.ok) {
20
+ const text = await res.text().catch(() => 'Unknown error');
21
+ throw new Error(`Exam API error (${res.status}): ${text}`);
22
+ }
23
+ const json = await res.json();
24
+ if (json.success === false) {
25
+ throw new Error(json.message || 'Exam API error');
26
+ }
27
+ return json.data;
28
+ }
29
+ async getExams() {
30
+ return this.request('GET', '');
31
+ }
32
+ async startExam(examId) {
33
+ return this.request('POST', `/${examId}/start`);
34
+ }
35
+ async submitExam(examId, answers) {
36
+ return this.request('POST', `/${examId}/submit`, { answers });
37
+ }
38
+ async getResult(examId) {
39
+ return this.request('GET', `/${examId}/result`);
40
+ }
41
+ }
@@ -0,0 +1,6 @@
1
+ import type { ExamState } from '../types/index.js';
2
+ export declare function getExamState(): ExamState | null;
3
+ export declare function saveExamState(state: ExamState): void;
4
+ export declare function clearExamState(): void;
5
+ export declare function isExamActive(): boolean;
6
+ export declare function getExamDeadline(): Date | null;
@@ -0,0 +1,35 @@
1
+ import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { getIcoaDir } from './config.js';
4
+ function stateFile() {
5
+ return join(getIcoaDir(), 'exam-state.json');
6
+ }
7
+ export function getExamState() {
8
+ const f = stateFile();
9
+ if (!existsSync(f))
10
+ return null;
11
+ try {
12
+ return JSON.parse(readFileSync(f, 'utf-8'));
13
+ }
14
+ catch {
15
+ return null;
16
+ }
17
+ }
18
+ export function saveExamState(state) {
19
+ writeFileSync(stateFile(), JSON.stringify(state, null, 2));
20
+ }
21
+ export function clearExamState() {
22
+ const f = stateFile();
23
+ if (existsSync(f))
24
+ unlinkSync(f);
25
+ }
26
+ export function isExamActive() {
27
+ return getExamState() !== null;
28
+ }
29
+ export function getExamDeadline() {
30
+ const state = getExamState();
31
+ if (!state)
32
+ return null;
33
+ const start = new Date(state.session.startedAt).getTime();
34
+ return new Date(start + state.session.durationMinutes * 60 * 1000);
35
+ }
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Post-install script: shows a progress bar during ICOA CLI setup.
4
+ * Runs automatically after `npm install -g icoa-cli`.
5
+ */
6
+ export {};
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Post-install script: shows a progress bar during ICOA CLI setup.
4
+ * Runs automatically after `npm install -g icoa-cli`.
5
+ */
6
+ const steps = [
7
+ 'Initializing ICOA CLI...',
8
+ 'Loading competition modules...',
9
+ 'Configuring exam system...',
10
+ 'Setting up references...',
11
+ 'Finalizing installation...',
12
+ ];
13
+ const BAR_WIDTH = 30;
14
+ const TOTAL = 100;
15
+ function drawBar(percent, label) {
16
+ const filled = Math.round((percent / TOTAL) * BAR_WIDTH);
17
+ const empty = BAR_WIDTH - filled;
18
+ const bar = '\x1b[32m' + '█'.repeat(filled) + '\x1b[90m' + '░'.repeat(empty) + '\x1b[0m';
19
+ const pct = String(percent).padStart(3) + '%';
20
+ process.stdout.write(`\r ${bar} ${pct} \x1b[90m${label}\x1b[0m`);
21
+ }
22
+ async function run() {
23
+ console.log();
24
+ console.log(' \x1b[1m\x1b[37m██╗ ██████╗ ██████╗ █████╗\x1b[0m');
25
+ console.log(' \x1b[1m\x1b[37m██║██╔════╝██╔═══██╗██╔══██╗\x1b[0m');
26
+ console.log(' \x1b[1m\x1b[37m██║██║ ██║ ██║███████║\x1b[0m');
27
+ console.log(' \x1b[1m\x1b[37m██║██║ ██║ ██║██╔══██║\x1b[0m');
28
+ console.log(' \x1b[1m\x1b[37m██║╚██████╗╚██████╔╝██║ ██║\x1b[0m');
29
+ console.log(' \x1b[1m\x1b[37m╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝\x1b[0m');
30
+ console.log();
31
+ for (let i = 0; i < steps.length; i++) {
32
+ const startPct = Math.round((i / steps.length) * TOTAL);
33
+ const endPct = Math.round(((i + 1) / steps.length) * TOTAL);
34
+ for (let p = startPct; p <= endPct; p++) {
35
+ drawBar(p, steps[i]);
36
+ await new Promise((r) => setTimeout(r, 15));
37
+ }
38
+ }
39
+ console.log();
40
+ console.log();
41
+ console.log(' \x1b[32m✓\x1b[0m ICOA CLI installed successfully!');
42
+ console.log();
43
+ console.log(' \x1b[90mGet started:\x1b[0m');
44
+ console.log(' \x1b[1m\x1b[37micoa\x1b[0m \x1b[90mLaunch and select your mode\x1b[0m');
45
+ console.log(' \x1b[1m\x1b[37micoa --help\x1b[0m \x1b[90mShow all commands\x1b[0m');
46
+ console.log();
47
+ }
48
+ run().catch(() => { });
49
+ export {};
package/dist/repl.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createInterface } from 'node:readline';
2
2
  import { spawn, execSync as execSyncFn } from 'node:child_process';
3
3
  import chalk from 'chalk';
4
- import { isConnected, getConfig } from './lib/config.js';
4
+ 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';
@@ -28,14 +28,42 @@ const BLOCKED_COMMANDS = new Set([
28
28
  'iptables', 'ufw', // firewall
29
29
  ]);
30
30
  const INTERCEPT = '__REPL_NO_EXIT__';
31
- const VERSION = '2.3.3';
31
+ const VERSION = '2.5.1';
32
32
  export async function startRepl(program, resumeMode) {
33
33
  const config = getConfig();
34
34
  const connected = isConnected();
35
35
  const realExit = process.exit.bind(process);
36
36
  const activated = isActivated();
37
- // First run or upgrade: prompt to install env
38
- if (isFirstRunOrUpgrade(VERSION)) {
37
+ // ─── Mode selection (every launch) ───
38
+ const { select: selectMode, confirm: confirmMode } = await import('@inquirer/prompts');
39
+ const savedMode = config.mode || '';
40
+ const choices = [
41
+ { name: ` ${chalk.bold('National Selection')} ${chalk.gray('·')} ${chalk.gray('Exam only, lightweight')}`, value: 'selection' },
42
+ { name: ` ${chalk.bold('International Olympiad')} ${chalk.gray('·')} ${chalk.gray('CTF x AI (~500MB)')}`, value: 'olympiad' },
43
+ { name: ` ${chalk.bold('National/Regional Partner')} ${chalk.gray('·')} ${chalk.gray('Organizer management')}`, value: 'organizer' },
44
+ ];
45
+ const defaultIndex = savedMode ? choices.findIndex((c) => c.value === savedMode) : 0;
46
+ console.log(chalk.gray(' Use ↑↓ to select, Enter to confirm.'));
47
+ console.log();
48
+ let mode = await selectMode({
49
+ message: 'Mode',
50
+ choices,
51
+ default: choices[defaultIndex >= 0 ? defaultIndex : 0].value,
52
+ });
53
+ if (mode === 'olympiad' && savedMode !== 'olympiad') {
54
+ console.log();
55
+ console.log(chalk.yellow(' This mode will download ~500MB of CTF tools and AI models.'));
56
+ const proceed = await confirmMode({ message: 'Continue?', default: true });
57
+ if (!proceed) {
58
+ mode = 'selection';
59
+ console.log(chalk.gray(' Switched to National Selection mode.'));
60
+ }
61
+ }
62
+ if (mode !== savedMode)
63
+ saveConfig({ mode });
64
+ console.log();
65
+ // First run or upgrade: prompt to install env (olympiad only)
66
+ if (mode === 'olympiad' && isFirstRunOrUpgrade(VERSION)) {
39
67
  markVersionSeen(VERSION);
40
68
  console.log(chalk.gray(' Checking competition environment...'));
41
69
  // Quick check: count missing Python libs
@@ -91,41 +119,66 @@ export async function startRepl(program, resumeMode) {
91
119
  console.log();
92
120
  }
93
121
  }
94
- // Device mismatch check
95
- if (activated && !isDeviceMatch()) {
96
- console.log(chalk.red(' Token was activated on a different device.'));
97
- console.log(chalk.gray(' Contact organizer for assistance.'));
98
- console.log();
99
- }
100
- else if (connected) {
101
- console.log(chalk.green(` Welcome back, ${config.userName}!`));
102
- console.log(chalk.gray(` Connected to ${config.ctfdUrl}`));
103
- console.log();
104
- }
105
- else if (activated) {
106
- ensureWorkspace();
107
- console.log(chalk.green(' Welcome, competitor! Ready to hack.'));
108
- console.log(chalk.gray(` Workspace: ${WORKSPACE}`));
109
- console.log();
110
- console.log(chalk.gray(' Quick Start'));
111
- console.log(chalk.gray(' ─────────────'));
112
- console.log(chalk.white(' join <url> ') + chalk.gray('Connect to competition'));
113
- console.log(chalk.white(' challenges ') + chalk.gray('View challenges'));
114
- console.log(chalk.white(' hint <question> ') + chalk.gray('Ask AI for help'));
115
- console.log(chalk.white(' env ') + chalk.gray('Check your tools'));
116
- console.log(chalk.white(' help ') + chalk.gray('All commands'));
117
- console.log();
122
+ // ─── Mode-specific welcome ───
123
+ if (mode === 'selection' || mode === 'organizer') {
124
+ // Lightweight modes: skip activate/device checks
125
+ const modeLabel = mode === 'selection'
126
+ ? chalk.cyan.bold('[Selection Mode]')
127
+ : chalk.yellow.bold('[Organizer Mode]');
128
+ if (connected) {
129
+ console.log(chalk.green(` Welcome back, ${config.userName}!`) + ' ' + modeLabel);
130
+ console.log(chalk.gray(` Connected to ${config.ctfdUrl}`));
131
+ console.log(chalk.gray(' Switch mode: setup'));
132
+ console.log();
133
+ }
134
+ else {
135
+ console.log(' ' + modeLabel + chalk.gray(' (switch via: setup)'));
136
+ console.log();
137
+ console.log(chalk.gray(' Quick Start'));
138
+ console.log(chalk.gray(' ─────────────'));
139
+ console.log(chalk.white(' join <url> ') + chalk.gray('Connect to exam server'));
140
+ console.log(chalk.white(' exam list ') + chalk.gray('View available exams'));
141
+ console.log(chalk.white(' help ') + chalk.gray('All commands'));
142
+ console.log();
143
+ }
118
144
  }
119
145
  else {
120
- console.log(chalk.white(' Welcome to ICOA CLI!'));
121
- console.log();
122
- console.log(chalk.gray(' Quick Start'));
123
- console.log(chalk.gray(' ─────────────'));
124
- console.log(chalk.white(' activate <token> ') + chalk.gray('Unlock with your access token'));
125
- console.log(chalk.white(' ref <topic> ') + chalk.gray('Browse tool references'));
126
- console.log(chalk.white(' env ') + chalk.gray('Check your tools'));
127
- console.log(chalk.white(' help ') + chalk.gray('All commands'));
128
- console.log();
146
+ // Olympiad mode: full flow with activate/device checks
147
+ if (activated && !isDeviceMatch()) {
148
+ console.log(chalk.red(' Token was activated on a different device.'));
149
+ console.log(chalk.gray(' Contact organizer for assistance.'));
150
+ console.log();
151
+ }
152
+ else if (connected) {
153
+ console.log(chalk.green(` Welcome back, ${config.userName}!`));
154
+ console.log(chalk.gray(` Connected to ${config.ctfdUrl}`));
155
+ console.log();
156
+ }
157
+ else if (activated) {
158
+ ensureWorkspace();
159
+ console.log(chalk.green(' Welcome, competitor! Ready to hack.'));
160
+ console.log(chalk.gray(` Workspace: ${WORKSPACE}`));
161
+ console.log();
162
+ console.log(chalk.gray(' Quick Start'));
163
+ console.log(chalk.gray(' ─────────────'));
164
+ console.log(chalk.white(' join <url> ') + chalk.gray('Connect to competition'));
165
+ console.log(chalk.white(' challenges ') + chalk.gray('View challenges'));
166
+ console.log(chalk.white(' hint <question> ') + chalk.gray('Ask AI for help'));
167
+ console.log(chalk.white(' env ') + chalk.gray('Check your tools'));
168
+ console.log(chalk.white(' help ') + chalk.gray('All commands'));
169
+ console.log();
170
+ }
171
+ else {
172
+ console.log(chalk.white(' Welcome to ICOA CLI!'));
173
+ console.log();
174
+ console.log(chalk.gray(' Quick Start'));
175
+ console.log(chalk.gray(' ─────────────'));
176
+ console.log(chalk.white(' activate <token> ') + chalk.gray('Unlock with your access token'));
177
+ console.log(chalk.white(' ref <topic> ') + chalk.gray('Browse tool references'));
178
+ console.log(chalk.white(' env ') + chalk.gray('Check your tools'));
179
+ console.log(chalk.white(' help ') + chalk.gray('All commands'));
180
+ console.log();
181
+ }
129
182
  }
130
183
  program.exitOverride();
131
184
  program.configureOutput({
@@ -176,7 +229,7 @@ export async function startRepl(program, resumeMode) {
176
229
  }
177
230
  // Help
178
231
  if (input === 'help' || input === '?') {
179
- printReplHelp(isActivated());
232
+ printReplHelp(isActivated(), mode);
180
233
  rl.prompt();
181
234
  return;
182
235
  }
@@ -209,9 +262,24 @@ export async function startRepl(program, resumeMode) {
209
262
  rl.prompt();
210
263
  return;
211
264
  }
212
- // Token check — only ref allowed without activation or device mismatch
213
265
  const cmd = input.split(/\s+/)[0].toLowerCase();
214
- if ((!isActivated() || !isDeviceMatch()) && !isFreeCommand(cmd)) {
266
+ // ─── Mode-based command filtering ───
267
+ const selectionCommands = ['join', 'exam', 'setup', 'lang', 'ref', 'ctf'];
268
+ const organizerCommands = ['join', 'exam', 'setup', 'lang', 'ref', 'ctf'];
269
+ if (mode === 'selection' && !selectionCommands.includes(cmd)) {
270
+ console.log(chalk.gray(' Not available in Selection mode. Switch via: setup'));
271
+ console.log();
272
+ rl.prompt();
273
+ return;
274
+ }
275
+ if (mode === 'organizer' && !organizerCommands.includes(cmd)) {
276
+ console.log(chalk.gray(' Not available in Organizer mode. Switch via: setup'));
277
+ console.log();
278
+ rl.prompt();
279
+ return;
280
+ }
281
+ // Token check — only in olympiad mode
282
+ if (mode === 'olympiad' && (!isActivated() || !isDeviceMatch()) && !isFreeCommand(cmd)) {
215
283
  console.log(chalk.yellow(' Restricted mode. ') + chalk.gray('Enter your access token:'));
216
284
  console.log(chalk.white(' activate <token>'));
217
285
  console.log();
@@ -226,6 +294,7 @@ export async function startRepl(program, resumeMode) {
226
294
  'scoreboard', 'sb', 'status', 'time', 'hint', 'hint-b', 'hint-c',
227
295
  'hint-budget', 'ref', 'shell', 'files', 'connect', 'note',
228
296
  'log', 'lang', 'setup', 'env', 'ai4ctf', 'model', 'ctf',
297
+ 'exam',
229
298
  ];
230
299
  if (!knownCommands.includes(cmd)) {
231
300
  // Block dangerous commands
@@ -378,15 +447,37 @@ function mapCommand(input) {
378
447
  'hint', 'hint-b', 'hint-c', 'hint-budget',
379
448
  'ref', 'shell', 'files', 'connect', 'note',
380
449
  'log', 'lang', 'setup', 'env', 'ai4ctf', 'model',
381
- 'ctf',
450
+ 'ctf', 'exam',
382
451
  ];
383
452
  if (directCommands.includes(cmd)) {
384
453
  return [cmd, ...rest];
385
454
  }
386
455
  return parts;
387
456
  }
388
- function printReplHelp(activated) {
457
+ function printReplHelp(activated, mode = 'olympiad') {
389
458
  console.log();
459
+ // ─── Selection / Organizer: lightweight help ───
460
+ if (mode === 'selection' || mode === 'organizer') {
461
+ console.log(chalk.bold.white(' Exam'));
462
+ console.log(chalk.white(' join <url> ') + chalk.gray('Connect to exam server'));
463
+ console.log(chalk.white(' exam list ') + chalk.gray('Available exams'));
464
+ console.log(chalk.white(' exam start <id> ') + chalk.gray('Begin an exam'));
465
+ console.log(chalk.white(' exam q [n] ') + chalk.gray('View questions'));
466
+ console.log(chalk.white(' exam answer <n> <X> ') + chalk.gray('Answer question'));
467
+ console.log(chalk.white(' exam review ') + chalk.gray('Review all answers'));
468
+ console.log(chalk.white(' exam submit ') + chalk.gray('Submit for grading'));
469
+ console.log(chalk.white(' exam result ') + chalk.gray('View your score'));
470
+ console.log();
471
+ console.log(chalk.bold.white(' System'));
472
+ console.log(chalk.white(' ref [topic] ') + chalk.gray('Quick reference'));
473
+ console.log(chalk.white(' setup ') + chalk.gray('Settings / switch mode'));
474
+ console.log(chalk.white(' lang [code] ') + chalk.gray('Switch language'));
475
+ console.log(chalk.white(' clear ') + chalk.gray('Clear screen'));
476
+ console.log(chalk.white(' exit ') + chalk.gray('Quit'));
477
+ console.log();
478
+ return;
479
+ }
480
+ // ─── Olympiad: full help ───
390
481
  if (!activated) {
391
482
  console.log(chalk.bold.yellow(' Restricted Mode — activate with a token to unlock all commands'));
392
483
  console.log();
@@ -405,6 +496,15 @@ function printReplHelp(activated) {
405
496
  console.log(chalk.white(' status ') + chalk.gray('Competition status'));
406
497
  console.log(chalk.white(' time ') + chalk.gray('Countdown timer'));
407
498
  console.log();
499
+ console.log(chalk.bold.white(' Exam'));
500
+ console.log(chalk.white(' exam list ') + chalk.gray('Available exams'));
501
+ console.log(chalk.white(' exam start <id> ') + chalk.gray('Begin an exam'));
502
+ console.log(chalk.white(' exam q [n] ') + chalk.gray('View questions'));
503
+ console.log(chalk.white(' exam answer <n> <X> ') + chalk.gray('Answer question'));
504
+ console.log(chalk.white(' exam review ') + chalk.gray('Review all answers'));
505
+ console.log(chalk.white(' exam submit ') + chalk.gray('Submit for grading'));
506
+ console.log(chalk.white(' exam result ') + chalk.gray('View your score'));
507
+ console.log();
408
508
  console.log(chalk.bold.white(' AI'));
409
509
  console.log(chalk.white(' ai4ctf ') + chalk.gray('Chat with your AI teammate'));
410
510
  console.log(chalk.white(' hint <question> ') + chalk.gray('Level A — General guidance'));
@@ -102,8 +102,11 @@ export interface IcoaConfig {
102
102
  deviceFingerprint: string;
103
103
  lastVersion: string;
104
104
  sessionCookie: string;
105
+ country: string;
106
+ mode: IcoaMode | '';
105
107
  }
106
108
  export type CompetitionState = 'pre_competition' | 'demo' | 'live' | 'finished' | 'unknown';
109
+ export type IcoaMode = 'selection' | 'olympiad' | 'organizer';
107
110
  export type HintLevel = 'A' | 'B' | 'C';
108
111
  export interface HintBudget {
109
112
  a: number;
@@ -128,3 +131,44 @@ export declare const DEFAULT_BUDGET: HintBudget;
128
131
  export declare const DEFAULT_CONFIG: IcoaConfig;
129
132
  export declare const SUPPORTED_LANGUAGES: readonly ["en", "zh", "ja", "ko", "es"];
130
133
  export type SupportedLanguage = typeof SUPPORTED_LANGUAGES[number];
134
+ export interface ExamListItem {
135
+ id: string;
136
+ name: string;
137
+ country: string;
138
+ questionCount: number;
139
+ durationMinutes: number;
140
+ status: 'available' | 'in_progress' | 'submitted';
141
+ }
142
+ export interface ExamQuestion {
143
+ number: number;
144
+ text: string;
145
+ category?: string;
146
+ options: {
147
+ A: string;
148
+ B: string;
149
+ C: string;
150
+ D: string;
151
+ };
152
+ }
153
+ export interface ExamSession {
154
+ examId: string;
155
+ examName: string;
156
+ startedAt: string;
157
+ durationMinutes: number;
158
+ questionCount: number;
159
+ country: string;
160
+ }
161
+ export interface ExamState {
162
+ session: ExamSession;
163
+ questions: ExamQuestion[];
164
+ answers: Record<number, string>;
165
+ }
166
+ export interface ExamResult {
167
+ examId: string;
168
+ examName: string;
169
+ score: number;
170
+ total: number;
171
+ percentage: number;
172
+ passed: boolean;
173
+ submittedAt: string;
174
+ }
@@ -30,5 +30,7 @@ export const DEFAULT_CONFIG = {
30
30
  deviceFingerprint: '',
31
31
  lastVersion: '',
32
32
  sessionCookie: '',
33
+ country: '',
34
+ mode: '',
33
35
  };
34
36
  export const SUPPORTED_LANGUAGES = ['en', 'zh', 'ja', 'ko', 'es'];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "icoa-cli",
3
- "version": "2.3.3",
3
+ "version": "2.5.3",
4
4
  "description": "ICOA CLI — The world's first CLI-native CTF competition terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,7 +13,8 @@
13
13
  "scripts": {
14
14
  "build": "tsc",
15
15
  "dev": "tsc --watch",
16
- "start": "node dist/index.js"
16
+ "start": "node dist/index.js",
17
+ "postinstall": "node dist/postinstall.js 2>/dev/null || true"
17
18
  },
18
19
  "keywords": [
19
20
  "ctf",
package/refs/icoa.txt CHANGED
@@ -1,11 +1,16 @@
1
- ICOA CLI v2 — Quick Reference
1
+ ICOA CLI v2.5 — Quick Reference
2
2
  ==============================
3
3
 
4
+ MODES (selected on first run)
5
+ National Selection Lightweight exam-only mode
6
+ International Olympiad Full CTF competition mode
7
+ Organizer Partner management mode
8
+ setup → Switch Mode Change mode anytime
9
+
4
10
  GETTING STARTED
5
- activate <token> Unlock with your access token
6
- join <url> Connect to CTFd competition
7
- env Check tool environment (109 commands)
8
- env setup One-click install all tools
11
+ join <url> Connect to server
12
+ activate <token> Unlock (Olympiad mode only)
13
+ env Check tool environment (Olympiad only)
9
14
  help Show all commands
10
15
 
11
16
  COMPETITION
@@ -16,6 +21,15 @@ COMPETITION
16
21
  status Hint budget, score, rank, time
17
22
  time Competition countdown
18
23
 
24
+ EXAM (National Selection)
25
+ exam list Available exams for your country
26
+ exam start <id> Begin exam (starts timer)
27
+ exam q [n] View question n (or all)
28
+ exam answer <n> <A-D> Answer question n
29
+ exam review Review all answers
30
+ exam submit Submit for grading
31
+ exam result View your score
32
+
19
33
  AI HINTS
20
34
  hint <question> Level A — General guidance (50 uses)
21
35
  hint-b <question> Level B — Deep analysis (10 uses)