#!/usr/bin/env node 'use strict'; // Quick Start Quiz — the proctor. Open source and short enough to read end to end. // // What it sends to the quiz app: counts, dates, version strings, config NAMES (hook event names, // settings keys, frontmatter field names, tool names in matchers) and sha256 fingerprints of // normalized text. It never sends file contents, file names (only fingerprints), emails, keys, or // paths. The only "path" it sends is a fingerprint of the memory folder's name. // // Commands: // node proctor.js start take today's quiz: prints the questions and the quiz id // node proctor.js answer answers as JSON on stdin, like {"Q1": "my answer"} // node proctor.js facts print exactly what would be sent // node proctor.js custom add|list|remove // node proctor.js key [new] show your score page, or make a new key // // Node 18 or newer, no dependencies. CommonJS on purpose so it runs anywhere node does. const fs = require('fs'); const os = require('os'); const path = require('path'); const crypto = require('crypto'); const { execFileSync, execSync } = require('child_process'); const HOME = os.homedir(); const CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR || path.join(HOME, '.claude'); const STATE_DIR = process.env.QUICKSTART_DIR || path.join(CONFIG_DIR, 'quickstart'); const MAX_STRING = 300; // no string in the payload is ever longer than this const CUSTOM_VALUE_CAP = 100; // ---------- normalization — identical to the grader's, by contract ---------- function norm(s) { return String(s == null ? '' : s) .normalize('NFKC') .toLowerCase() .replace(/[^\p{L}\p{N}\s./:-]/gu, ' ') .replace(/\s+/g, ' ') .trim(); } function first8(s) { return norm(s).split(' ').slice(0, 8).join(' '); } function sha256Hex(s) { return crypto.createHash('sha256').update(String(s == null ? '' : s), 'utf8').digest('hex'); } // ---------- small file helpers ---------- function isFile(p) { try { return fs.statSync(p).isFile(); } catch { return false; } } function isDir(p) { try { return fs.statSync(p).isDirectory(); } catch { return false; } } function readText(p) { return fs.readFileSync(p, 'utf8'); } // Counts lines the way `wc -l` does: one per newline character. function countLines(text) { let n = 0; for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === 10) n++; return n; } // Hidden entries (a .git folder, a .gitignore) are never skills, notes or tasks. function listDirs(dir) { if (!isDir(dir)) return []; return fs.readdirSync(dir).filter((name) => !name.startsWith('.') && isDir(path.join(dir, name))).sort(); } // YAML-ish frontmatter: only when line 1 is exactly "---". Top-level keys only; a block scalar // (">" or "|") gathers its indented lines; nested maps are left alone. Returns null when absent. function parseFrontmatter(text) { const lines = String(text).split(/\r?\n/); if (lines[0] !== '---') return null; const fields = {}; const keys = []; let lastKey = null; let inBlock = false; let closed = false; let i = 1; for (; i < lines.length; i++) { const line = lines[i]; if (line === '---' || line === '...') { closed = true; break; } const m = /^([A-Za-z_][A-Za-z0-9_-]*)\s*:(.*)$/.exec(line); if (m) { lastKey = m[1]; const v = m[2].trim(); inBlock = /^[|>][+-]?$/.test(v); fields[lastKey] = inBlock ? '' : v; if (!keys.includes(lastKey)) keys.push(lastKey); continue; } if (inBlock && lastKey && /^\s+\S/.test(line)) { fields[lastKey] = (fields[lastKey] ? fields[lastKey] + ' ' : '') + line.trim(); } } if (!closed) return null; return { fields, keys, raw: lines.slice(1, i).join('\n') }; } function unquote(v) { v = String(v == null ? '' : v).trim(); if (v.length >= 2) { if (v[0] === '"' && v[v.length - 1] === '"') return v.slice(1, -1).replace(/\\"/g, '"'); if (v[0] === "'" && v[v.length - 1] === "'") return v.slice(1, -1).replace(/''/g, "'"); } return v; } function descriptionOf(fm) { if (!fm || fm.fields.description === undefined) return null; return unquote(fm.fields.description); } function firstNonEmptyLine(text) { return String(text).split(/\r?\n/).find((l) => l.trim() !== '') || ''; } // Anthropic's project-folder rule: the folder path with every character that is not a letter or // digit turned into "-". function projectFolderName(p) { return String(p).replace(/[^A-Za-z0-9]/g, '-'); } // ---------- the facts ---------- function getClaudeVersion() { const candidates = process.platform === 'win32' ? ['claude.cmd', 'claude'] : ['claude', path.join(HOME, '.local', 'bin', 'claude'), '/usr/local/bin/claude']; for (const c of candidates) { try { const out = execFileSync(c, ['--version'], { encoding: 'utf8', timeout: 8000, stdio: ['ignore', 'pipe', 'ignore'] }); const m = /(\d+\.\d+\.\d+)/.exec(out); if (m) return m[1]; } catch { /* try the next one */ } } return 'unknown'; } function readClaudeMd(configDir) { const out = { exists: false, lines: 0, bytes: 0, first_line_sha: null, over_target: null }; const file = path.join(configDir, 'CLAUDE.md'); if (!isFile(file)) return out; const text = readText(file); out.exists = true; out.lines = countLines(text); out.bytes = Buffer.byteLength(text, 'utf8'); out.first_line_sha = sha256Hex(first8(firstNonEmptyLine(text))); return out; } function resolveMemoryDir({ configDir, cwd, homeDir }) { const candidates = [ { name: projectFolderName(cwd), why: "this project's own memory folder" }, { name: projectFolderName(homeDir), why: "your home folder's memory (this project has none of its own)" }, ]; for (const c of candidates) { const dir = path.join(configDir, 'projects', c.name, 'memory'); if (isDir(dir)) return { found: true, dir, name: c.name, why: c.why }; } return { found: false, dir: null, name: candidates[0].name, why: 'none found, for this project or your home folder' }; } function readMemory(ctx) { const chosen = resolveMemoryDir(ctx); const out = { dir: sha256Hex(norm(chosen.name)), index_exists: false, index_lines: 0, index_bytes: 0, index_bullets: 0, files: 0, newest: null, }; if (!chosen.found) return { memory: out, chosen }; const index = path.join(chosen.dir, 'MEMORY.md'); if (isFile(index)) { const text = readText(index); out.index_exists = true; out.index_lines = countLines(text); out.index_bytes = Buffer.byteLength(text, 'utf8'); out.index_bullets = text.split(/\r?\n/).filter((l) => l.startsWith('- ')).length; } const files = fs.readdirSync(chosen.dir) .filter((f) => !f.startsWith('.') && f.endsWith('.md') && f !== 'MEMORY.md' && isFile(path.join(chosen.dir, f))) .sort(); out.files = files.length; let best = null; for (const f of files) { const full = path.join(chosen.dir, f); const text = readText(full); const fm = parseFrontmatter(text); let modified = null; const m = fm && /^\s*modified:\s*["']?([^\s"']+)/m.exec(fm.raw); if (m && !Number.isNaN(Date.parse(m[1]))) modified = new Date(Date.parse(m[1])).toISOString(); else modified = fs.statSync(full).mtime.toISOString(); if (!best || modified > best.modified) { const desc = descriptionOf(fm); best = { f, modified, desc: desc === null ? firstNonEmptyLine(text) : desc }; } } if (best) { out.newest = { name_sha: sha256Hex(norm(best.f)), desc_sha: sha256Hex(first8(best.desc)), modified: best.modified, }; } return { memory: out, chosen }; } function readSettings(configDir) { const out = { exists: false, hook_events: [], hook_counts: {}, matcher_tokens: [], top_keys: [] }; const warnings = []; const file = path.join(configDir, 'settings.json'); if (!isFile(file)) return { settings: out, warnings }; out.exists = true; let s; try { s = JSON.parse(readText(file)); } catch { warnings.push('Your settings file is not valid JSON, so its hooks and keys were not read.'); return { settings: out, warnings }; } if (!s || typeof s !== 'object' || Array.isArray(s)) return { settings: out, warnings }; out.top_keys = Object.keys(s); const hooks = s.hooks && typeof s.hooks === 'object' && !Array.isArray(s.hooks) ? s.hooks : {}; out.hook_events = Object.keys(hooks); for (const event of out.hook_events) { const groups = Array.isArray(hooks[event]) ? hooks[event] : []; out.hook_counts[event] = groups.reduce((n, g) => n + (g && Array.isArray(g.hooks) ? g.hooks.length : 0), 0); if (event !== 'PreToolUse' && event !== 'PostToolUse') continue; for (const g of groups) { if (!g || typeof g.matcher !== 'string') continue; for (const piece of g.matcher.split(/[|,]/)) { const token = piece.trim(); if (!token || token.includes('.*')) continue; if (!out.matcher_tokens.includes(token)) out.matcher_tokens.push(token); } } } return { settings: out, warnings }; } function readSkills(configDir) { const out = { folders: 0, with_file: 0, fields: [], longest_description_chars: 0 }; const dir = path.join(configDir, 'skills'); const folders = listDirs(dir); out.folders = folders.length; const fields = new Set(); for (const name of folders) { const file = path.join(dir, name, 'SKILL.md'); if (!isFile(file)) continue; out.with_file++; const fm = parseFrontmatter(readText(file)); if (!fm) continue; for (const k of fm.keys) fields.add(k); const desc = descriptionOf(fm); if (desc !== null && desc.length > out.longest_description_chars) out.longest_description_chars = desc.length; } out.fields = [...fields].sort(); return out; } function readTasks(configDir) { const out = { count: 0, with_name_and_description: 0 }; const dir = path.join(configDir, 'scheduled-tasks'); for (const name of listDirs(dir)) { const file = path.join(dir, name, 'SKILL.md'); if (!isFile(file)) continue; out.count++; const fm = parseFrontmatter(readText(file)); if (fm && unquote(fm.fields.name) !== '' && fm.fields.name !== undefined && (descriptionOf(fm) || '') !== '') { out.with_name_and_description++; } } return out; } function defaultRunCommand(command) { return execSync(command, { encoding: 'utf8', timeout: 10000, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }); } function loadCustomList(stateDir) { const file = path.join(stateDir, 'custom.json'); if (!isFile(file)) return []; const list = JSON.parse(readText(file)); return Array.isArray(list) ? list : []; } function saveCustomList(stateDir, list) { fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 }); fs.writeFileSync(path.join(stateDir, 'custom.json'), JSON.stringify(list, null, 2) + '\n', { mode: 0o600 }); } function readCustom(stateDir, runCommand) { const out = {}; const warnings = []; let list; try { list = loadCustomList(stateDir); } catch { warnings.push('Your custom questions file could not be read, so no custom questions were sent.'); return { custom: out, warnings }; } for (const q of list) { if (!q || typeof q.qid !== 'string' || typeof q.key_command !== 'string') continue; let output; try { output = runCommand(q.key_command); } catch { warnings.push('The command behind one of your custom questions did not run, so that question was left out this time: ' + String(q.question || q.qid)); continue; } const text = String(output == null ? '' : output).trim(); if (q.match_kind === 'text') out[q.qid] = { sha: sha256Hex(first8(text)) }; else out[q.qid] = { value: text.slice(0, CUSTOM_VALUE_CAP) }; } return { custom: out, warnings }; } function localTimeZone() { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'America/New_York'; } catch { return 'America/New_York'; } } // Safety net: no string in the payload may be longer than MAX_STRING. function clampStrings(value) { if (typeof value === 'string') return value.length > MAX_STRING ? value.slice(0, MAX_STRING) : value; if (Array.isArray(value)) return value.map(clampStrings); if (value && typeof value === 'object') { const out = {}; for (const [k, v] of Object.entries(value)) out[clampStrings(k)] = clampStrings(v); return out; } return value; } function collectFacts(opts = {}) { const configDir = opts.configDir || CONFIG_DIR; const stateDir = opts.stateDir || (opts.configDir ? path.join(opts.configDir, 'quickstart') : STATE_DIR); const cwd = opts.cwd || process.cwd(); const homeDir = opts.homeDir || HOME; const runCommand = opts.runCommand || defaultRunCommand; const claudeVersion = opts.claudeVersion !== undefined ? opts.claudeVersion : getClaudeVersion(); const warnings = []; const claude_md = readClaudeMd(configDir); const { memory, chosen } = readMemory({ configDir, cwd, homeDir }); const settingsRead = readSettings(configDir); warnings.push(...settingsRead.warnings); const skills = readSkills(configDir); const tasks = readTasks(configDir); const customRead = readCustom(stateDir, runCommand); warnings.push(...customRead.warnings); const facts = clampStrings({ v: 1, when: new Date().toISOString(), os: process.platform, tz: localTimeZone(), tz_offset_minutes: -new Date().getTimezoneOffset(), claude_version: claudeVersion, claude_md, memory, settings: settingsRead.settings, skills, tasks, custom: customRead.custom, hashes_of: 'sha256 of normalized text; normalization = lowercase, collapse whitespace, strip punctuation except . - /', }); return { facts, chosen, warnings }; } // ---------- state: base url, key, today's quiz ---------- class ProctorError extends Error { constructor(message, status) { super(message); this.status = status || 0; } } function getBaseUrl(stateDir = STATE_DIR) { let base = process.env.QUICKSTART_URL || ''; if (!base) { const file = path.join(stateDir, 'url'); if (isFile(file)) base = readText(file).trim(); } base = base.replace(/\/+$/, ''); if (!/^https?:\/\//.test(base)) throw new ProctorError('The quiz app address is not set. Run the installer first.'); return base; } function readKey(stateDir = STATE_DIR) { const file = path.join(stateDir, 'key'); if (!isFile(file)) return null; const key = readText(file).trim(); return key || null; } function writeSecret(file, value) { fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); fs.writeFileSync(file, value + '\n', { mode: 0o600 }); try { fs.chmodSync(file, 0o600); } catch { /* best effort on odd file systems */ } } function writeState(stateDir, name, value) { fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 }); fs.writeFileSync(path.join(stateDir, name), value, { mode: 0o600 }); } async function api(method, route, { key, body, stateDir = STATE_DIR } = {}) { const base = getBaseUrl(stateDir); const headers = { accept: 'application/json' }; if (body !== undefined) headers['content-type'] = 'application/json'; if (key) headers['x-quiz-key'] = key; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 30000); let res; try { res = await fetch(base + route, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal: controller.signal, }); } catch { throw new ProctorError('Could not reach the quiz app. Check your connection and try again.'); } finally { clearTimeout(timer); } const text = await res.text(); let data = null; try { data = text ? JSON.parse(text) : null; } catch { data = null; } if (!res.ok) { const said = data && typeof data.error === 'string' ? data.error : ''; if (res.status === 401 || res.status === 403) { throw new ProctorError(said || 'Your saved quiz key is not recognized by the app. Run the key command with "new" to get a fresh one.', res.status); } throw new ProctorError(said || 'The quiz app could not do that right now. Try again in a minute.', res.status); } return data; } async function createKey(stateDir = STATE_DIR) { const data = await api('POST', '/api/keys', { body: { tz: localTimeZone() }, stateDir }); if (!data || typeof data.key !== 'string' || typeof data.public_id !== 'string') { throw new ProctorError('The quiz app did not hand back a key. Try again in a minute.'); } writeSecret(path.join(stateDir, 'key'), data.key); writeState(stateDir, 'public_id', data.public_id + '\n'); return { key: data.key, public_id: data.public_id }; } async function ensureKey(stateDir = STATE_DIR) { const existing = readKey(stateDir); if (existing) return { key: existing, created: false }; const made = await createKey(stateDir); return { key: made.key, public_id: made.public_id, created: true }; } function scorePage(stateDir = STATE_DIR) { const file = path.join(stateDir, 'public_id'); if (!isFile(file)) return null; const id = readText(file).trim(); return id ? getBaseUrl(stateDir) + '/score/' + id : null; } function selfCommand() { let p = path.resolve(process.argv[1] || __filename); if (p.startsWith(HOME + path.sep)) p = '~' + p.slice(HOME.length); return 'node ' + p; } function localDay(date = new Date()) { const pad = (n) => String(n).padStart(2, '0'); return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()); } function readStdin() { try { return fs.readFileSync(0, 'utf8'); } catch { return ''; } } // ---------- commands ---------- async function cmdStart(stateDir = STATE_DIR) { const { facts, chosen, warnings } = collectFacts({ stateDir }); console.log('Memory folder: ' + chosen.why + '.'); for (const w of warnings) console.error('Note: ' + w); const { key, created, public_id } = await ensureKey(stateDir); if (created) console.log('Your quiz key is saved. Your score page: ' + getBaseUrl(stateDir) + '/score/' + public_id); const res = await api('POST', '/api/quiz/start', { key, body: facts, stateDir }); if (!res || typeof res.quiz_id !== 'string' || !Array.isArray(res.questions)) { throw new ProctorError('The quiz app did not hand back a quiz. Try again in a minute.'); } writeState(stateDir, 'today.json', JSON.stringify({ quiz_id: res.quiz_id, day: res.day || localDay(), asked_at: new Date().toISOString(), questions: res.questions, }, null, 2) + '\n'); console.log(''); console.log("Today's quiz — open each thing named, then answer from what you read:"); for (const q of res.questions) { const open = q.open ? ' (open: ' + q.open + ')' : ''; console.log(q.id + open + ' — ' + q.text); } if (res.findings_preview_count > 0) { console.log(''); console.log('Setup notes waiting: ' + res.findings_preview_count + '. You will see them with your report.'); } console.log(''); console.log('Quiz id: ' + res.quiz_id); console.log('Answer with: ' + selfCommand() + ' answer ' + res.quiz_id + ' (JSON on stdin, one entry per question id, like {"' + (res.questions[0] ? res.questions[0].id : 'Q1') + '": "your answer"})'); return res; } function printReport(report) { const score = Number(report.score); const total = Number(report.total); const practice = report.counted === false || report.practice === true; console.log('Score: ' + score + ' of ' + total + (practice ? ' (practice — this one does not count)' : '')); for (const q of Array.isArray(report.per_question) ? report.per_question : []) { console.log((q.right ? 'right' : 'wrong') + ' ' + q.id + ' — ' + (q.say || '')); } const findings = Array.isArray(report.findings) ? report.findings : []; if (findings.length) { console.log(''); console.log('Setup findings:'); for (const f of findings) { const line = typeof f === 'string' ? f : (f && f.say) ? ((f.severity ? f.severity + ': ' : '') + f.say) : JSON.stringify(f); console.log(' - ' + line); } } const changes = Array.isArray(report.changes_since_last) ? report.changes_since_last : []; if (changes.length) { console.log(''); console.log('Anthropic changed these since your last quiz:'); for (const c of changes) console.log(' - ' + (typeof c === 'string' ? c : JSON.stringify(c))); } const say = Array.isArray(report.say_to_user) ? report.say_to_user : []; console.log(''); console.log('Say to the person:'); if (say.length) for (const line of say) console.log(' ' + line); else console.log(' Today\'s quiz is done: ' + score + ' of ' + total + '.'); console.log(''); console.log('Ready: ' + (report.ready ? 'yes' : 'not yet')); } async function cmdAnswer(quizId, stateDir = STATE_DIR, input) { if (!quizId) throw new ProctorError('Tell me which quiz: ' + selfCommand() + ' answer (the id printed by start)'); const raw = input !== undefined ? input : readStdin(); let parsed; try { parsed = JSON.parse(raw); } catch { throw new ProctorError('The answers must be JSON on stdin, like {"Q1": "your answer", "Q2": "your answer"}.'); } if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { throw new ProctorError('The answers must be a JSON object with one entry per question id.'); } const source = parsed.answers && typeof parsed.answers === 'object' && !Array.isArray(parsed.answers) ? parsed.answers : parsed; const answers = {}; for (const [id, value] of Object.entries(source)) { answers[id] = typeof value === 'string' ? value : (value == null ? '' : (typeof value === 'object' ? JSON.stringify(value) : String(value))); } const key = readKey(stateDir); if (!key) throw new ProctorError('No quiz key yet. Run start first.'); const report = await api('POST', '/api/quiz/' + encodeURIComponent(quizId) + '/answers', { key, body: { answers }, stateDir }); if (!report || typeof report !== 'object') throw new ProctorError('The quiz app did not hand back a report. Try again in a minute.'); printReport(report); writeState(stateDir, 'last-taken', (typeof report.day === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(report.day) ? report.day : localDay()) + '\n'); return report; } function cmdFacts(stateDir = STATE_DIR) { const { facts, chosen, warnings } = collectFacts({ stateDir }); console.error('Memory folder: ' + chosen.why + '.'); for (const w of warnings) console.error('Note: ' + w); console.log(JSON.stringify(facts, null, 2)); return facts; } function parseFlags(args) { const out = { _: [] }; for (let i = 0; i < args.length; i++) { const a = args[i]; if (a.startsWith('--')) { const eq = a.indexOf('='); if (eq > 0) out[a.slice(2, eq)] = a.slice(eq + 1); else { out[a.slice(2)] = args[i + 1] !== undefined ? args[i + 1] : ''; i++; } } else out._.push(a); } return out; } const MATCH_KINDS = ['int', 'date', 'text', 'version']; async function cmdCustom(args, stateDir = STATE_DIR, input) { const sub = args[0]; if (sub === 'list') { const list = loadCustomList(stateDir); if (!list.length) { console.log('You have no custom questions yet. Add one with: ' + selfCommand() + ' custom add'); return list; } console.log('Your custom questions (' + list.length + '):'); for (const q of list) { console.log(' ' + q.qid + ' — ' + q.question + ' (open: ' + q.open_hint + '; answer kind: ' + q.match_kind + '; command: ' + q.key_command + ')'); } return list; } if (sub === 'add') { const flags = parseFlags(args.slice(1)); let spec = { question: flags.question, open_hint: flags.open, match_kind: flags.match, key_command: flags.command }; if (!spec.question && !spec.key_command) { const raw = input !== undefined ? input : readStdin(); try { spec = Object.assign(spec, JSON.parse(raw)); } catch { throw new ProctorError('To add a custom question, give JSON on stdin like {"question": "...", "open_hint": "...", "match_kind": "int", "key_command": "..."} or use --question --open --match --command.'); } } for (const field of ['question', 'open_hint', 'match_kind', 'key_command']) { if (typeof spec[field] !== 'string' || !spec[field].trim()) throw new ProctorError('A custom question needs a question, what to open, an answer kind (int, date, text or version) and the command that computes the real answer.'); } if (!MATCH_KINDS.includes(spec.match_kind)) throw new ProctorError('The answer kind must be one of: int, date, text, version.'); let probe; try { probe = String(defaultRunCommand(spec.key_command)).trim(); } catch { throw new ProctorError('The command behind that question did not run on this machine, so it was not added. Try the command by hand first.'); } if (!probe) throw new ProctorError('The command behind that question printed nothing, so it was not added. It must print the real answer.'); const key = readKey(stateDir); if (!key) throw new ProctorError('No quiz key yet. Run start first.'); const res = await api('POST', '/api/custom', { key, body: { question: spec.question, open_hint: spec.open_hint, match_kind: spec.match_kind }, stateDir }); if (!res || typeof res.qid !== 'string') throw new ProctorError('The quiz app did not accept the question. Try again in a minute.'); const list = loadCustomList(stateDir).filter((q) => q && q.qid !== res.qid); list.push({ qid: res.qid, question: spec.question, open_hint: spec.open_hint, match_kind: spec.match_kind, key_command: spec.key_command }); saveCustomList(stateDir, list); console.log('Added. It will rotate into your quiz. Its id: ' + res.qid); return res; } if (sub === 'remove') { const qid = args[1]; if (!qid) throw new ProctorError('Tell me which one: ' + selfCommand() + ' custom remove (ids are shown by custom list)'); const key = readKey(stateDir); if (!key) throw new ProctorError('No quiz key yet. Run start first.'); await api('DELETE', '/api/custom/' + encodeURIComponent(qid), { key, stateDir }); const list = loadCustomList(stateDir); const kept = list.filter((q) => !q || q.qid !== qid); saveCustomList(stateDir, kept); console.log(kept.length === list.length ? 'The app no longer has it; nothing by that id was saved here either.' : 'Removed.'); return kept; } throw new ProctorError('Custom questions: ' + selfCommand() + ' custom add | list | remove '); } async function cmdKey(args, stateDir = STATE_DIR) { if (args[0] === 'new') { const made = await createKey(stateDir); console.log('Your new quiz key is saved. Your score page: ' + getBaseUrl(stateDir) + '/score/' + made.public_id); return made; } const page = scorePage(stateDir); if (!page) { console.log('No quiz key yet. Run start first.'); return null; } console.log('Your score page: ' + page); return page; } function usage() { const me = selfCommand(); return [ 'Quick Start Quiz — the proctor.', ' ' + me + ' start take today\'s quiz (prints the questions and the quiz id)', ' ' + me + ' answer answers as JSON on stdin, like {"Q1": "your answer"}', ' ' + me + ' facts print exactly what is sent to the app (nothing else ever is)', ' ' + me + ' custom add|list|remove ', ' ' + me + ' key [new] show your score page, or make a new key', ].join('\n'); } async function main(argv) { const [cmd, ...rest] = argv; switch (cmd) { case 'start': return cmdStart(); case 'answer': return cmdAnswer(rest[0]); case 'facts': return cmdFacts(); case 'custom': return cmdCustom(rest); case 'key': return cmdKey(rest); case undefined: case 'help': case '--help': case '-h': console.log(usage()); return null; default: throw new ProctorError('I do not know "' + cmd + '".\n' + usage()); } } if (require.main === module) { main(process.argv.slice(2)).then(() => { process.exitCode = 0; }).catch((err) => { if (err instanceof ProctorError) console.error(err.message); else console.error('Something went wrong in the proctor: ' + (err && err.message ? err.message : String(err))); process.exitCode = 1; }); } module.exports = { norm, first8, sha256Hex, countLines, parseFrontmatter, unquote, descriptionOf, projectFolderName, getClaudeVersion, readClaudeMd, resolveMemoryDir, readMemory, readSettings, readSkills, readTasks, readCustom, collectFacts, clampStrings, getBaseUrl, readKey, ensureKey, createKey, scorePage, localDay, cmdStart, cmdAnswer, cmdFacts, cmdCustom, cmdKey, printReport, parseFlags, ProctorError, MAX_STRING, CUSTOM_VALUE_CAP, MATCH_KINDS, };