icoa-cli 2.19.36 → 2.19.38

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.
@@ -1,4 +1,5 @@
1
1
  import chalk from 'chalk';
2
+ import { createChatSession } from '../lib/gemini.js';
2
3
  import { logCommand } from '../lib/logger.js';
3
4
  import { printError } from '../lib/ui.js';
4
5
  import { getConfig } from '../lib/config.js';
@@ -200,23 +201,7 @@ export function registerCtf4aiDemoCommand(program) {
200
201
  console.log(chalk.gray(` ${t('ctf4aiQuit')}`));
201
202
  console.log();
202
203
  try {
203
- // Create chat with restrictive system prompt
204
- const { GoogleGenAI } = await import('@google/genai');
205
- const apiKey = process.env.GEMINI_API_KEY || config.geminiApiKey || 'AIzaSyBLjo2UjaFqWmFaCap0TpiXjo9cDqFmY-E';
206
- const ai = new GoogleGenAI({ apiKey });
207
- const chat = ai.chats.create({
208
- model: modelName,
209
- config: { systemInstruction: CTF4AI_SYSTEM_PROMPT },
210
- });
211
- ctf4aiSession = {
212
- async sendMessage(msg) {
213
- const response = await chat.sendMessage({ message: msg });
214
- const text = response.text ?? '';
215
- const usage = response.usageMetadata;
216
- const tokensUsed = usage?.totalTokenCount || ((usage?.promptTokenCount || 0) + (usage?.candidatesTokenCount || 0));
217
- return { text, tokensUsed };
218
- },
219
- };
204
+ ctf4aiSession = await createChatSession(undefined, CTF4AI_SYSTEM_PROMPT);
220
205
  ctf4aiActive = true;
221
206
  ctf4aiTokens = 0;
222
207
  console.log(chalk.red(' ctf4ai> ') + chalk.gray(`${t('ctf4aiPrompt')}`));
@@ -183,17 +183,29 @@ function printHowToPlay() {
183
183
  console.log(chalk.gray(' ru · hi · de · id · th · vi · tr'));
184
184
  console.log(chalk.gray(' ─────────────────────────────────────────'));
185
185
  }
186
- // Australian easter eggs every 3 questions (for 15-question demo)
187
- function getEasterEggs() {
188
- return {
189
- 3: { emoji: '🏛️', text: t('egg3') },
190
- 5: { emoji: '🐨', text: t('egg5') },
191
- 7: { emoji: '🌉', text: t('egg7') },
192
- 9: { emoji: '🦘', text: t('egg9') },
193
- 11: { emoji: '🏖️', text: t('egg11') },
194
- 13: { emoji: '🦈', text: t('egg13') },
195
- 15: { emoji: '🎉', text: t('egg15') },
196
- };
186
+ // Australian easter eggs at percentage-based positions. The messages were
187
+ // originally written for a 15-question exam; we now map them by percentage
188
+ // so they fire at the right moment regardless of question count (10 or 15).
189
+ function getEasterEgg(current, total) {
190
+ const EGGS = [
191
+ { pct: 0.20, emoji: '🏛️', key: 'egg3' }, // Great start
192
+ { pct: 0.33, emoji: '🐨', key: 'egg5' }, // 1/3 done
193
+ { pct: 0.50, emoji: '🌉', key: 'egg7' }, // Keep going (halfway)
194
+ { pct: 0.60, emoji: '🦘', key: 'egg9' }, // Past halfway
195
+ { pct: 0.80, emoji: '🏖️', key: 'egg11' }, // Almost there
196
+ { pct: 0.87, emoji: '🦈', key: 'egg13' }, // 2 more to go
197
+ { pct: 1.00, emoji: '🎉', key: 'egg15' }, // All done
198
+ ];
199
+ const seen = new Set();
200
+ for (const egg of EGGS) {
201
+ const targetQ = Math.round(egg.pct * total);
202
+ if (targetQ < 1 || seen.has(targetQ))
203
+ continue;
204
+ seen.add(targetQ);
205
+ if (current === targetQ)
206
+ return { emoji: egg.emoji, text: t(egg.key) };
207
+ }
208
+ return undefined;
197
209
  }
198
210
  function printQuestionProgress(current, total, answered) {
199
211
  const width = 30;
@@ -220,9 +232,9 @@ function printQuestion(q, answer) {
220
232
  const eliminated = help.eliminated[q.number] || [];
221
233
  // Progress bar
222
234
  printQuestionProgress(q.number, total, answered);
223
- // Easter egg
224
- const egg = getEasterEggs()[q.number];
225
- if (egg && q.number <= total) {
235
+ // Easter egg (position calculated by percentage of total)
236
+ const egg = getEasterEgg(q.number, total);
237
+ if (egg) {
226
238
  console.log(chalk.yellow(` ${egg.emoji} ${egg.text}`));
227
239
  }
228
240
  console.log();
package/dist/index.js CHANGED
@@ -18,6 +18,7 @@ import { registerCtf4aiDemoCommand } from './commands/ctf4ai-demo.js';
18
18
  import { getConfig, saveConfig } from './lib/config.js';
19
19
  import { startRepl } from './repl.js';
20
20
  import { setTerminalTheme } from './lib/theme.js';
21
+ import { checkForUpdates } from './lib/update-check.js';
21
22
  import { readFileSync } from 'node:fs';
22
23
  import { fileURLToPath } from 'node:url';
23
24
  import { dirname, join } from 'node:path';
@@ -73,6 +74,7 @@ program
73
74
  .action((opts) => {
74
75
  // Force hacker theme: black background + green text
75
76
  setTerminalTheme();
77
+ checkForUpdates();
76
78
  console.log(BANNER);
77
79
  // If running interactively (no extra args or --resume), start REPL
78
80
  if (process.argv.length <= 2 || opts.resume) {
@@ -6,7 +6,7 @@ export declare function generateHint(level: HintLevel, question: string, context
6
6
  }>;
7
7
  export declare function translateText(text: string, targetLang: string): Promise<string>;
8
8
  export declare function setApiKey(key: string): void;
9
- export declare function createChatSession(context?: ChallengeContext): Promise<{
9
+ export declare function createChatSession(context?: ChallengeContext, customSystemPrompt?: string): Promise<{
10
10
  sendMessage: (msg: string) => Promise<{
11
11
  text: string;
12
12
  tokensUsed: number;
@@ -41,16 +41,12 @@ function buildSystemPrompt(level, context) {
41
41
  export function filterFlagPatterns(text) {
42
42
  return text.replace(/icoa\{[^}]*\}/gi, '[FLAG REDACTED]');
43
43
  }
44
- // Default shared API key for competition (free tier)
45
- const DEFAULT_API_KEY = 'AIzaSyBLjo2UjaFqWmFaCap0TpiXjo9cDqFmY-E';
44
+ // No DEFAULT_API_KEY in client code keys stay server-side only.
45
+ // For demo mode (no user key), createChatSession routes through the
46
+ // server proxy at /api/icoa/ai/chat. For users with their own key
47
+ // (via setup or env var), we go direct to the Gemini SDK.
46
48
  function getApiKey() {
47
- const envKey = process.env.GEMINI_API_KEY;
48
- if (envKey)
49
- return envKey;
50
- const config = getConfig();
51
- if (config.geminiApiKey)
52
- return config.geminiApiKey;
53
- return DEFAULT_API_KEY;
49
+ return process.env.GEMINI_API_KEY || getConfig().geminiApiKey || '';
54
50
  }
55
51
  function getClient(apiKey) {
56
52
  return new GoogleGenAI({ apiKey });
@@ -141,47 +137,59 @@ RULES:
141
137
  - If you don't know something, say so honestly
142
138
  - Keep responses concise unless the user asks for detail
143
139
  - When the user opens a challenge, use the context to give relevant advice`;
144
- export async function createChatSession(context) {
145
- let apiKey = getApiKey();
146
- if (!apiKey) {
147
- try {
148
- const { input } = await import('@inquirer/prompts');
149
- console.log();
150
- console.log(chalk.yellow(' Gemini API key not configured.'));
151
- console.log(chalk.gray(' Get one free at: ') + chalk.cyan('https://aistudio.google.com/apikey'));
152
- console.log();
153
- apiKey = await input({ message: 'Enter your Gemini API Key:' });
154
- if (apiKey.trim()) {
155
- apiKey = apiKey.trim();
156
- saveConfig({ geminiApiKey: apiKey });
157
- console.log(chalk.green(' Key saved for future use.'));
158
- console.log();
159
- }
160
- else {
161
- throw new Error('No API key provided.');
162
- }
163
- }
164
- catch {
165
- throw new Error('Gemini API key not configured. Run: setup');
166
- }
167
- }
140
+ export async function createChatSession(context, customSystemPrompt) {
168
141
  const config = getConfig();
169
- const modelName = config.geminiModel || 'gemma-4-31b-it';
170
- const ai = getClient(apiKey);
171
- let systemPrompt = CHAT_SYSTEM_PROMPT;
142
+ const apiKey = getApiKey();
143
+ let systemPrompt = customSystemPrompt || CHAT_SYSTEM_PROMPT;
172
144
  if (context) {
173
145
  systemPrompt += `\n\nThe competitor is currently working on:\nChallenge: ${context.name}\nCategory: ${context.category}`;
174
146
  }
175
- const chat = ai.chats.create({
176
- model: modelName,
177
- config: { systemInstruction: systemPrompt },
178
- });
147
+ // ─── Path A: user has their own key → direct Gemini SDK ───
148
+ if (apiKey) {
149
+ const modelName = config.geminiModel || 'gemini-2.5-flash';
150
+ const ai = getClient(apiKey);
151
+ const chat = ai.chats.create({
152
+ model: modelName,
153
+ config: { systemInstruction: systemPrompt },
154
+ });
155
+ return {
156
+ async sendMessage(msg) {
157
+ const response = await chat.sendMessage({ message: msg });
158
+ const text = filterFlagPatterns(response.text ?? '');
159
+ const usage = response.usageMetadata;
160
+ const tokensUsed = usage?.totalTokenCount || ((usage?.promptTokenCount || 0) + (usage?.candidatesTokenCount || 0));
161
+ return { text, tokensUsed };
162
+ },
163
+ };
164
+ }
165
+ // ─── Path B: no key → server proxy (key stays server-side) ───
166
+ const serverUrl = config.ctfdUrl || 'https://practice.icoa2026.au';
167
+ const fp = config.deviceFingerprint || '';
168
+ const modelName = config.geminiModel || 'gemini-2.5-flash';
169
+ const messages = [];
179
170
  return {
180
171
  async sendMessage(msg) {
181
- const response = await chat.sendMessage({ message: msg });
182
- const text = filterFlagPatterns(response.text ?? '');
183
- const usage = response.usageMetadata;
184
- const tokensUsed = usage?.totalTokenCount || ((usage?.promptTokenCount || 0) + (usage?.candidatesTokenCount || 0));
172
+ messages.push({ role: 'user', text: msg });
173
+ const res = await fetch(`${serverUrl}/api/icoa/ai/chat`, {
174
+ method: 'POST',
175
+ headers: { 'Content-Type': 'application/json' },
176
+ body: JSON.stringify({
177
+ systemPrompt,
178
+ messages,
179
+ model: modelName,
180
+ maxTokens: 2048,
181
+ deviceFingerprint: fp,
182
+ }),
183
+ signal: AbortSignal.timeout(60_000),
184
+ });
185
+ if (!res.ok) {
186
+ const err = await res.json().catch(() => ({ message: 'AI proxy error' }));
187
+ throw new Error(err.message || `AI proxy returned ${res.status}`);
188
+ }
189
+ const json = await res.json();
190
+ const text = filterFlagPatterns(json.data?.text || '');
191
+ const tokensUsed = json.data?.tokensUsed || 0;
192
+ messages.push({ role: 'model', text });
185
193
  return { text, tokensUsed };
186
194
  },
187
195
  };
@@ -0,0 +1 @@
1
+ export declare function checkForUpdates(): void;
@@ -0,0 +1,77 @@
1
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
2
+ import { join, dirname } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import chalk from 'chalk';
5
+ import { getIcoaDir } from './config.js';
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+ const SIX_HOURS_MS = 6 * 60 * 60 * 1000;
8
+ function isNewer(latest, current) {
9
+ const l = latest.split('.').map(Number);
10
+ const c = current.split('.').map(Number);
11
+ for (let i = 0; i < Math.max(l.length, c.length); i++) {
12
+ const lv = l[i] ?? 0;
13
+ const cv = c[i] ?? 0;
14
+ if (lv > cv)
15
+ return true;
16
+ if (lv < cv)
17
+ return false;
18
+ }
19
+ return false;
20
+ }
21
+ export function checkForUpdates() {
22
+ // Fire-and-forget async check
23
+ setTimeout(async () => {
24
+ try {
25
+ const pkgPath = join(__dirname, '..', '..', 'package.json');
26
+ const current = JSON.parse(readFileSync(pkgPath, 'utf-8')).version;
27
+ const cacheFile = join(getIcoaDir(), 'update-check.json');
28
+ // Check if we already checked recently
29
+ if (existsSync(cacheFile)) {
30
+ try {
31
+ const cache = JSON.parse(readFileSync(cacheFile, 'utf-8'));
32
+ if (Date.now() - cache.lastCheck < SIX_HOURS_MS) {
33
+ // Still within cooldown — but show banner if cached version is newer
34
+ if (cache.latestVersion && isNewer(cache.latestVersion, current)) {
35
+ console.log(chalk.yellow(' \u2B06 Update available: ') +
36
+ chalk.white('v' + current + ' \u2192 v' + cache.latestVersion) +
37
+ chalk.gray(' Run: npm install -g icoa-cli@latest'));
38
+ }
39
+ return;
40
+ }
41
+ }
42
+ catch {
43
+ // Corrupted cache, continue with fresh check
44
+ }
45
+ }
46
+ // Fetch latest version from npm with 5-second timeout
47
+ const controller = new AbortController();
48
+ const timeout = setTimeout(() => controller.abort(), 5000);
49
+ const res = await fetch('https://registry.npmjs.org/icoa-cli/latest', {
50
+ signal: controller.signal,
51
+ headers: { 'Accept': 'application/json' },
52
+ });
53
+ clearTimeout(timeout);
54
+ if (!res.ok)
55
+ return;
56
+ const data = await res.json();
57
+ const latest = data.version;
58
+ if (!latest)
59
+ return;
60
+ // Save cache
61
+ const cache = {
62
+ lastCheck: Date.now(),
63
+ latestVersion: latest,
64
+ };
65
+ writeFileSync(cacheFile, JSON.stringify(cache, null, 2));
66
+ // Print banner if newer
67
+ if (isNewer(latest, current)) {
68
+ console.log(chalk.yellow(' \u2B06 Update available: ') +
69
+ chalk.white('v' + current + ' \u2192 v' + latest) +
70
+ chalk.gray(' Run: npm install -g icoa-cli@latest'));
71
+ }
72
+ }
73
+ catch {
74
+ // Completely silent on any failure
75
+ }
76
+ }, 0);
77
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "icoa-cli",
3
- "version": "2.19.36",
3
+ "version": "2.19.38",
4
4
  "description": "ICOA CLI — The world's first CLI-native CTF competition terminal",
5
5
  "type": "module",
6
6
  "bin": {