skillgauntlet 1.1.0 → 1.2.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/README.md +30 -1
- package/bin/skillgauntlet.js +328 -132
- package/lib/browse.js +58 -0
- package/lib/chart.js +157 -0
- package/lib/depth.js +4 -1
- package/lib/discover.js +27 -2
- package/lib/findhost.js +83 -0
- package/lib/gain.js +10 -6
- package/lib/i18n.js +395 -34
- package/lib/models.js +38 -0
- package/lib/report.js +1 -1
- package/lib/schema.js +90 -0
- package/lib/scrub.js +186 -0
- package/lib/static.js +22 -11
- package/lib/submission.js +325 -0
- package/lib/submit.js +208 -0
- package/lib/ticker.js +39 -0
- package/lib/trial.js +69 -0
- package/lib/ui.js +40 -2
- package/package.json +4 -2
- package/schemas/submission-1.json +210 -0
package/lib/browse.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Picking a directory with the arrow keys: the list shows the folders under the current one, a way up, and a way
|
|
2
|
+
// to type a path instead. `select` is injected so the walk can be tested without a terminal.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { t } from './i18n.js';
|
|
7
|
+
|
|
8
|
+
const DOT_KEPT = new Set(['.claude', '.agents', '.codex', '.codebuddy']);
|
|
9
|
+
|
|
10
|
+
function subdirs(dir) {
|
|
11
|
+
let entries = [];
|
|
12
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return []; }
|
|
13
|
+
return entries
|
|
14
|
+
.filter((e) => e.isDirectory() && (!e.name.startsWith('.') || DOT_KEPT.has(e.name)) && e.name !== 'node_modules')
|
|
15
|
+
.map((e) => e.name)
|
|
16
|
+
.sort((a, b) => a.localeCompare(b));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const holdsSkill = (dir) => fs.existsSync(path.join(dir, 'SKILL.md'));
|
|
20
|
+
|
|
21
|
+
function drives() {
|
|
22
|
+
const found = [];
|
|
23
|
+
for (let c = 65; c <= 90; c += 1) {
|
|
24
|
+
const root = `${String.fromCharCode(c)}:\\`;
|
|
25
|
+
if (fs.existsSync(root)) found.push(root);
|
|
26
|
+
}
|
|
27
|
+
return found;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// A long path would wrap and break the in-place redraw, so only its tail is shown.
|
|
31
|
+
const tail = (p, n = 36) => (p.length > n ? `…${p.slice(-(n - 1))}` : p);
|
|
32
|
+
|
|
33
|
+
// Returns the chosen directory, or null when the user asks to type a path instead.
|
|
34
|
+
export async function browseDir(select, start = process.cwd()) {
|
|
35
|
+
let dir = path.resolve(start);
|
|
36
|
+
for (;;) {
|
|
37
|
+
const parent = path.dirname(dir);
|
|
38
|
+
const atRoot = parent === dir;
|
|
39
|
+
const options = [{ label: t('browseUse'), hint: holdsSkill(dir) ? t('browseHasSkill') : '', value: { use: dir } }];
|
|
40
|
+
if (!atRoot) options.push({ label: t('browseUp'), hint: tail(parent), value: { go: parent } });
|
|
41
|
+
else if (process.platform === 'win32') options.push({ label: t('browseDrives'), value: { drives: true } });
|
|
42
|
+
if (dir !== os.homedir()) options.push({ label: t('browseHome'), hint: tail(os.homedir()), value: { go: os.homedir() } });
|
|
43
|
+
for (const name of subdirs(dir)) {
|
|
44
|
+
const full = path.join(dir, name);
|
|
45
|
+
options.push({ label: `${name}${path.sep}`, hint: holdsSkill(full) ? t('browseHasSkill') : '', value: { go: full } });
|
|
46
|
+
}
|
|
47
|
+
options.push({ label: t('browseType'), value: { type: true } });
|
|
48
|
+
|
|
49
|
+
const pick = await select(t('browseTitle', { dir: tail(dir) }), options, 0);
|
|
50
|
+
if (pick.use) return pick.use;
|
|
51
|
+
if (pick.type) return null;
|
|
52
|
+
if (pick.go) dir = pick.go;
|
|
53
|
+
if (pick.drives) {
|
|
54
|
+
const root = await select(t('browseDrives'), drives().map((d) => ({ label: d, value: d })), 0);
|
|
55
|
+
dir = root;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
package/lib/chart.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// A result drawn for the terminal: bars for rates, one mark per question, a verdict in words. The Markdown report
|
|
2
|
+
// stays the full record; this is what a person reads first, so it carries the few numbers that decide the reading.
|
|
3
|
+
import { stdout } from 'node:process';
|
|
4
|
+
import { gradeTrigger } from './submission.js';
|
|
5
|
+
import { t, lang } from './i18n.js';
|
|
6
|
+
|
|
7
|
+
const color = () => stdout.isTTY && !process.env.NO_COLOR;
|
|
8
|
+
const paint = (code, s) => (color() ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
9
|
+
const green = (s) => paint(32, s);
|
|
10
|
+
const yellow = (s) => paint(33, s);
|
|
11
|
+
const red = (s) => paint(31, s);
|
|
12
|
+
const dim = (s) => paint(2, s);
|
|
13
|
+
const bold = (s) => paint(1, s);
|
|
14
|
+
|
|
15
|
+
const pct = (x) => (x == null ? '—' : `${Math.round(x * 100)}%`);
|
|
16
|
+
|
|
17
|
+
// width cells; filled share = value (0..1)
|
|
18
|
+
export function bar(value, width = 24, tone = (s) => s) {
|
|
19
|
+
const v = Math.min(1, Math.max(0, value ?? 0));
|
|
20
|
+
const n = Math.round(v * width);
|
|
21
|
+
return `${tone('█'.repeat(n))}${dim('░'.repeat(width - n))}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const good = (v) => (v >= 0.8 ? green : v >= 0.5 ? yellow : red); // higher is better
|
|
25
|
+
const bad = (v) => (v <= 0.1 ? green : v <= 0.25 ? yellow : red); // lower is better
|
|
26
|
+
|
|
27
|
+
const GRADE_TONE = { well: green, fair: yellow, poorly: red, insufficient: dim };
|
|
28
|
+
|
|
29
|
+
function staticLines(result) {
|
|
30
|
+
const L = [bold(t('chart.static'))];
|
|
31
|
+
for (const g of result.groups) {
|
|
32
|
+
const mark = g.verdict === 'pass' ? green('✓') : g.verdict === 'warn' ? yellow('!') : red('✗');
|
|
33
|
+
L.push(` ${mark} ${g.id} ${t(g.titleKey)}`);
|
|
34
|
+
// A group that is not clean says which checks and why: a letter and a mark alone cannot be acted on.
|
|
35
|
+
for (const c of g.checks.filter((x) => x.status === 'warn' || x.status === 'fail')) {
|
|
36
|
+
L.push(` ${c.status === 'fail' ? red('✗') : yellow('!')} ${c.id} ${t(c.titleKey)}${dim(` — ${cut(t(c.detailKey, c.detailVars), 70)}`)}`);
|
|
37
|
+
for (const e of (c.evidence || []).slice(0, 2)) L.push(` ${dim(`${cut(`${e.file || ''}:${e.line}`, 56)} ${cut(e.match, 40)}`)}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
L.push(` ${dim(t('chart.staticLegend'))}`);
|
|
41
|
+
const st = result.stats;
|
|
42
|
+
if (st) L.push(` ${dim(t('chart.stats', { tokens: st.skillMdTokens, desc: st.descriptionChars, files: st.files }))}`);
|
|
43
|
+
return L;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function triggerLines(trial, records) {
|
|
47
|
+
const pos = trial.questions.filter((q) => q.shouldTrigger);
|
|
48
|
+
const neg = trial.questions.filter((q) => !q.shouldTrigger);
|
|
49
|
+
const nPos = pos.reduce((a, q) => a + q.n, 0);
|
|
50
|
+
const nNeg = neg.reduce((a, q) => a + q.n, 0);
|
|
51
|
+
const grade = gradeTrigger({ recall: trial.recall, fpr: trial.fpr, nPos, nNeg });
|
|
52
|
+
const ci = (c) => (c?.[0] == null ? '' : dim(` ${t('chart.ci', { lo: pct(c[0]), hi: pct(c[1]) })}`));
|
|
53
|
+
const L = [bold(t('chart.trigger')), ''];
|
|
54
|
+
L.push(` ${dim(t('chart.conditions', { host: trial.host || '?', model: trial.modelResolved || trial.model || '?', trials: trial.trials, failed: trial.failedTrials || 0 }))}`, '');
|
|
55
|
+
L.push(` ${t('chart.verdict')} ${(GRADE_TONE[grade] || ((s) => s))(bold(t(`chart.grade.${grade}`)))}`);
|
|
56
|
+
L.push('');
|
|
57
|
+
L.push(` ${padCells(t('chart.recall'), pad(t('chart.recall'), t('chart.fpr')))}${bar(trial.recall, 24, good(trial.recall ?? 0))} ${pct(trial.recall)}${ci(trial.recallCi)}`);
|
|
58
|
+
L.push(` ${padCells(t('chart.fpr'), pad(t('chart.recall'), t('chart.fpr')))}${bar(trial.fpr, 24, bad(trial.fpr ?? 0))} ${pct(trial.fpr)}${ci(trial.fprCi)}`);
|
|
59
|
+
L.push('');
|
|
60
|
+
L.push(` ${dim(t('chart.legend'))}`);
|
|
61
|
+
const strip = (qs) => qs.map((q) => (q.hits * 2 > q.n ? green('●') : q.hits > 0 ? yellow('◐') : dim('○'))).join('');
|
|
62
|
+
L.push(` ${padCells(t('chart.should'), pad(t('chart.should'), t('chart.shouldNot')))}${strip(pos)} ${dim(`${pos.filter((q) => q.hits * 2 > q.n).length}/${pos.length}`)}`);
|
|
63
|
+
L.push(` ${padCells(t('chart.shouldNot'), pad(t('chart.should'), t('chart.shouldNot')))}${strip(neg)} ${dim(`${neg.filter((q) => q.hits > 0).length}/${neg.length}`)}`);
|
|
64
|
+
if (trial.incomplete) L.push(` ${yellow(t('chart.incomplete', { n: trial.incomplete }))}`);
|
|
65
|
+
if (trial.recall === 0 && trial.fpr === 0) L.push('', ` ${yellow(t('chart.allZero'))}`);
|
|
66
|
+
if (records?.length) L.push(...detailLines(records));
|
|
67
|
+
return L;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Width of the wider of two labels in terminal cells: CJK characters take two.
|
|
71
|
+
function cells(s) {
|
|
72
|
+
let n = 0;
|
|
73
|
+
for (const ch of String(s)) n += /[ᄀ-ᅟ⺀-가-힣豈-︰--⦆¢-₩]/.test(ch) ? 2 : 1;
|
|
74
|
+
return n;
|
|
75
|
+
}
|
|
76
|
+
function pad(a, b) { return Math.max(cells(a), cells(b)) + 2; }
|
|
77
|
+
// String.padEnd counts code units, not cells, so columns are padded by hand.
|
|
78
|
+
const padCells = (str, width) => str + ' '.repeat(Math.max(0, width - cells(str)));
|
|
79
|
+
|
|
80
|
+
// What the model did instead when the skill was not used, then the questions behind the numbers.
|
|
81
|
+
function detailLines(records) {
|
|
82
|
+
const L = [];
|
|
83
|
+
const missed = records.filter((r) => r.shouldTrigger && r.verdict !== 'triggered');
|
|
84
|
+
if (missed.length) {
|
|
85
|
+
const kinds = new Map();
|
|
86
|
+
for (const r of missed) {
|
|
87
|
+
const k = r.verdict === 'other_skill' ? t('chart.otherSkill', { name: r.invokedSkill || '?' }) : (r.firstTool || t('chart.noTool'));
|
|
88
|
+
kinds.set(k, (kinds.get(k) || 0) + 1);
|
|
89
|
+
}
|
|
90
|
+
const rows = [...kinds].sort((a, b) => b[1] - a[1]);
|
|
91
|
+
const w = Math.max(...rows.map(([k]) => cells(k))) + 2;
|
|
92
|
+
L.push('', ` ${bold(t('chart.missedTitle', { n: missed.length }))}`);
|
|
93
|
+
for (const [k, n] of rows) L.push(` ${padCells(k, w)}${bar(n / missed.length, 16, yellow)} ${n}`);
|
|
94
|
+
const shown = missed.slice(0, 8);
|
|
95
|
+
L.push('', ` ${dim(t('chart.missedList'))}`);
|
|
96
|
+
for (const r of shown) L.push(` ${dim('○')} ${cut(r.query, 46)} ${dim(`→ ${r.firstTool || t('chart.noTool')}`)}`);
|
|
97
|
+
if (missed.length > shown.length) L.push(` ${dim(t('chart.more', { n: missed.length - shown.length }))}`);
|
|
98
|
+
}
|
|
99
|
+
const wrong = records.filter((r) => !r.shouldTrigger && r.verdict === 'triggered');
|
|
100
|
+
if (wrong.length) {
|
|
101
|
+
L.push('', ` ${bold(t('chart.wrongTitle', { n: wrong.length }))}`);
|
|
102
|
+
for (const r of wrong.slice(0, 8)) L.push(` ${red('●')} ${cut(r.query, 46)}`);
|
|
103
|
+
}
|
|
104
|
+
return L;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Cut to n terminal cells, with an ellipsis when something was removed.
|
|
108
|
+
function cut(str, n) {
|
|
109
|
+
let out = '';
|
|
110
|
+
let w = 0;
|
|
111
|
+
for (const ch of String(str).replace(/\s+/g, ' ')) {
|
|
112
|
+
const c = cells(ch);
|
|
113
|
+
if (w + c > n - 1) return `${out}…`;
|
|
114
|
+
out += ch;
|
|
115
|
+
w += c;
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function probeLines(probe) {
|
|
121
|
+
const same = (b) => (b ? green(t('probeSame')) : yellow(t('probeDiffer')));
|
|
122
|
+
const L = [bold(t('chart.probe')), ` ${dim(t('chart.probeAsk'))}`, ''];
|
|
123
|
+
const w = Math.max(cells(t('probeWith')), cells(t('probeWithout'))) + 2;
|
|
124
|
+
for (const [label, side] of [[t('probeWith'), probe.withSkill], [t('probeWithout'), probe.without]]) {
|
|
125
|
+
if (!side) continue;
|
|
126
|
+
const act = side.firstAction ? cut(`${side.firstAction.kind}: ${side.firstAction.detail}`, 80) : t('chart.noAction');
|
|
127
|
+
const files = side.createdFiles?.length ? cut(side.createdFiles.join(', '), 80) : t('chart.noFiles');
|
|
128
|
+
L.push(` ${bold(padCells(label, w))}${t('chart.firstAction')} ${act}`);
|
|
129
|
+
L.push(` ${padCells('', w)}${t('chart.filesMade')} ${files}`);
|
|
130
|
+
}
|
|
131
|
+
L.push('', ` ${t('probeActionKind')} ${same(probe.actionKindIdentical)}`, ` ${t('probeArtifacts')} ${same(probe.artifactsIdentical)}`);
|
|
132
|
+
const reading = probe.actionKindIdentical && probe.artifactsIdentical ? 'chart.probeNoEffect'
|
|
133
|
+
: probe.artifactsIdentical === false ? 'chart.probeEffect' : 'chart.probeMixed';
|
|
134
|
+
L.push('', ` ${t(reading)}`, ` ${dim(t('chart.probeNote'))}`);
|
|
135
|
+
return L;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function gainLines(gain) {
|
|
139
|
+
const L = [bold(t('chart.gain')), ` ${dim(t('chart.gainLegend'))}`];
|
|
140
|
+
for (const c of gain.contracts) {
|
|
141
|
+
L.push(` ${String(c.descI18n?.[lang()] || c.desc).slice(0, 60)}`);
|
|
142
|
+
L.push(` ${padCells(t('gain.withHeader'), 10)}${bar(c.with, 20, green)} ${pct(c.with)}`);
|
|
143
|
+
L.push(` ${padCells(t('gain.withoutHeader'), 10)}${bar(c.without, 20, (s) => s)} ${pct(c.without)}`);
|
|
144
|
+
}
|
|
145
|
+
return L;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// One block per tested skill. Any part may be absent (static only, probe only, no gain layer).
|
|
149
|
+
export function renderResult({ name, result, trial, records, gain, probe, reportPath }) {
|
|
150
|
+
const L = ['', bold(`════ ${name} ════`), ''];
|
|
151
|
+
if (result) L.push(...staticLines(result), '');
|
|
152
|
+
if (trial) L.push(...triggerLines(trial, records), '');
|
|
153
|
+
if (probe) L.push(...probeLines(probe), '');
|
|
154
|
+
if (gain?.contracts?.length) L.push(...gainLines(gain), '');
|
|
155
|
+
if (reportPath) L.push(dim(t('chart.full', { path: reportPath })));
|
|
156
|
+
return L.join('\n');
|
|
157
|
+
}
|
package/lib/depth.js
CHANGED
|
@@ -15,7 +15,7 @@ export const DEPTHS = {
|
|
|
15
15
|
id: 'quick',
|
|
16
16
|
sets: 1,
|
|
17
17
|
perClause: 2,
|
|
18
|
-
negatives:
|
|
18
|
+
negatives: 10, // the grade needs at least 10 on each side, so fewer would make every quick result "too few"
|
|
19
19
|
runs: 1,
|
|
20
20
|
maxExplore: 0,
|
|
21
21
|
layers: ['static', 'trigger'],
|
|
@@ -53,3 +53,6 @@ export function estimate(depth, clauses = 3) {
|
|
|
53
53
|
minutes: Math.max(1, Math.round((trials * 4) / 2 / 60)),
|
|
54
54
|
};
|
|
55
55
|
}
|
|
56
|
+
|
|
57
|
+
// Minutes for a number of trials, on the same assumption as estimate(): 2 in parallel at ~4 s each.
|
|
58
|
+
export const minutesFor = (trials) => Math.max(1, Math.round((trials * 4) / 2 / 60));
|
package/lib/discover.js
CHANGED
|
@@ -5,6 +5,9 @@ import os from 'node:os';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
|
|
7
7
|
const SKIP = new Set(['.git', 'node_modules', '.cache', 'dist', 'build', 'vendor', '__pycache__']);
|
|
8
|
+
// Dot directories are skipped, except the ones agent hosts install skills into: a repository's own skill
|
|
9
|
+
// often lives only under .claude/skills.
|
|
10
|
+
const SKILL_DOT_DIRS = new Set(['.claude', '.agents', '.codex', '.codebuddy']);
|
|
8
11
|
|
|
9
12
|
export function findSkills(root, { maxDepth = 6 } = {}) {
|
|
10
13
|
const found = [];
|
|
@@ -17,12 +20,25 @@ export function findSkills(root, { maxDepth = 6 } = {}) {
|
|
|
17
20
|
return; // a skill dir does not contain nested skills
|
|
18
21
|
}
|
|
19
22
|
for (const e of entries) {
|
|
20
|
-
if (!e.isDirectory() || SKIP.has(e.name) || e.name.startsWith('.')) continue;
|
|
23
|
+
if (!e.isDirectory() || SKIP.has(e.name) || (e.name.startsWith('.') && !SKILL_DOT_DIRS.has(e.name))) continue;
|
|
21
24
|
walk(path.join(dir, e.name), depth + 1);
|
|
22
25
|
}
|
|
23
26
|
};
|
|
24
27
|
walk(path.resolve(root), 0);
|
|
25
|
-
return found.sort();
|
|
28
|
+
return dropCopies(found.sort());
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Repositories often ship a second copy of a skill (a packaged template next to the installed one). A copy with
|
|
32
|
+
// the same name and byte-identical SKILL.md adds nothing to a test, so only the first path is kept.
|
|
33
|
+
function dropCopies(dirs) {
|
|
34
|
+
const seen = new Set();
|
|
35
|
+
return dirs.filter((d) => {
|
|
36
|
+
let key = d;
|
|
37
|
+
try { key = [path.basename(d), fs.readFileSync(path.join(d, 'SKILL.md'), 'utf8')].join(' | '); } catch { /* keep it */ }
|
|
38
|
+
if (seen.has(key)) return false;
|
|
39
|
+
seen.add(key);
|
|
40
|
+
return true;
|
|
41
|
+
});
|
|
26
42
|
}
|
|
27
43
|
|
|
28
44
|
export function readFrontmatter(skillDir) {
|
|
@@ -81,3 +97,12 @@ export function resolveSpec(spec) {
|
|
|
81
97
|
} catch { /* keep empty */ }
|
|
82
98
|
return { kind: 'git', dir, source: { repo: `https://github.com/${owner}/${repo}`, commit, ref: ref || null } };
|
|
83
99
|
}
|
|
100
|
+
|
|
101
|
+
// The name a host knows a skill by is the `name` in its SKILL.md frontmatter; the folder name is only a fallback.
|
|
102
|
+
// A skill at the root of a repository sits in a folder named after the repository (or its cache slug), so using
|
|
103
|
+
// the folder name made every trial of such a skill look like "another skill was used".
|
|
104
|
+
export function skillNameOf(skillDir) {
|
|
105
|
+
const fm = readFrontmatter(skillDir).fm;
|
|
106
|
+
const name = String(fm.name || '').trim();
|
|
107
|
+
return /^[\w.-]+$/.test(name) ? name : path.basename(skillDir);
|
|
108
|
+
}
|
package/lib/findhost.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// A host CLI that is not on PATH is often still on the machine: in npm's global folder, in Downloads under the
|
|
2
|
+
// name the release page gave it, or in a tools folder. Look there before telling the user to install anything,
|
|
3
|
+
// and where the host is an npm package offer to install it.
|
|
4
|
+
import { execSync } from 'node:child_process';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
// The command name each host answers to, and (when there is one) the npm package that provides it.
|
|
10
|
+
export const HOST_CLI = {
|
|
11
|
+
'claude-code': { bin: 'claude', pkg: '@anthropic-ai/claude-code' },
|
|
12
|
+
codebuddy: { bin: 'codebuddy', pkg: '@tencent-ai/codebuddy-code' },
|
|
13
|
+
codewhale: { bin: 'codewhale', pkg: 'codewhale' },
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const isWin = process.platform === 'win32';
|
|
17
|
+
const RUNNABLE = isWin ? /\.(exe|cmd|bat)$/i : /^[^.]*$/;
|
|
18
|
+
|
|
19
|
+
function npmGlobalDir() {
|
|
20
|
+
try {
|
|
21
|
+
const prefix = execSync('npm prefix -g', { encoding: 'utf8', timeout: 15000, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
22
|
+
return isWin ? prefix : path.join(prefix, 'bin');
|
|
23
|
+
} catch { return null; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function listDir(dir) {
|
|
27
|
+
try { return fs.readdirSync(dir, { withFileTypes: true }); } catch { return []; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Files named like the host (codewhale.exe, codewhale-windows-x64.exe, codewhale.cmd) directly in dir.
|
|
31
|
+
function inDir(dir, bin) {
|
|
32
|
+
const out = [];
|
|
33
|
+
for (const e of listDir(dir)) {
|
|
34
|
+
if (!e.isFile()) continue;
|
|
35
|
+
const base = e.name.toLowerCase();
|
|
36
|
+
const exact = base === bin || base.startsWith(`${bin}.`);
|
|
37
|
+
// a Windows release asset keeps its platform suffix (codewhale-windows-x64.exe); only a real exe counts for those.
|
|
38
|
+
// Elsewhere an executable has no extension and only the exact name is trusted.
|
|
39
|
+
const variant = isWin && (base.startsWith(`${bin}-`) || base.startsWith(`${bin}_`)) && base.endsWith('.exe');
|
|
40
|
+
if ((exact || variant) && RUNNABLE.test(e.name)) out.push(path.join(dir, e.name));
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Directories worth looking in, nothing recursive beyond one level of subfolders for the download-like ones.
|
|
46
|
+
export function searchDirs() {
|
|
47
|
+
const home = os.homedir();
|
|
48
|
+
const dirs = [npmGlobalDir(), ...(process.env.PATH || '').split(path.delimiter)].filter(Boolean);
|
|
49
|
+
for (const sub of ['.local/bin', '.cargo/bin', 'bin', 'tools', 'Downloads', 'Desktop', 'Documents']) dirs.push(path.join(home, sub));
|
|
50
|
+
if (process.env.LOCALAPPDATA) dirs.push(path.join(process.env.LOCALAPPDATA, 'Programs'));
|
|
51
|
+
return dirs;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Absolute paths of candidates for a host, best first (a real .exe before a .cmd shim). `dirs` is injectable.
|
|
55
|
+
export function searchHost(host, dirs = searchDirs()) {
|
|
56
|
+
const spec = HOST_CLI[host];
|
|
57
|
+
if (!spec) return [];
|
|
58
|
+
const seen = new Set();
|
|
59
|
+
const found = [];
|
|
60
|
+
const add = (f) => { if (!seen.has(f.toLowerCase())) { seen.add(f.toLowerCase()); found.push(f); } };
|
|
61
|
+
for (const dir of dirs) {
|
|
62
|
+
inDir(dir, spec.bin).forEach(add);
|
|
63
|
+
// one level down: Downloads\codewhale\codewhale.exe, Programs\codewhale\...
|
|
64
|
+
for (const e of listDir(dir).slice(0, 400)) {
|
|
65
|
+
if (e.isDirectory() && e.name.toLowerCase().includes(spec.bin)) inDir(path.join(dir, e.name), spec.bin).forEach(add);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const exactName = (f) => { const b = path.basename(f).toLowerCase(); return b === spec.bin || b.startsWith(`${spec.bin}.`); };
|
|
69
|
+
const rank = (f) => (exactName(f) ? 0 : 10) + (/\.exe$/i.test(f) ? 0 : /\.(cmd|bat)$/i.test(f) ? 1 : 2);
|
|
70
|
+
return found.sort((a, b) => rank(a) - rank(b));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Codewhale's package has an install script, and npm only runs install scripts it has been told to allow. The
|
|
74
|
+
// allowance is given for this one command (--allow-scripts) rather than written into the user's npm config.
|
|
75
|
+
const INSTALL_FLAGS = { codewhale: ' --allow-scripts=codewhale' };
|
|
76
|
+
export const installCommand = (host) => (HOST_CLI[host]?.pkg ? `npm install -g ${HOST_CLI[host].pkg}${INSTALL_FLAGS[host] || ''}` : null);
|
|
77
|
+
|
|
78
|
+
// Runs the npm install with its output on the user's terminal. Throws with a short reason on failure.
|
|
79
|
+
export function installHost(host, run = (cmd) => execSync(cmd, { stdio: 'inherit' })) {
|
|
80
|
+
const cmd = installCommand(host);
|
|
81
|
+
if (!cmd) throw new Error('no installer for this host');
|
|
82
|
+
try { run(cmd); } catch (err) { throw new Error(String(err.message || err).split(/\r?\n/)[0]); }
|
|
83
|
+
}
|
package/lib/gain.js
CHANGED
|
@@ -21,6 +21,8 @@ import fs from 'node:fs';
|
|
|
21
21
|
import os from 'node:os';
|
|
22
22
|
import path from 'node:path';
|
|
23
23
|
import { fileURLToPath } from 'node:url';
|
|
24
|
+
import { launchSpec } from './spawn.js';
|
|
25
|
+
import { narrowEnv } from './trial.js';
|
|
24
26
|
|
|
25
27
|
const HERE = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
26
28
|
const CONTRACT_DIR = path.join(HERE, 'contracts');
|
|
@@ -36,8 +38,8 @@ function copyDir(src, dst) {
|
|
|
36
38
|
}
|
|
37
39
|
}
|
|
38
40
|
|
|
39
|
-
export function loadContracts(skillName) {
|
|
40
|
-
const file = path.join(
|
|
41
|
+
export function loadContracts(skillName, dir = CONTRACT_DIR) {
|
|
42
|
+
const file = path.join(dir, `${skillName}.json`);
|
|
41
43
|
if (!fs.existsSync(file)) return null;
|
|
42
44
|
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
43
45
|
}
|
|
@@ -111,8 +113,10 @@ function runTask({ bin, model, task, skillDir, skillName, timeout }) {
|
|
|
111
113
|
if (model) args.push('--model', model);
|
|
112
114
|
|
|
113
115
|
return new Promise((resolve) => {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
+
// Not through a shell: cmd.exe would re-split the prompt on spaces (see spawn.js).
|
|
117
|
+
const launch = launchSpec(bin, args, 'claude');
|
|
118
|
+
const child = spawn(launch.cmd, launch.args, {
|
|
119
|
+
cwd: tmp, shell: launch.shell, env: narrowEnv(), windowsHide: true, stdio: 'ignore',
|
|
116
120
|
});
|
|
117
121
|
const timer = setTimeout(() => {
|
|
118
122
|
try {
|
|
@@ -128,8 +132,8 @@ function runTask({ bin, model, task, skillDir, skillName, timeout }) {
|
|
|
128
132
|
});
|
|
129
133
|
}
|
|
130
134
|
|
|
131
|
-
export async function runGain({ bin, skillDir, skillName, model, timeout = 600, onProgress }) {
|
|
132
|
-
const spec = loadContracts(skillName);
|
|
135
|
+
export async function runGain({ bin, skillDir, skillName, model, timeout = 600, onProgress, contractDir }) {
|
|
136
|
+
const spec = loadContracts(skillName, contractDir);
|
|
133
137
|
const hasContracts = spec?.contracts?.length || spec?.tasks?.some((t) => t.contracts?.length);
|
|
134
138
|
if (!spec || !hasContracts || !spec.tasks?.length) {
|
|
135
139
|
return { measurable: false, reasonKey: 'gain.noContracts' };
|