skillgauntlet 1.1.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -201
- package/README.md +44 -1
- package/bin/skillgauntlet.js +380 -131
- package/lib/automerge.js +107 -0
- package/lib/browse.js +58 -0
- package/lib/chart.js +160 -0
- package/lib/depth.js +4 -1
- package/lib/discover.js +27 -2
- package/lib/findhost.js +83 -0
- package/lib/i18n.js +405 -39
- package/lib/models.js +46 -0
- package/lib/report.js +1 -1
- package/lib/schema.js +90 -0
- package/lib/scrub.js +186 -0
- package/lib/static.js +24 -13
- package/lib/submission.js +325 -0
- package/lib/submit.js +208 -0
- package/lib/ticker.js +39 -0
- package/lib/trial.js +65 -13
- package/lib/ui.js +40 -2
- package/lib/update.js +73 -0
- package/package.json +4 -2
- package/schemas/submission-1.json +210 -0
package/bin/skillgauntlet.js
CHANGED
|
@@ -6,17 +6,26 @@ import fs from 'node:fs';
|
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { analyze } from '../lib/static.js';
|
|
8
8
|
import { staticMarkdown, trialSection, gainSection, probeSection, scorecard } from '../lib/report.js';
|
|
9
|
-
import { findSkills, resolveSpec } from '../lib/discover.js';
|
|
9
|
+
import { findSkills, resolveSpec, skillNameOf } from '../lib/discover.js';
|
|
10
10
|
import { installedSkills } from '../lib/installed.js';
|
|
11
11
|
import { probeActivation } from '../lib/codex.js';
|
|
12
12
|
import { fingerprint, cacheFileFor, readCachedQueries, writeCachedQueries } from '../lib/queries.js';
|
|
13
|
-
import { openRun, querySetHash } from '../lib/runs.js';
|
|
14
|
-
import { DEPTHS,
|
|
15
|
-
import { ask, choose, select, confirm, say, closeUi } from '../lib/ui.js';
|
|
13
|
+
import { openRun, querySetHash, readRun, validRecords } from '../lib/runs.js';
|
|
14
|
+
import { DEPTHS, minutesFor } from '../lib/depth.js';
|
|
15
|
+
import { ask, choose, select, confirm, say, closeUi, hasTerminal, setBack, Back } from '../lib/ui.js';
|
|
16
|
+
import { hostModels, unknownModel } from '../lib/models.js';
|
|
17
|
+
import { browseDir } from '../lib/browse.js';
|
|
18
|
+
import { withTicker } from '../lib/ticker.js';
|
|
19
|
+
import { renderResult, bar } from '../lib/chart.js';
|
|
16
20
|
import { setLang, detectLang, errorText, t } from '../lib/i18n.js';
|
|
17
|
-
import { resolveBin, backend, backendFor,
|
|
21
|
+
import { resolveBin, backend, backendFor, usesOwnDeepseek, setNativeModels, HOSTS, checkModel, draftQueries, draftQuality, runTrial, requireHost, pingHost, setHostBin, binInstalled } from '../lib/trial.js';
|
|
22
|
+
import { HOST_CLI, searchHost, installCommand, installHost } from '../lib/findhost.js';
|
|
18
23
|
import { runGain } from '../lib/gain.js';
|
|
19
24
|
import { ERROR_STREAK_STOP, executeTrials, finishRun, resumeRun } from '../lib/execute.js';
|
|
25
|
+
import { cmdSubmit } from '../lib/submit.js';
|
|
26
|
+
import { gitFacts } from '../lib/submission.js';
|
|
27
|
+
import { checkForUpdate, declineUpdate, currentVersion, isCheckout } from '../lib/update.js';
|
|
28
|
+
import { spawnSync as runSync, execSync } from 'node:child_process';
|
|
20
29
|
|
|
21
30
|
function relLabel(root, dir) {
|
|
22
31
|
const rel = path.relative(root, dir).replace(/\\/g, '/');
|
|
@@ -39,9 +48,102 @@ function writeOut(skillDir, name, md, sc) {
|
|
|
39
48
|
return out;
|
|
40
49
|
}
|
|
41
50
|
|
|
51
|
+
// The host CLI is not on PATH. Look for it elsewhere on the machine, offer to install it where an installer
|
|
52
|
+
// exists, and otherwise let the user pick another host. True when the host can now be used.
|
|
53
|
+
async function fixMissingHost(hostId, err) {
|
|
54
|
+
const { bin } = HOST_CLI[hostId];
|
|
55
|
+
const label = err.vars?.host || hostId;
|
|
56
|
+
say(`\n${t('hostNotOnPath', { host: label })}`);
|
|
57
|
+
const options = withTicker(waitText(t('waitSearch', { host: label }), { slowAfter: 600 }), () => searchHost(hostId)).slice(0, 3).map((f) => ({ label: t('hostUseFound', { path: f }), value: { use: f } }));
|
|
58
|
+
const cmd = installCommand(hostId);
|
|
59
|
+
if (cmd) options.push({ label: t('hostInstallNow', { cmd }), value: { install: true } });
|
|
60
|
+
else say(t(err.howKey));
|
|
61
|
+
options.push({ label: t('hostOther'), value: { other: true } });
|
|
62
|
+
|
|
63
|
+
const pick = await select(t('hostFixQ'), options, 0);
|
|
64
|
+
if (pick.use) {
|
|
65
|
+
setHostBin(bin, pick.use);
|
|
66
|
+
say(` ${pick.use}`);
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
if (pick.install) {
|
|
70
|
+
say(`\n${t('hostInstalling', { host: label })}`);
|
|
71
|
+
try { installHost(hostId); } catch (e) {
|
|
72
|
+
say(t('hostInstallFailed', { why: e.message }));
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
if (binInstalled(bin)) return true;
|
|
76
|
+
const again = searchHost(hostId)[0]; // npm's folder may not be on this process's PATH yet
|
|
77
|
+
if (again) { setHostBin(bin, again); return true; }
|
|
78
|
+
say(t('hostStillMissing', { host: label }));
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let approvedFollowing = false; // the user said not to ask again for the remaining skills
|
|
84
|
+
|
|
85
|
+
async function approveTrials({ q, runs, n }) {
|
|
86
|
+
if (approvedFollowing) return true;
|
|
87
|
+
const pick = await choose(t('trialsQ', { q, runs, n, minutes: minutesFor(n) }), [
|
|
88
|
+
{ label: t('trialsGo'), value: 'go' },
|
|
89
|
+
{ label: t('trialsAll'), value: 'all' },
|
|
90
|
+
{ label: t('trialsSkip'), value: 'skip' },
|
|
91
|
+
], 1);
|
|
92
|
+
if (pick === 'all') approvedFollowing = true;
|
|
93
|
+
return pick !== 'skip';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const hostChecked = new Set();
|
|
97
|
+
const finishedRuns = []; // run directories of complete runs, offered for contribution at the end
|
|
98
|
+
const remoteSource = new Map(); // skill directory -> the public repository, commit and subfolder it was cloned from
|
|
99
|
+
|
|
100
|
+
// The arguments `submit` needs for a run. A skill cloned from GitHub in this session already has its repository,
|
|
101
|
+
// commit and subfolder, so the user is not asked for them; other skills are looked up from their own git checkout.
|
|
102
|
+
function submitArgs(runDir) {
|
|
103
|
+
const skillDir = path.resolve(runDir, '..', '..', '..');
|
|
104
|
+
const src = remoteSource.get(skillDir);
|
|
105
|
+
return src ? [runDir, '--repo', src.repo, '--commit', src.commit, ...(src.subdir ? ['--subdir', src.subdir] : [])] : [runDir];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Whether the public repository and commit of a run's skill can be told at all.
|
|
109
|
+
function canSubmit(runDir) {
|
|
110
|
+
const skillDir = path.resolve(runDir, '..', '..', '..');
|
|
111
|
+
if (remoteSource.has(skillDir)) return true;
|
|
112
|
+
const f = gitFacts(skillDir);
|
|
113
|
+
return Boolean(f.repo && f.commit);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const quoteArg = (a) => (/[\s"]/.test(a) ? `"${a}"` : a);
|
|
117
|
+
const waitText = (label, extra = {}) => ({ label, elapsed: t('waitElapsed'), abortHint: t('waitAbort'), ...extra });
|
|
118
|
+
|
|
119
|
+
// Asking a host CLI for its models takes a few seconds (CodeBuddy about three), so it shows a wait line and is
|
|
120
|
+
// remembered for the rest of the run.
|
|
121
|
+
const modelCache = new Map();
|
|
122
|
+
|
|
123
|
+
// A typed model the host does not list: say so now, while nothing has been spent, and ask again.
|
|
124
|
+
function notListed(model, known, label) {
|
|
125
|
+
const list = unknownModel(model, known);
|
|
126
|
+
if (!list) return false;
|
|
127
|
+
say(`\n${t('modelUnknown', { model, host: label, list: list.slice(0, 8).join(', ') })}`);
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
function modelsOf(host, label) {
|
|
131
|
+
if (!modelCache.has(host)) {
|
|
132
|
+
const list = withTicker(waitText(t('waitModels', { host: label }), { slowAfter: 600 }), () => hostModels(host, resolveBin(host)));
|
|
133
|
+
setNativeModels(host, list);
|
|
134
|
+
modelCache.set(host, list);
|
|
135
|
+
}
|
|
136
|
+
return modelCache.get(host);
|
|
137
|
+
}
|
|
138
|
+
const HOST_LABEL = { 'claude-code': 'Claude Code', codebuddy: 'CodeBuddy', codewhale: 'Codewhale' };
|
|
139
|
+
|
|
42
140
|
async function runTriggerLayer({ skillDir, skillName, depth, model, host = 'claude-code' }) {
|
|
43
141
|
requireHost(host);
|
|
44
142
|
const bin = resolveBin(HOSTS[host].bin);
|
|
143
|
+
if (!hostChecked.has(host)) {
|
|
144
|
+
withTicker(waitText(t('hostPing', { host: HOST_LABEL[host] || host })), () => pingHost(host, { bin, model }));
|
|
145
|
+
hostChecked.add(host);
|
|
146
|
+
}
|
|
45
147
|
const cacheFile = cacheFileFor(skillDir, depth);
|
|
46
148
|
const want = { perClause: depth.perClause, negatives: depth.negatives, skillFingerprint: fingerprint(skillDir) };
|
|
47
149
|
|
|
@@ -50,8 +152,7 @@ async function runTriggerLayer({ skillDir, skillName, depth, model, host = 'clau
|
|
|
50
152
|
if (qs) {
|
|
51
153
|
say(` ${t('queriesReused', { n: qs.queries.length, file: path.basename(cacheFile) })}`);
|
|
52
154
|
} else {
|
|
53
|
-
|
|
54
|
-
qs = draftQueries({ bin, skillDir, skillName, model, perClause: depth.perClause, negatives: depth.negatives, host });
|
|
155
|
+
qs = withTicker(waitText(t('drafting')), () => draftQueries({ bin, skillDir, skillName, model, perClause: depth.perClause, negatives: depth.negatives, host }));
|
|
55
156
|
say(` ${t('drafted', { q: qs.queries.length, c: qs.clauses.length })}`);
|
|
56
157
|
writeCachedQueries(cacheFile, {
|
|
57
158
|
schema: 'skillgauntlet/queries/1',
|
|
@@ -74,6 +175,12 @@ async function runTriggerLayer({ skillDir, skillName, depth, model, host = 'clau
|
|
|
74
175
|
const trials = [];
|
|
75
176
|
for (const q of qs.queries) for (let i = 0; i < depth.runs; i += 1) trials.push({ q, i });
|
|
76
177
|
|
|
178
|
+
// The number of trials is only known now that the questions exist, so this is where the user agrees to it.
|
|
179
|
+
if (!(await approveTrials({ q: qs.queries.length, runs: depth.runs, n: trials.length }))) {
|
|
180
|
+
say(` ${t('trialsDeclined')}`);
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
77
184
|
// Opened before the first trial: an interrupted run keeps its conditions and every finished record,
|
|
78
185
|
// which is what makes a later recompute or comparison possible without paying again.
|
|
79
186
|
const run = openRun({
|
|
@@ -109,7 +216,8 @@ async function runTriggerLayer({ skillDir, skillName, depth, model, host = 'clau
|
|
|
109
216
|
say(` ${t('runSaved', { path: run.dir })}`);
|
|
110
217
|
if (outcome.stopped) reportStopped(outcome.stopped, run.dir, model);
|
|
111
218
|
else if (result?.incomplete) reportIncomplete(result.incomplete, run.dir, model);
|
|
112
|
-
|
|
219
|
+
else finishedRuns.push(run.dir);
|
|
220
|
+
return result && { ...result, runDir: run.dir };
|
|
113
221
|
} catch (err) {
|
|
114
222
|
run.close(null, { interrupted: true });
|
|
115
223
|
throw err;
|
|
@@ -144,6 +252,11 @@ function archiveProbe({ skillDir, skillName, model, probe }) {
|
|
|
144
252
|
|
|
145
253
|
function progress() {
|
|
146
254
|
return ({ row, done, total }) => {
|
|
255
|
+
// On a terminal one line is redrawn in place; the verdicts of single trials belong to the result, not the wait.
|
|
256
|
+
if (process.stdout.isTTY) {
|
|
257
|
+
process.stdout.write(`\r\x1b[2K ${t('progressLine')} ${bar(done / total, 24)} ${done}/${total}${done >= total ? '\n' : ''}`);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
147
260
|
const mark = { triggered: '✓', not_triggered: '·', other_skill: '↷' }[row.verdict] || '!';
|
|
148
261
|
say(` [${done}/${total}] ${mark} ${row.queryId}#${row.run} → ${row.verdict}`);
|
|
149
262
|
};
|
|
@@ -206,6 +319,19 @@ async function pickInstalled() {
|
|
|
206
319
|
return hit ? [{ name: hit.name, dir: hit.dir, root: hit.root }] : [];
|
|
207
320
|
}
|
|
208
321
|
|
|
322
|
+
// A model for hosts that have an account default: pick it, a known name, or type one. Returns null for the default.
|
|
323
|
+
async function pickModel(known = []) {
|
|
324
|
+
if (!hasTerminal()) return (await ask(t('modelOptionalQ'), '')).trim() || null;
|
|
325
|
+
const other = '\u0000other';
|
|
326
|
+
const v = await select(t('modelQ'), [
|
|
327
|
+
{ label: t('modelDefault'), value: '' },
|
|
328
|
+
...known.map((m) => ({ label: m, value: m })),
|
|
329
|
+
{ label: t('modelOther'), value: other },
|
|
330
|
+
], 0);
|
|
331
|
+
if (v === other) return (await ask(t('modelOptionalQ'), '')).trim() || null;
|
|
332
|
+
return v || null;
|
|
333
|
+
}
|
|
334
|
+
|
|
209
335
|
async function pickRemote() {
|
|
210
336
|
for (;;) {
|
|
211
337
|
const raw = await ask(t('askRepo'));
|
|
@@ -216,30 +342,74 @@ async function pickRemote() {
|
|
|
216
342
|
}
|
|
217
343
|
let resolved;
|
|
218
344
|
try {
|
|
219
|
-
resolved = resolveSpec(parsed.repo);
|
|
345
|
+
resolved = withTicker(waitText(t('waitCloning'), { slowAfter: 600 }), () => resolveSpec(parsed.repo));
|
|
220
346
|
} catch (err) {
|
|
221
347
|
say(` ${errorText(err)}`);
|
|
222
348
|
continue;
|
|
223
349
|
}
|
|
224
350
|
const found = findSkills(resolved.dir);
|
|
351
|
+
for (const d of found) {
|
|
352
|
+
remoteSource.set(path.resolve(d), { repo: resolved.source.repo, commit: resolved.source.commit, subdir: path.relative(resolved.dir, d).replace(/\\/g, '/') });
|
|
353
|
+
}
|
|
225
354
|
if (!found.length) {
|
|
226
355
|
say(` ${t('noSkill')}`);
|
|
227
356
|
return [];
|
|
228
357
|
}
|
|
229
358
|
if (parsed.hint) {
|
|
230
|
-
const hit = found.find((d) => relLabel(resolved.dir, d) === parsed.hint || d.endsWith(`/${parsed.hint}`));
|
|
359
|
+
const hit = found.find((d) => relLabel(resolved.dir, d) === parsed.hint || d.replace(/\\/g, '/').endsWith(`/${parsed.hint}`));
|
|
231
360
|
if (hit) return [{ name: path.basename(hit), dir: hit, root: resolved.dir }];
|
|
232
361
|
say(` ${t('notFoundNamed', { name: parsed.hint, list: found.map((d) => relLabel(resolved.dir, d)).slice(0, 8).join(', ') })}`);
|
|
233
362
|
return [];
|
|
234
363
|
}
|
|
235
364
|
if (found.length === 1) return [{ name: path.basename(found[0]), dir: found[0], root: resolved.dir }];
|
|
236
|
-
say(
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
365
|
+
say(`\n${t('multiFound', { n: found.length })}`);
|
|
366
|
+
return pickAmong(found, resolved.dir);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Several skills in one place: all of them, or one picked from the full list.
|
|
371
|
+
async function pickAmong(found, root) {
|
|
372
|
+
const scope = await choose(t('scopeQ'), [
|
|
373
|
+
{ label: t('scopeAll'), hint: t('scopeAllHint', { n: found.length }), value: 'all' },
|
|
374
|
+
{ label: t('scopeOne'), hint: t('scopeOneHint'), value: 'one' },
|
|
375
|
+
], 2);
|
|
376
|
+
if (scope === 'all') return found.map((d) => ({ name: path.basename(d), dir: d, root }));
|
|
377
|
+
const dir = await select(t('pickOneQ'), found.map((d) => ({ label: relLabel(root, d), value: d })), 0);
|
|
378
|
+
return [{ name: path.basename(dir), dir, root }];
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Before the questions: is there a newer published version? The user chooses; if the update cannot be fetched or
|
|
382
|
+
// does not run, the session carries on with the version that is already running.
|
|
383
|
+
async function offerUpdate() {
|
|
384
|
+
if (!hasTerminal() || (isCheckout() && !process.env.SG_FORCE_UPDATE_CHECK)) return; // a checkout is not nagged unless asked
|
|
385
|
+
const found = await checkForUpdate();
|
|
386
|
+
if (!found) return;
|
|
387
|
+
say(`\n${t('updateAvail', found)}`);
|
|
388
|
+
const pick = await choose(t('updateQ'), [
|
|
389
|
+
{ label: t('updateNow'), value: 'now' },
|
|
390
|
+
{ label: t('updateSkip'), value: 'skip' },
|
|
391
|
+
], 1);
|
|
392
|
+
if (pick !== 'now') {
|
|
393
|
+
declineUpdate(found.latest);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
const spec = `skillgauntlet@${found.latest}`;
|
|
397
|
+
const env = { ...process.env, SG_NO_UPDATE_CHECK: '1' };
|
|
398
|
+
try {
|
|
399
|
+
// fetch it first and check it answers as the version asked for, so a failed download never costs the session
|
|
400
|
+
const said = withTicker(waitText(t('updating', found), { slowAfter: 600 }), () => execSync(`npx -y ${spec} --version`, { encoding: 'utf8', timeout: 180000, windowsHide: true, env, stdio: ['ignore', 'pipe', 'pipe'] }));
|
|
401
|
+
if (String(said).trim() !== found.latest) throw new Error(`it reported ${String(said).trim() || 'nothing'}`);
|
|
402
|
+
} catch (err) {
|
|
403
|
+
// npm's own words when it gave any ("... is not in this registry"), else the error's first line
|
|
404
|
+
const lines = String(err.stderr || err.message).split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
405
|
+
const why = (lines.find((l) => /no matching version|not in this registry|404/i.test(l)) || lines.find((l) => /error|ERR/.test(l)) || lines[0] || 'unknown')
|
|
406
|
+
.replace(/^npm error\s*(notarget\s*)?/i, '').replace(/[.。]+$/, '');
|
|
407
|
+
say(t('updateFailed', { why: why.slice(0, 160), current: found.current }));
|
|
408
|
+
return;
|
|
242
409
|
}
|
|
410
|
+
closeUi();
|
|
411
|
+
const r = runSync(`npx -y ${spec}`, process.argv.slice(2), { stdio: 'inherit', env, shell: true });
|
|
412
|
+
process.exit(r.status ?? 0);
|
|
243
413
|
}
|
|
244
414
|
|
|
245
415
|
async function interactive() {
|
|
@@ -249,135 +419,176 @@ async function interactive() {
|
|
|
249
419
|
{ label: t('langJa'), value: 'ja' },
|
|
250
420
|
], 0);
|
|
251
421
|
setLang(langChoice);
|
|
422
|
+
await offerUpdate();
|
|
252
423
|
|
|
253
424
|
say('');
|
|
254
425
|
say(t('title'));
|
|
255
426
|
say(t('rule'));
|
|
256
427
|
|
|
257
|
-
|
|
258
|
-
{ label: t('srcInstalled'), hint: t('srcInstalledHint'), value: 'installed' },
|
|
259
|
-
{ label: t('srcPath'), value: 'path' },
|
|
260
|
-
{ label: t('srcRepo'), hint: 'owner/repo', value: 'repo' },
|
|
261
|
-
], 1);
|
|
428
|
+
setBack(true);
|
|
262
429
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
const found = findSkills(root);
|
|
268
|
-
if (!found.length) say(`\n${t('noSkill')}`);
|
|
269
|
-
else if (found.length === 1) targets = [{ name: path.basename(found[0]), dir: found[0], root }];
|
|
270
|
-
else {
|
|
271
|
-
const name = await select(t('pickOneQ'), found.map((d) => ({ label: relLabel(root, d), value: d })), 0);
|
|
272
|
-
targets = [{ name: path.basename(name), dir: name, root }];
|
|
273
|
-
}
|
|
274
|
-
} else targets = await pickRemote();
|
|
275
|
-
if (!targets.length) return;
|
|
276
|
-
|
|
277
|
-
const host = await choose(t('hostQ'), [
|
|
278
|
-
{ label: t('hostClaude'), hint: t('hostClaudeHint'), value: 'claude' },
|
|
279
|
-
{ label: t('hostCodebuddy'), hint: t('hostCodebuddyHint'), value: 'codebuddy' },
|
|
280
|
-
{ label: t('hostCodewhale'), hint: t('hostCodewhaleHint'), value: 'codewhale' },
|
|
281
|
-
{ label: t('hostCodex'), hint: t('hostCodexHint'), value: 'codex' },
|
|
282
|
-
], 1);
|
|
430
|
+
// The questions run as steps so ← or Esc can return to the previous one (a step that ends the run returns END).
|
|
431
|
+
const END = Symbol('end');
|
|
432
|
+
const AGAIN = Symbol('again');
|
|
433
|
+
const S = { targets: [], host: null, depth: null, model: null, wantProbe: false };
|
|
283
434
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
{ label: t('depthQuick'), hint: t('depthQuickHint'), value: 'quick' },
|
|
291
|
-
{ label: t('depthStandard'), hint: t('depthStandardHint'), value: 'standard' },
|
|
292
|
-
{ label: t('depthDeep'), hint: t('depthDeepHint'), value: 'deep' },
|
|
293
|
-
], 2);
|
|
294
|
-
depth = DEPTHS[depthId];
|
|
295
|
-
|
|
296
|
-
const provider = await choose(t('providerQ'), [
|
|
297
|
-
{ label: t('provClaude'), hint: t('provClaudeHint'), value: 'claude' },
|
|
298
|
-
{ label: t('provDeepseek'), hint: t('provDeepseekHint'), value: 'deepseek' },
|
|
299
|
-
{ label: t('provManual'), value: 'manual' },
|
|
435
|
+
const sourceStep = async () => {
|
|
436
|
+
say(`\n${t('sourceNote')}`);
|
|
437
|
+
const source = await choose(t('sourceQ'), [
|
|
438
|
+
{ label: t('srcInstalled'), hint: t('srcInstalledHint'), value: 'installed' },
|
|
439
|
+
{ label: t('srcPath'), value: 'path' },
|
|
440
|
+
{ label: t('srcRepo'), hint: t('srcRepoHint'), value: 'repo' },
|
|
300
441
|
], 1);
|
|
301
442
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
443
|
+
let targets = [];
|
|
444
|
+
if (source === 'installed') targets = await pickInstalled();
|
|
445
|
+
else if (source === 'path') {
|
|
446
|
+
// Arrow keys walk the folders; typing a path stays available as the last entry (and is all a pipe has).
|
|
447
|
+
const browsed = hasTerminal() ? await browseDir((a, b, c) => select(a, b, c, { clear: true })) : null;
|
|
448
|
+
if (browsed) say(` ${browsed}`);
|
|
449
|
+
const root = path.resolve(browsed ?? await ask(t('srcPath')));
|
|
450
|
+
const found = findSkills(root);
|
|
451
|
+
if (!found.length) say(`\n${t('noSkill')}`);
|
|
452
|
+
else if (found.length === 1) targets = [{ name: path.basename(found[0]), dir: found[0], root }];
|
|
453
|
+
else {
|
|
454
|
+
say(`\n${t('multiFound', { n: found.length })}`);
|
|
455
|
+
targets = await pickAmong(found, root);
|
|
312
456
|
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
minutes: est.minutes,
|
|
326
|
-
totalMinutes: est.minutes * targets.length,
|
|
327
|
-
})}`);
|
|
328
|
-
} else if (host === 'codewhale') {
|
|
329
|
-
say(`\n${t('codewhaleNote')}`);
|
|
330
|
-
const depthId = await choose(t('depthQ'), [
|
|
331
|
-
{ label: t('depthQuick'), hint: t('depthQuickHint'), value: 'quick' },
|
|
332
|
-
{ label: t('depthStandard'), hint: t('depthStandardHint'), value: 'standard' },
|
|
457
|
+
} else targets = await pickRemote();
|
|
458
|
+
if (!targets.length) return hasTerminal() ? AGAIN : END;
|
|
459
|
+
S.targets = targets.map((tg) => ({ ...tg, name: skillNameOf(tg.dir) }));
|
|
460
|
+
return null;
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
const hostStep = async () => {
|
|
464
|
+
S.host = await choose(t('hostQ'), [
|
|
465
|
+
{ label: t('hostClaude'), hint: t('hostClaudeHint'), value: 'claude' },
|
|
466
|
+
{ label: t('hostCodebuddy'), hint: t('hostCodebuddyHint'), value: 'codebuddy' },
|
|
467
|
+
{ label: t('hostCodewhale'), hint: t('hostCodewhaleHint'), value: 'codewhale' },
|
|
468
|
+
{ label: t('hostCodex'), hint: t('hostCodexHint'), value: 'codex' },
|
|
333
469
|
], 1);
|
|
334
|
-
|
|
335
|
-
model
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
470
|
+
|
|
471
|
+
// Look for the host CLI before any question about depth or model: a missing one ends the run here, for free.
|
|
472
|
+
const hostId = { claude: 'claude-code', codebuddy: 'codebuddy', codewhale: 'codewhale' }[S.host];
|
|
473
|
+
if (hostId) {
|
|
474
|
+
try { requireHost(hostId); } catch (err) {
|
|
475
|
+
if (!(await fixMissingHost(hostId, err))) return AGAIN;
|
|
476
|
+
}
|
|
339
477
|
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
478
|
+
return null;
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
const optionsStep = async () => {
|
|
482
|
+
const { host, targets } = S;
|
|
483
|
+
S.depth = null;
|
|
484
|
+
S.model = null;
|
|
485
|
+
S.wantProbe = false;
|
|
486
|
+
|
|
487
|
+
if (host === 'claude') {
|
|
488
|
+
const depthId = await choose(t('depthQ'), [
|
|
489
|
+
{ label: t('depthQuick'), hint: t('depthQuickHint'), value: 'quick' },
|
|
490
|
+
{ label: t('depthStandard'), hint: t('depthStandardHint'), value: 'standard' },
|
|
491
|
+
{ label: t('depthDeep'), hint: t('depthDeepHint'), value: 'deep' },
|
|
492
|
+
], 2);
|
|
493
|
+
S.depth = DEPTHS[depthId];
|
|
494
|
+
|
|
495
|
+
const provider = await choose(t('providerQ'), [
|
|
496
|
+
{ label: t('provClaude'), value: 'claude' },
|
|
497
|
+
{ label: t('provDeepseek'), value: 'deepseek' },
|
|
498
|
+
{ label: t('provManual'), value: 'manual' },
|
|
499
|
+
], 1);
|
|
500
|
+
|
|
501
|
+
let model;
|
|
502
|
+
if (provider === 'claude') {
|
|
503
|
+
model = await choose(t('modelQ'), [
|
|
504
|
+
{ label: 'haiku', hint: t('modelCheap'), value: 'haiku' },
|
|
505
|
+
{ label: 'sonnet', hint: t('modelDaily'), value: 'sonnet' },
|
|
506
|
+
{ label: 'opus', hint: t('modelExpensive'), value: 'opus' },
|
|
507
|
+
], 1);
|
|
508
|
+
} else if (provider === 'deepseek') {
|
|
509
|
+
if (!process.env.ANTHROPIC_BASE_URL) {
|
|
510
|
+
say(`\n${t('needBaseUrl')}`);
|
|
511
|
+
return END;
|
|
512
|
+
}
|
|
513
|
+
model = await choose(t('modelQ'), [
|
|
514
|
+
{ label: 'deepseek-v4-flash', value: 'deepseek-v4-flash' },
|
|
515
|
+
{ label: 'deepseek-v4-pro', value: 'deepseek-v4-pro' },
|
|
516
|
+
], 1);
|
|
517
|
+
} else model = await ask(t('modelQ'));
|
|
518
|
+
checkModel(model, false);
|
|
519
|
+
S.model = model;
|
|
520
|
+
say(`\n${t('backendLine', { backend: backend(), model })}`);
|
|
521
|
+
|
|
522
|
+
say(`\n${t('draftFirst')}`);
|
|
523
|
+
} else if (host === 'codewhale') {
|
|
524
|
+
say(`\n${t('codewhaleNote')}`);
|
|
525
|
+
const depthId = await choose(t('depthQ'), [
|
|
526
|
+
{ label: t('depthQuick'), hint: t('depthQuickHint'), value: 'quick' },
|
|
527
|
+
{ label: t('depthStandard'), hint: t('depthStandardHint'), value: 'standard' },
|
|
528
|
+
], 1);
|
|
529
|
+
S.depth = DEPTHS[depthId];
|
|
530
|
+
S.model = (await pickModel(['deepseek-flash'])) || 'deepseek-flash';
|
|
531
|
+
if (!process.env.DEEPSEEK_API_KEY) {
|
|
532
|
+
say(`\n${t('needDeepseekKey')}`);
|
|
533
|
+
return END;
|
|
534
|
+
}
|
|
535
|
+
say(`\n${t('draftFirst')}`);
|
|
536
|
+
} else if (host === 'codebuddy') {
|
|
537
|
+
say(`\n${t('codebuddyNote')}`);
|
|
538
|
+
const depthId = await choose(t('depthQ'), [
|
|
539
|
+
{ label: t('depthQuick'), hint: t('depthQuickHint'), value: 'quick' },
|
|
540
|
+
{ label: t('depthStandard'), hint: t('depthStandardHint'), value: 'standard' },
|
|
541
|
+
], 1);
|
|
542
|
+
S.depth = DEPTHS[depthId];
|
|
543
|
+
S.model = await pickModel(modelsOf('codebuddy', 'CodeBuddy'));
|
|
544
|
+
if (notListed(S.model, modelsOf('codebuddy', 'CodeBuddy'), 'CodeBuddy')) return AGAIN;
|
|
545
|
+
if (usesOwnDeepseek('codebuddy', S.model) && !process.env.DEEPSEEK_API_KEY) {
|
|
546
|
+
say(`\n${t('needDeepseekKey')}`);
|
|
547
|
+
return END;
|
|
548
|
+
}
|
|
549
|
+
say(`\n${t('draftFirst')}`);
|
|
550
|
+
} else {
|
|
551
|
+
say(`\n${t('codexNote')}`);
|
|
552
|
+
S.model = await pickModel(modelsOf('codex', 'Codex'));
|
|
553
|
+
if (notListed(S.model, modelsOf('codex', 'Codex'), 'Codex')) return AGAIN;
|
|
554
|
+
if (usesOwnDeepseek('codex', S.model) && !process.env.DEEPSEEK_API_KEY) {
|
|
555
|
+
say(`\n${t('needDeepseekKey')}`);
|
|
556
|
+
return END;
|
|
557
|
+
}
|
|
558
|
+
S.wantProbe = await confirm(t('probeQ'), false);
|
|
559
|
+
say(`\n${S.wantProbe
|
|
560
|
+
? t('codexEstimate', { skills: targets.length, runs: 2 })
|
|
561
|
+
: t('codexStaticOnly', { skills: targets.length })}`);
|
|
353
562
|
}
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
return;
|
|
563
|
+
return null;
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
const confirmStep = async () => {
|
|
567
|
+
if (!(await confirm(t('confirmRun'), false))) {
|
|
568
|
+
say(t('cancelled'));
|
|
569
|
+
return END;
|
|
362
570
|
}
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
? t('codexEstimate', { skills: targets.length, runs: 2 })
|
|
366
|
-
: t('codexStaticOnly', { skills: targets.length })}`);
|
|
367
|
-
}
|
|
571
|
+
return null;
|
|
572
|
+
};
|
|
368
573
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
574
|
+
const steps = [sourceStep, hostStep, optionsStep, confirmStep];
|
|
575
|
+
for (let i = 0; i < steps.length;) {
|
|
576
|
+
let r;
|
|
577
|
+
try { r = await steps[i](); } catch (err) {
|
|
578
|
+
if (err instanceof Back) { i = Math.max(0, i - 1); continue; }
|
|
579
|
+
throw err;
|
|
580
|
+
}
|
|
581
|
+
if (r === END) return;
|
|
582
|
+
if (r !== AGAIN) i += 1;
|
|
372
583
|
}
|
|
584
|
+
const { targets, host, depth, model, wantProbe } = S;
|
|
373
585
|
|
|
586
|
+
setBack(false); // from here on a ← would have nothing to go back to
|
|
587
|
+
const outcomes = [];
|
|
374
588
|
for (const target of targets) {
|
|
375
589
|
say(`\n──── ${target.name} ────`);
|
|
376
|
-
const result = analyze(target.dir);
|
|
590
|
+
const result = withTicker(waitText(t('waitAnalyze'), { slowAfter: 600 }), () => analyze(target.dir));
|
|
377
591
|
const sc = scorecard(result);
|
|
378
|
-
const verdicts = result.groups
|
|
379
|
-
.map((g) => `${g.id}${g.verdict === 'pass' ? '✓' : g.verdict === 'warn' ? '!' : '✗'}`).join(' ');
|
|
380
|
-
say(` ${t('staticDone', { verdicts })}`);
|
|
381
592
|
|
|
382
593
|
let trial = null;
|
|
383
594
|
let gain = null;
|
|
@@ -421,15 +632,39 @@ async function interactive() {
|
|
|
421
632
|
|
|
422
633
|
const md = `${staticMarkdown(result)}${trialSection(trial)}${gainSection(gain)}${probeSection(probe)}`;
|
|
423
634
|
const out = writeOut(target.dir, target.name, md, sc);
|
|
424
|
-
|
|
635
|
+
let records = null;
|
|
636
|
+
if (trial?.runDir) { try { records = validRecords(readRun(trial.runDir).records); } catch { /* the chart then shows the totals only */ } }
|
|
637
|
+
outcomes.push({ name: target.name, result, trial, records, gain, probe, reportPath: path.join(out, `${target.name}.md`) });
|
|
425
638
|
// The run stopped on a failure streak (usage limit, dead token): the next skill would fail the same way.
|
|
426
639
|
if ((host === 'claude' || host === 'codebuddy' || host === 'codewhale') && process.exitCode === 3) break;
|
|
427
640
|
}
|
|
428
641
|
|
|
429
|
-
// Nothing is uploaded
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
|
|
642
|
+
// Nothing is uploaded without a yes here and a second yes after the preview: `submit` scrubs local paths and
|
|
643
|
+
// keys, shows exactly what would go, and only then opens the pull request. Declining leaves the run on disk.
|
|
644
|
+
// The results are drawn only after this decision, so the choice is made on the run, not on how it looks.
|
|
645
|
+
let contributed = false;
|
|
646
|
+
let explained = false;
|
|
647
|
+
const submittable = finishedRuns.filter(canSubmit);
|
|
648
|
+
if (finishedRuns.length && !submittable.length) {
|
|
649
|
+
const manual = finishedRuns.map((d) => `npx skillgauntlet submit "${d}" --repo https://github.com/owner/repo --commit <sha> [--subdir path]`).join('\n ');
|
|
650
|
+
say(`\n${t('noSubmitNoSource', { cmd: manual })}`);
|
|
651
|
+
explained = true;
|
|
652
|
+
} else if (submittable.length && process.exitCode !== 3) {
|
|
653
|
+
if (hasTerminal() && await confirm(t('contributeQ', { n: submittable.length }), false)) {
|
|
654
|
+
for (const dir of submittable) await cmdSubmit(submitArgs(dir));
|
|
655
|
+
contributed = true;
|
|
656
|
+
}
|
|
657
|
+
} else if (!finishedRuns.length && outcomes.length) {
|
|
658
|
+
// Say why there is no upload question, instead of silently not asking.
|
|
659
|
+
say(`\n${t(outcomes.some((o) => o.probe) ? 'noSubmitProbe' : 'noSubmitNoRun')}`);
|
|
660
|
+
explained = true;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
for (const o of outcomes) say(renderResult(o));
|
|
664
|
+
if (!contributed && !explained) {
|
|
665
|
+
const cmd = submittable.map((d) => `npx skillgauntlet submit ${submitArgs(d).map(quoteArg).join(' ')}`).join('\n ');
|
|
666
|
+
say(`\n${t('shareState', { cmd: submittable.length > 1 ? `\n ${cmd}\n` : cmd })}`);
|
|
667
|
+
}
|
|
433
668
|
}
|
|
434
669
|
|
|
435
670
|
async function cmdStatic(target, asJson) {
|
|
@@ -461,6 +696,10 @@ async function main() {
|
|
|
461
696
|
say(t('usage'));
|
|
462
697
|
return;
|
|
463
698
|
}
|
|
699
|
+
if (args.includes('--version') || args.includes('-v')) {
|
|
700
|
+
say(currentVersion() || 'unknown');
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
464
703
|
if (args[0] === 'static') {
|
|
465
704
|
await cmdStatic(args[1] || '.', args.includes('--json'));
|
|
466
705
|
return;
|
|
@@ -469,6 +708,10 @@ async function main() {
|
|
|
469
708
|
await cmdResume(args.slice(1));
|
|
470
709
|
return;
|
|
471
710
|
}
|
|
711
|
+
if (args[0] === 'submit') {
|
|
712
|
+
await cmdSubmit(args.slice(1));
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
472
715
|
await interactive();
|
|
473
716
|
}
|
|
474
717
|
|
|
@@ -484,4 +727,10 @@ main()
|
|
|
484
727
|
if (process.env.SG_DEBUG) say(err.stack);
|
|
485
728
|
process.exitCode = 1;
|
|
486
729
|
})
|
|
487
|
-
.finally(() =>
|
|
730
|
+
.finally(() => {
|
|
731
|
+
closeUi();
|
|
732
|
+
// After a ← or Esc on Windows the terminal's stdin can keep the process alive once the program has finished (or
|
|
733
|
+
// handled Ctrl+C), so the shell prompt never comes back. A terminal session is over here: end it explicitly.
|
|
734
|
+
// Output to a terminal is synchronous, so nothing is cut off; a pipe is left to end by itself.
|
|
735
|
+
if (process.stdin.isTTY && process.stdout.isTTY) process.exit(process.exitCode ?? 0);
|
|
736
|
+
});
|