icoa-cli 2.3.2 → 2.5.2
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.
- package/dist/commands/ai4ctf.d.ts +2 -0
- package/dist/commands/ai4ctf.js +44 -64
- package/dist/commands/connect.js +1 -1
- package/dist/commands/ctf.js +34 -35
- package/dist/commands/exam.d.ts +2 -0
- package/dist/commands/exam.js +376 -0
- package/dist/commands/files.js +1 -1
- package/dist/commands/ref.js +3 -3
- package/dist/commands/setup.js +17 -2
- package/dist/index.js +3 -1
- package/dist/lib/exam-client.d.ts +14 -0
- package/dist/lib/exam-client.js +41 -0
- package/dist/lib/exam-state.d.ts +6 -0
- package/dist/lib/exam-state.js +35 -0
- package/dist/postinstall.d.ts +6 -0
- package/dist/postinstall.js +49 -0
- package/dist/repl.js +159 -45
- package/dist/types/index.d.ts +44 -0
- package/dist/types/index.js +2 -0
- package/package.json +3 -2
- package/refs/icoa.txt +19 -5
package/dist/commands/ai4ctf.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { createInterface } from 'node:readline';
|
|
2
1
|
import chalk from 'chalk';
|
|
3
2
|
import { createChatSession } from '../lib/gemini.js';
|
|
4
3
|
import { isTokenCapReached, addTokenUsage, getTokenUsage } from '../lib/budget.js';
|
|
@@ -12,28 +11,68 @@ function getChallengeContext() {
|
|
|
12
11
|
}
|
|
13
12
|
return undefined;
|
|
14
13
|
}
|
|
14
|
+
// Chat state — shared with REPL
|
|
15
|
+
let chatActive = false;
|
|
16
|
+
let chatSession = null;
|
|
17
|
+
export function isChatActive() {
|
|
18
|
+
return chatActive;
|
|
19
|
+
}
|
|
20
|
+
export async function handleChatMessage(input) {
|
|
21
|
+
if (!chatSession)
|
|
22
|
+
return 'exit';
|
|
23
|
+
if (input === 'exit' || input === 'back' || input === 'quit') {
|
|
24
|
+
chatActive = false;
|
|
25
|
+
chatSession = null;
|
|
26
|
+
console.log();
|
|
27
|
+
printInfo('Returning to ICOA terminal.');
|
|
28
|
+
console.log();
|
|
29
|
+
return 'exit';
|
|
30
|
+
}
|
|
31
|
+
if (isTokenCapReached()) {
|
|
32
|
+
chatActive = false;
|
|
33
|
+
chatSession = null;
|
|
34
|
+
printError('Token budget exhausted. Exiting chat mode.');
|
|
35
|
+
return 'exit';
|
|
36
|
+
}
|
|
37
|
+
logCommand(`ai4ctf: ${input}`);
|
|
38
|
+
console.log(chalk.gray(' Thinking...'));
|
|
39
|
+
try {
|
|
40
|
+
const response = await chatSession.sendMessage(input);
|
|
41
|
+
process.stdout.write('\x1b[1A\x1b[2K');
|
|
42
|
+
addTokenUsage(response.tokensUsed);
|
|
43
|
+
const updated = getTokenUsage();
|
|
44
|
+
console.log();
|
|
45
|
+
printMarkdown(response.text);
|
|
46
|
+
console.log(chalk.gray(` [${response.tokensUsed.toLocaleString()} tokens | ${updated.used.toLocaleString()}/${updated.cap.toLocaleString()} total]`));
|
|
47
|
+
console.log();
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
process.stdout.write('\x1b[1A\x1b[2K');
|
|
51
|
+
printError(`AI error: ${err.message}`);
|
|
52
|
+
console.log();
|
|
53
|
+
}
|
|
54
|
+
return 'continue';
|
|
55
|
+
}
|
|
15
56
|
export function registerAi4ctfCommand(program) {
|
|
16
57
|
program
|
|
17
58
|
.command('ai4ctf')
|
|
18
59
|
.description('Chat with your AI teammate')
|
|
19
60
|
.action(async () => {
|
|
20
61
|
logCommand('ai4ctf');
|
|
21
|
-
// Check token cap
|
|
22
62
|
if (isTokenCapReached()) {
|
|
23
63
|
printError('Token budget exhausted. No more AI interactions available.');
|
|
24
64
|
return;
|
|
25
65
|
}
|
|
26
66
|
const context = getChallengeContext();
|
|
27
67
|
const tokenState = getTokenUsage();
|
|
28
|
-
// Create chat session
|
|
29
|
-
let chat;
|
|
30
68
|
try {
|
|
31
|
-
|
|
69
|
+
chatSession = await createChatSession(context);
|
|
32
70
|
}
|
|
33
71
|
catch (err) {
|
|
34
72
|
printError(err.message);
|
|
35
73
|
return;
|
|
36
74
|
}
|
|
75
|
+
chatActive = true;
|
|
37
76
|
// Welcome banner
|
|
38
77
|
console.log();
|
|
39
78
|
console.log(chalk.magenta(' ┌─────────────────────────────────────────┐'));
|
|
@@ -47,64 +86,5 @@ export function registerAi4ctfCommand(program) {
|
|
|
47
86
|
console.log(chalk.magenta(' │') + chalk.gray(" Type 'exit' to return".padEnd(41)) + chalk.magenta('│'));
|
|
48
87
|
console.log(chalk.magenta(' └─────────────────────────────────────────┘'));
|
|
49
88
|
console.log();
|
|
50
|
-
// Inner chat REPL
|
|
51
|
-
return new Promise((resolve) => {
|
|
52
|
-
const chatRl = createInterface({
|
|
53
|
-
input: process.stdin,
|
|
54
|
-
output: process.stdout,
|
|
55
|
-
prompt: chalk.magenta('ai4ctf> '),
|
|
56
|
-
terminal: true,
|
|
57
|
-
});
|
|
58
|
-
chatRl.prompt();
|
|
59
|
-
chatRl.on('line', async (line) => {
|
|
60
|
-
const input = line.trim();
|
|
61
|
-
if (!input) {
|
|
62
|
-
chatRl.prompt();
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
// Exit commands
|
|
66
|
-
if (input === 'exit' || input === 'back' || input === 'quit') {
|
|
67
|
-
chatRl.close();
|
|
68
|
-
return;
|
|
69
|
-
}
|
|
70
|
-
// Token cap check
|
|
71
|
-
if (isTokenCapReached()) {
|
|
72
|
-
console.log();
|
|
73
|
-
printError('Token budget exhausted. Exiting chat mode.');
|
|
74
|
-
chatRl.close();
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
// Log the chat message
|
|
78
|
-
logCommand(`ai4ctf: ${input}`);
|
|
79
|
-
// Send message
|
|
80
|
-
console.log(chalk.gray(' Thinking...'));
|
|
81
|
-
try {
|
|
82
|
-
const response = await chat.sendMessage(input);
|
|
83
|
-
// Clear "Thinking..." line
|
|
84
|
-
process.stdout.write('\x1b[1A\x1b[2K');
|
|
85
|
-
// Track tokens
|
|
86
|
-
addTokenUsage(response.tokensUsed);
|
|
87
|
-
const updated = getTokenUsage();
|
|
88
|
-
// Display response
|
|
89
|
-
console.log();
|
|
90
|
-
printMarkdown(response.text);
|
|
91
|
-
console.log(chalk.gray(` [${response.tokensUsed.toLocaleString()} tokens | ${updated.used.toLocaleString()}/${updated.cap.toLocaleString()} total]`));
|
|
92
|
-
console.log();
|
|
93
|
-
}
|
|
94
|
-
catch (err) {
|
|
95
|
-
// Clear "Thinking..." line
|
|
96
|
-
process.stdout.write('\x1b[1A\x1b[2K');
|
|
97
|
-
printError(`AI error: ${err.message}`);
|
|
98
|
-
console.log();
|
|
99
|
-
}
|
|
100
|
-
chatRl.prompt();
|
|
101
|
-
});
|
|
102
|
-
chatRl.on('close', () => {
|
|
103
|
-
console.log();
|
|
104
|
-
printInfo('Returning to ICOA terminal.');
|
|
105
|
-
console.log();
|
|
106
|
-
resolve();
|
|
107
|
-
});
|
|
108
|
-
});
|
|
109
89
|
});
|
|
110
90
|
}
|
package/dist/commands/connect.js
CHANGED
|
@@ -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:
|
|
15
|
+
printError('Not connected. Run: join <url>');
|
|
16
16
|
return;
|
|
17
17
|
}
|
|
18
18
|
const client = new CTFdClient(config.ctfdUrl, config.token);
|
package/dist/commands/ctf.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import { input
|
|
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
|
|
34
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
60
|
-
spinner.
|
|
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
|
|
67
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(`
|
|
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(`
|
|
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:
|
|
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,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
|
+
}
|
package/dist/commands/files.js
CHANGED
|
@@ -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:
|
|
16
|
+
printError('Not connected. Run: join <url>');
|
|
17
17
|
return;
|
|
18
18
|
}
|
|
19
19
|
const client = new CTFdClient(config.ctfdUrl, config.token);
|