skillgauntlet 1.3.1 → 1.3.3

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.
@@ -13,13 +13,17 @@ import { fingerprint, cacheFileFor, readCachedQueries, writeCachedQueries } from
13
13
  import { openRun, querySetHash, readRun, readAnswers, validRecords } from '../lib/runs.js';
14
14
  import { DEPTHS, minutesFor } from '../lib/depth.js';
15
15
  import { ask, choose, select, confirm, say, closeUi, hasTerminal, setBack, Back } from '../lib/ui.js';
16
- import { hostModels, unknownModel } from '../lib/models.js';
16
+ import { hostModels, unknownModel, codebuddyCatalog } from '../lib/models.js';
17
17
  import { browseDir } from '../lib/browse.js';
18
18
  import { withTicker } from '../lib/ticker.js';
19
- import { renderResult, bar } from '../lib/chart.js';
19
+ import { renderResult } from '../lib/chart.js';
20
+ import { makeProgress } from '../lib/progress.js';
20
21
  import { setLang, detectLang, errorText, t } from '../lib/i18n.js';
21
22
  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';
23
+ import { HOST_CLI, searchHost, installCommand, installHost, LOGIN, loginCommand } from '../lib/findhost.js';
24
+ import { launchSpec } from '../lib/spawn.js';
25
+ import { ensureTool } from '../lib/github.js';
26
+ import { spawnSync as runLogin } from 'node:child_process';
23
27
  import { runGain } from '../lib/gain.js';
24
28
  import { ERROR_STREAK_STOP, executeTrials, finishRun, resumeRun } from '../lib/execute.js';
25
29
  import { cmdSubmit } from '../lib/submit.js';
@@ -127,6 +131,13 @@ function notListed(model, known, label) {
127
131
  say(`\n${t('modelUnknown', { model, host: label, list: list.slice(0, 8).join(', ') })}`);
128
132
  return true;
129
133
  }
134
+ // Models in CodeBuddy's cached catalog that its --help does not list for this account right now.
135
+ function catalogOnly(host) {
136
+ if (host !== 'codebuddy') return [];
137
+ const listed = new Set(modelsOf('codebuddy', 'CodeBuddy'));
138
+ return codebuddyCatalog().filter((m) => !listed.has(m) && !m.startsWith('custom-local:'));
139
+ }
140
+
130
141
  function modelsOf(host, label) {
131
142
  if (!modelCache.has(host)) {
132
143
  const list = withTicker(waitText(t('waitModels', { host: label }), { slowAfter: 600 }), () => hostModels(host, resolveBin(host)));
@@ -137,12 +148,40 @@ function modelsOf(host, label) {
137
148
  }
138
149
  const HOST_LABEL = { 'claude-code': 'Claude Code', codebuddy: 'CodeBuddy', codewhale: 'Codewhale' };
139
150
 
151
+ // The host did not answer its sign-in check. Show why, and let the user sign in with one press of enter: the host's own
152
+ // login runs on this terminal, then the check is made again. True when a sign-in was run (the caller checks again).
153
+ async function offerLogin(host, bin, err) {
154
+ const label = HOST_LABEL[host] || host;
155
+ const spec = LOGIN[host];
156
+ const cmd = loginCommand(host, path.basename(bin).replace(/\.(cmd|exe|bat)$/i, ''));
157
+ say(`\n${errorText(err)}`);
158
+ const pick = await choose(t('loginQ'), [
159
+ { label: t('loginNow', { host: label }), hint: cmd ? t('loginRuns', { cmd }) : '', value: 'now' },
160
+ { label: t('loginSkip'), value: 'skip' },
161
+ ], 1);
162
+ if (pick !== 'now') return false;
163
+ say(`\n${t(spec.hint)}\n`);
164
+ closeUi(); // hand the terminal to the host's own login
165
+ const launch = launchSpec(bin, spec.args, HOSTS[host].bin);
166
+ const r = runLogin(launch.cmd, launch.args, { stdio: 'inherit', shell: launch.shell });
167
+ say('');
168
+ return !r.error;
169
+ }
170
+
140
171
  async function runTriggerLayer({ skillDir, skillName, depth, model, host = 'claude-code' }) {
141
172
  requireHost(host);
142
173
  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);
174
+ while (!hostChecked.has(host)) {
175
+ try {
176
+ withTicker(waitText(t('hostPing', { host: HOST_LABEL[host] || host })), () => pingHost(host, { bin, model }));
177
+ hostChecked.add(host);
178
+ } catch (err) {
179
+ // Not signed in is the common reason: offer the sign-in right here, then check again.
180
+ if (err.messageKey !== 'hostNotReady' || !LOGIN[host] || !hasTerminal() || !(await offerLogin(host, bin, err))) {
181
+ if (err.messageKey === 'hostNotReady') err.hostDown = true;
182
+ throw err;
183
+ }
184
+ }
146
185
  }
147
186
  const cacheFile = cacheFileFor(skillDir, depth);
148
187
  const want = { perClause: depth.perClause, negatives: depth.negatives, skillFingerprint: fingerprint(skillDir) };
@@ -205,13 +244,15 @@ async function runTriggerLayer({ skillDir, skillName, depth, model, host = 'clau
205
244
  queries: { schema: 'skillgauntlet/queries/1', queries: qs.queries },
206
245
  });
207
246
 
247
+ const prog = progress(trials.length);
208
248
  try {
209
249
  const outcome = await executeTrials({
210
250
  run,
211
251
  trials,
212
252
  doTrial: ({ q }) => runTrial({ bin, skillDir, skillName, query: q.query, model, maxExplore: depth.maxExplore, host }),
213
- onTrial: progress(),
253
+ onTrial: prog,
214
254
  });
255
+ prog.finish();
215
256
  const result = finishRun(run, { model, backend: backendFor(host, model), host, runs: depth.runs, expected: trials.length }, outcome.stopped);
216
257
  say(` ${t('runSaved', { path: run.dir })}`);
217
258
  if (outcome.stopped) reportStopped(outcome.stopped, run.dir, model);
@@ -219,6 +260,7 @@ async function runTriggerLayer({ skillDir, skillName, depth, model, host = 'clau
219
260
  else finishedRuns.push(run.dir);
220
261
  return result && { ...result, runDir: run.dir, queries: qs.queries };
221
262
  } catch (err) {
263
+ prog.finish();
222
264
  run.close(null, { interrupted: true });
223
265
  throw err;
224
266
  }
@@ -250,17 +292,7 @@ function archiveProbe({ skillDir, skillName, model, probe }) {
250
292
  say(` ${t('runSaved', { path: run.dir })}`);
251
293
  }
252
294
 
253
- function progress() {
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
- }
260
- const mark = { triggered: '✓', not_triggered: '·', other_skill: '↷' }[row.verdict] || '!';
261
- say(` [${done}/${total}] ${mark} ${row.queryId}#${row.run} → ${row.verdict}`);
262
- };
263
- }
295
+ const progress = makeProgress;
264
296
 
265
297
  function reportIncomplete(n, dir, model) {
266
298
  say(`
@@ -286,12 +318,14 @@ async function cmdResume(args) {
286
318
  const runMeta = JSON.parse(fs.readFileSync(path.join(full, 'meta.json'), 'utf8'));
287
319
  const runHost = runMeta.host || 'claude-code';
288
320
  requireHost(runHost);
321
+ const resumeProgress = progress();
289
322
  const result = await resumeRun({
290
323
  dir: full,
291
324
  bin: resolveBin((HOSTS[runHost] || HOSTS['claude-code']).bin),
292
325
  want: { model: flag('--model'), runs: flag('--runs'), maxExplore: flag('--max-explore'), backend: backendFor(runHost, flag('--model') ?? runMeta.model) },
293
- onTrial: progress(),
326
+ onTrial: resumeProgress,
294
327
  });
328
+ resumeProgress.finish();
295
329
  if (result.nothing) {
296
330
  say(t('resumeNothing'));
297
331
  return;
@@ -320,12 +354,14 @@ async function pickInstalled() {
320
354
  }
321
355
 
322
356
  // 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 = []) {
357
+ async function pickModel(known = [], more = []) {
324
358
  if (!hasTerminal()) return (await ask(t('modelOptionalQ'), '')).trim() || null;
325
359
  const other = '\u0000other';
360
+ // `known` is what the account can use now; `more` is in the host's catalog but not promised to the account
326
361
  const v = await select(t('modelQ'), [
327
362
  { label: t('modelDefault'), value: '' },
328
- ...known.map((m) => ({ label: m, value: m })),
363
+ ...known.map((m) => ({ label: m, hint: more.length ? t('modelAccountHint') : '', value: m })),
364
+ ...more.map((m) => ({ label: m, hint: t('modelCatalogHint'), value: m })),
329
365
  { label: t('modelOther'), value: other },
330
366
  ], 0);
331
367
  if (v === other) return (await ask(t('modelOptionalQ'), '')).trim() || null;
@@ -340,6 +376,8 @@ async function pickRemote() {
340
376
  say(` ${t('needRepo')}`);
341
377
  continue;
342
378
  }
379
+ // cloning needs git; a machine without it is offered the install here, not given a raw error
380
+ if (hasTerminal() && !(await ensureTool('git', { pick: choose, out: say }))) return [];
343
381
  let resolved;
344
382
  try {
345
383
  resolved = withTicker(waitText(t('waitCloning'), { slowAfter: 600 }), () => resolveSpec(parsed.repo));
@@ -540,8 +578,8 @@ async function interactive() {
540
578
  { label: t('depthStandard'), hint: t('depthStandardHint'), value: 'standard' },
541
579
  ], 1);
542
580
  S.depth = DEPTHS[depthId];
543
- S.model = await pickModel(modelsOf('codebuddy', 'CodeBuddy'));
544
- if (notListed(S.model, modelsOf('codebuddy', 'CodeBuddy'), 'CodeBuddy')) return AGAIN;
581
+ S.model = await pickModel(modelsOf('codebuddy', 'CodeBuddy'), catalogOnly('codebuddy'));
582
+ if (notListed(S.model, [...modelsOf('codebuddy', 'CodeBuddy'), ...catalogOnly('codebuddy')], 'CodeBuddy')) return AGAIN;
545
583
  if (usesOwnDeepseek('codebuddy', S.model) && !process.env.DEEPSEEK_API_KEY) {
546
584
  say(`\n${t('needDeepseekKey')}`);
547
585
  return END;
@@ -595,14 +633,16 @@ async function interactive() {
595
633
  let probe = null;
596
634
 
597
635
  if (host === 'claude' || host === 'codebuddy' || host === 'codewhale') {
636
+ let hostDown = false; // the host did not answer the sign-in check: nothing else will work either
598
637
  try {
599
638
  trial = await runTriggerLayer({
600
639
  skillDir: target.dir, skillName: target.name, depth, model, host: { claude: 'claude-code', codebuddy: 'codebuddy', codewhale: 'codewhale' }[host],
601
640
  });
602
641
  } catch (err) {
603
642
  say(` ${t('trialFail', { err: errorText(err) })}`);
643
+ hostDown = Boolean(err.hostDown) || err.messageKey === 'hostNotReady';
604
644
  }
605
- if (host === 'claude' && depth.layers.includes('gain')) {
645
+ if (host === 'claude' && depth.layers.includes('gain') && !hostDown) {
606
646
  say(` ${t('gainNote')}`);
607
647
  const g = await runGain({
608
648
  bin: resolveBin('claude'),
package/lib/findhost.js CHANGED
@@ -81,3 +81,12 @@ export function installHost(host, run = (cmd) => execSync(cmd, { stdio: 'inherit
81
81
  if (!cmd) throw new Error('no installer for this host');
82
82
  try { run(cmd); } catch (err) { throw new Error(String(err.message || err).split(/\r?\n/)[0]); }
83
83
  }
84
+
85
+ // How to sign in to a host from this terminal. Claude Code and Codewhale have a login command; CodeBuddy has none, its
86
+ // sign-in is the /login command inside its own interface, so its CLI is opened and the user is told what to type.
87
+ export const LOGIN = {
88
+ 'claude-code': { args: ['auth', 'login'], hint: 'loginHintBrowser' },
89
+ codewhale: { args: ['login'], hint: 'loginHintBrowser' },
90
+ codebuddy: { args: [], hint: 'loginHintCodebuddy' },
91
+ };
92
+ export const loginCommand = (host, bin) => (LOGIN[host] ? `${bin} ${LOGIN[host].args.join(' ')}`.trim() : null);
package/lib/github.js ADDED
@@ -0,0 +1,143 @@
1
+ // What a machine with nothing installed needs before a result can be submitted, and what happens after the pull request
2
+ // is opened: git and the GitHub CLI (installed when the user presses enter), a GitHub sign-in (the CLI's own browser
3
+ // flow), the one-time CLA the bot asks for, and the wait for the merge. Every outside call goes through `exec`, `pick`
4
+ // and `run`, so the whole thing is tested with fakes.
5
+ import fs from 'node:fs';
6
+ import { execFileSync, spawnSync } from 'node:child_process';
7
+ import { t } from './i18n.js';
8
+
9
+ const quiet = (cmd, args) => execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, timeout: 120000 });
10
+ // a long-running, interactive command on the user's terminal (an installer, a browser sign-in)
11
+ const live = (cmd, args) => spawnSync(cmd, args, { stdio: 'inherit', shell: process.platform === 'win32', windowsHide: false });
12
+
13
+ export const TOOLS = {
14
+ git: { why: 'git', win: 'Git.Git', brew: 'git', url: 'https://git-scm.com/downloads' },
15
+ gh: { why: 'gh', win: 'GitHub.cli', brew: 'gh', url: 'https://cli.github.com' },
16
+ };
17
+
18
+ // Where a fresh install lands when the running process was started before it existed (its PATH is out of date).
19
+ export function knownDirs(platform = process.platform) {
20
+ if (platform === 'win32') return ['C:\\Program Files\\Git\\cmd', 'C:\\Program Files\\GitHub CLI', `${process.env.LOCALAPPDATA || ''}\\Programs\\Git\\cmd`];
21
+ if (platform === 'darwin') return ['/opt/homebrew/bin', '/usr/local/bin'];
22
+ return [];
23
+ }
24
+
25
+ export function addKnownDirsToPath(platform = process.platform, exists = fs.existsSync, env = process.env) {
26
+ const key = Object.keys(env).find((k) => k.toLowerCase() === 'path') || 'PATH';
27
+ const sep = platform === 'win32' ? ';' : ':'; // the separator of the system being described, not of the one running
28
+ const have = String(env[key] || '').split(sep).map((d) => d.toLowerCase());
29
+ const add = knownDirs(platform).filter((d) => d && exists(d) && !have.includes(d.toLowerCase()));
30
+ if (add.length) env[key] = `${add.join(sep)}${sep}${env[key] || ''}`;
31
+ return add;
32
+ }
33
+
34
+ // The command that installs a tool on this system, or null when it cannot be done without a password or a guess.
35
+ export function installPlan(tool, platform = process.platform) {
36
+ const spec = TOOLS[tool];
37
+ if (!spec) return null;
38
+ if (platform === 'win32') return { cmd: 'winget', args: ['install', '--id', spec.win, '-e', '--accept-source-agreements', '--accept-package-agreements'] };
39
+ if (platform === 'darwin') return { cmd: 'brew', args: ['install', spec.brew] };
40
+ return null;
41
+ }
42
+
43
+ export const has = (tool, exec = quiet) => { try { exec(tool, ['--version']); return true; } catch { return false; } };
44
+
45
+ const line = (plan) => `${plan.cmd} ${plan.args.join(' ')}`;
46
+
47
+ // One tool: present, or offered for install with enter. True when it is usable afterwards.
48
+ export async function ensureTool(tool, { exec = quiet, run = live, pick, out, platform = process.platform, exists = fs.existsSync } = {}) {
49
+ if (has(tool, exec)) return true;
50
+ addKnownDirsToPath(platform, exists);
51
+ if (has(tool, exec)) return true;
52
+ const plan = installPlan(tool, platform);
53
+ out(`\n${t('toolMissing', { tool })}`);
54
+ if (!plan) {
55
+ out(t('toolNoAuto', { tool, url: TOOLS[tool].url }));
56
+ return false;
57
+ }
58
+ const choice = await pick(t('toolQ', { tool }), [
59
+ { label: t('toolInstallNow', { tool }), hint: t('toolRuns', { cmd: line(plan) }), value: 'now' },
60
+ { label: t('toolInstallSelf'), value: 'self' },
61
+ ], 1);
62
+ if (choice !== 'now') return false;
63
+ out(`\n${t('toolInstalling', { tool })}\n`);
64
+ const r = run(plan.cmd, plan.args);
65
+ if (r?.error || (r && r.status !== 0 && r.status != null)) {
66
+ out(t('toolInstallFailed', { tool, url: TOOLS[tool].url }));
67
+ return false;
68
+ }
69
+ addKnownDirsToPath(platform, exists);
70
+ if (has(tool, exec)) {
71
+ out(t('toolInstalled', { tool }));
72
+ return true;
73
+ }
74
+ out(t('toolStillMissing', { tool }));
75
+ return false;
76
+ }
77
+
78
+ export const signedIn = (exec = quiet) => { try { exec('gh', ['auth', 'status']); return true; } catch { return false; } };
79
+
80
+ // The GitHub sign-in: the CLI's own browser flow (it shows a one-time code and opens the browser after enter).
81
+ export async function ensureSignedIn({ exec = quiet, run = live, pick, out } = {}) {
82
+ while (!signedIn(exec)) {
83
+ out(`\n${t('ghLoginNeeded')}`);
84
+ const choice = await pick(t('ghLoginQ'), [
85
+ { label: t('ghLoginNow'), hint: t('toolRuns', { cmd: 'gh auth login -h github.com -p https -w' }), value: 'now' },
86
+ { label: t('ghLoginSkip'), value: 'skip' },
87
+ ], 1);
88
+ if (choice !== 'now') return false;
89
+ out(`\n${t('ghLoginHint')}\n`);
90
+ run('gh', ['auth', 'login', '-h', 'github.com', '-p', 'https', '-w']);
91
+ }
92
+ return true;
93
+ }
94
+
95
+ // Everything submit needs on a machine with nothing on it. False when the user chose to stop or it cannot be done here.
96
+ export async function ensureGithub(env = {}) {
97
+ for (const tool of ['git', 'gh']) if (!(await ensureTool(tool, env))) return false;
98
+ return ensureSignedIn(env);
99
+ }
100
+
101
+ // ------------------------------------------------------------------------------------------------ after the PR is opened
102
+
103
+ // The state of the CLA bot's check among a pull request's checks, or null when it has not appeared yet.
104
+ export function claState(rollup) {
105
+ const e = (rollup || []).find((c) => (c.context || c.name) === 'license/cla');
106
+ return e ? String(e.state || e.conclusion || '').toUpperCase() : null;
107
+ }
108
+
109
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
110
+
111
+ // Show where the pull request stands until it is merged, needs a person, or the wait runs out. The first time the CLA
112
+ // check is not green the contributor is given the link and asked to come back with enter: signing is a web page that
113
+ // belongs to them and cannot be done for them.
114
+ export async function watchPullRequest(url, { exec = quiet, pick, out, upstream = 'SkillGauntlet/SkillGauntlet', wait = sleep, every = 10000, rounds = 60 } = {}) {
115
+ const number = String(url).split('/').pop();
116
+ let askedCla = false;
117
+ let shown = '';
118
+ out(`\n${t('prWaiting')}`);
119
+ for (let i = 0; i < rounds; i += 1) {
120
+ let view;
121
+ try { view = JSON.parse(exec('gh', ['pr', 'view', url, '--json', 'state,mergedAt,labels,statusCheckRollup'])); } catch { view = null; }
122
+ if (view) {
123
+ if (view.state === 'MERGED' || view.mergedAt) { out(`\n${t('prMerged', { url })}`); return 'merged'; }
124
+ if (view.state === 'CLOSED') { out(`\n${t('prClosed', { url })}`); return 'closed'; }
125
+ if ((view.labels || []).some((l) => l.name === 'needs-review')) { out(`\n${t('prNeedsReview', { url })}`); return 'review'; }
126
+ const cla = claState(view.statusCheckRollup);
127
+ if (cla && cla !== 'SUCCESS' && !askedCla) {
128
+ askedCla = true;
129
+ out(`\n${t('prCla', { url: `https://cla-assistant.io/${upstream}?pullRequest=${number}` })}`);
130
+ const choice = await pick(t('prClaQ'), [
131
+ { label: t('prClaDone'), value: 'wait' },
132
+ { label: t('prLater'), value: 'later' },
133
+ ], 1);
134
+ if (choice === 'later') { out(t('prLaterNote', { url })); return 'later'; }
135
+ }
136
+ const status = cla ? `${t('prStepCla')} ${cla === 'SUCCESS' ? '✓' : '…'}` : t('prStepChecks');
137
+ if (status !== shown) { out(` ${status}`); shown = status; }
138
+ }
139
+ await wait(every);
140
+ }
141
+ out(`\n${t('prTimeout', { url, m: Math.round((rounds * every) / 60000) })}`);
142
+ return 'timeout';
143
+ }
package/lib/i18n.js CHANGED
@@ -95,7 +95,7 @@ const MESSAGES = {
95
95
  staticDone: '静态层:{verdicts}',
96
96
  drafting: '正在出题(一次模型调用,可能要几分钟)…',
97
97
  hostPing: '正在确认 {host} 已登录…',
98
- hostNotReady: '{host} 没有响应,多半是还没登录。请先在它的命令行里输入 /login,登录后再重新运行。({why})',
98
+ hostNotReady: '{host} 没能完成一次试调用:{why}\n如果是没登录,请先在它的命令行里输入 /login,登录后再重新运行。',
99
99
  drafted: '出了 {q} 道题,{c} 条触发条款(已缓存,下次直接复用)',
100
100
  queriesReused: '复用缓存的 {n} 道题({file}),省一次出题调用',
101
101
  negativesChecked: '{n} 条负样本都填出了可核实的借用词',
@@ -131,6 +131,41 @@ const MESSAGES = {
131
131
  'chart.otherSkill': '改用别的技能:{name}',
132
132
  'chart.more': '…还有 {n} 题,见完整报告',
133
133
  'chart.wrongTitle': '不该触发却触发的 {n} 次',
134
+ modelAccountHint: '你的账号现在可用',
135
+ modelCatalogHint: 'CodeBuddy 目录里有,账号不一定能用',
136
+ loginQ: '要现在登录吗?',
137
+ loginNow: '现在登录 {host}(按回车)',
138
+ loginRuns: '会运行 {cmd}',
139
+ loginSkip: '先不登录,跳过触发层',
140
+ loginHintBrowser: '接下来会运行宿主自己的登录命令,一般在浏览器里完成;登录结束后会自动回到这里并重新检查。',
141
+ loginHintCodebuddy: '接下来会打开 CodeBuddy 的界面:请在里面输入 /login 完成登录,再输入 /exit 退出,就会回到这里并重新检查。',
142
+ toolMissing: '这台电脑上没有 {tool},提交结果需要它。',
143
+ toolNoAuto: '这个系统没法自动安装 {tool},请按 {url} 的说明装好,再重新运行。',
144
+ toolQ: '现在安装 {tool} 吗?',
145
+ toolInstallNow: '现在安装 {tool}(按回车)',
146
+ toolRuns: '会运行 {cmd}',
147
+ toolInstallSelf: '我自己装,稍后重新运行',
148
+ toolInstalling: '正在安装 {tool},输出就在下面,请等它结束…',
149
+ toolInstalled: '{tool} 装好了。',
150
+ toolInstallFailed: '{tool} 没装成功。请按 {url} 的说明自己装好,再重新运行。',
151
+ toolStillMissing: '装完之后还是找不到 {tool},请关掉终端重新打开,再重新运行。',
152
+ ghLoginNeeded: '还没登录 GitHub。提交结果要用你的 GitHub 账号开一个公开的 PR。',
153
+ ghLoginQ: '现在登录 GitHub 吗?',
154
+ ghLoginNow: '现在登录 GitHub(按回车)',
155
+ ghLoginSkip: '先不登录,只保存结果',
156
+ ghLoginHint: '接下来会显示一个一次性代码并打开浏览器:在网页里输入代码、点授权,终端会自动继续。',
157
+ prWaiting: '正在等 PR 的检查和合并…',
158
+ prStepCla: 'CLA 签署',
159
+ prStepChecks: '自动检查进行中',
160
+ prCla: '这是你第一次提交,需要签一次贡献者协议(CLA)。请在浏览器里打开:\n {url}\n用 GitHub 账号登录并点同意即可。',
161
+ prClaQ: '签好了吗?',
162
+ prClaDone: '我已经签好了(按回车继续等)',
163
+ prLater: '稍后再看(PR 已开,不影响)',
164
+ prLaterNote: 'PR 还在:{url}。签好 CLA 后会自动检查并合并,之后随时在浏览器里看。',
165
+ prMerged: '已合并 ✓ 这是你的提交记录:{url}',
166
+ prClosed: '这个 PR 已被关闭:{url}',
167
+ prNeedsReview: '没有自动合并,已转给维护者审核(常见原因:GitHub 账号注册不满 30 天,或今天已提交超过 5 条)。你不需要再做什么:{url}',
168
+ prTimeout: '等了 {m} 分钟还没合并,PR 还在:{url},之后随时在浏览器里看。',
134
169
  modelUnknown: '{host} 的模型清单里没有“{model}”。它现在支持:{list}。请重新选一个,或选“默认模型”。',
135
170
  updateAvail: '有新版本 {latest}(你现在用的是 {current})。',
136
171
  updateQ: '要更新吗?',
@@ -491,7 +526,7 @@ const MESSAGES = {
491
526
  staticDone: 'Static: {verdicts}',
492
527
  drafting: 'Drafting questions (one model call, may take a few minutes)…',
493
528
  hostPing: 'Checking that {host} is signed in…',
494
- hostNotReady: '{host} did not answer, most likely because it is not signed in. Run /login in its CLI first, then run this again. ({why})',
529
+ hostNotReady: '{host} could not complete a test call: {why}\nIf it is not signed in, run /login in its CLI first, then run this again.',
495
530
  drafted: '{q} queries over {c} trigger clauses (cached for reuse)',
496
531
  queriesReused: 'reusing {n} cached questions ({file}), one drafting call saved',
497
532
  negativesChecked: 'all {n} negatives name verifiable borrowed wording',
@@ -527,6 +562,41 @@ const MESSAGES = {
527
562
  'chart.otherSkill': 'used another skill: {name}',
528
563
  'chart.more': '…{n} more, see the full report',
529
564
  'chart.wrongTitle': '{n} times it triggered when it should not have',
565
+ modelAccountHint: 'usable with your account now',
566
+ modelCatalogHint: 'in CodeBuddy\'s catalog; your account may not have it',
567
+ loginQ: 'Sign in now?',
568
+ loginNow: 'Sign in to {host} now (press enter)',
569
+ loginRuns: 'runs {cmd}',
570
+ loginSkip: 'Not now, skip the trigger layer',
571
+ loginHintBrowser: 'The host\'s own login command runs next, usually finishing in your browser; when it ends you come back here and the check is made again.',
572
+ loginHintCodebuddy: 'CodeBuddy\'s interface opens next: type /login there to sign in, then /exit to leave; you come back here and the check is made again.',
573
+ toolMissing: '{tool} is not installed on this computer, and submitting needs it.',
574
+ toolNoAuto: 'This system cannot install {tool} automatically; install it from {url}, then run this again.',
575
+ toolQ: 'Install {tool} now?',
576
+ toolInstallNow: 'Install {tool} now (press enter)',
577
+ toolRuns: 'runs {cmd}',
578
+ toolInstallSelf: 'I will install it myself and run this again',
579
+ toolInstalling: 'Installing {tool}; its output is shown below, please wait for it to finish…',
580
+ toolInstalled: '{tool} is installed.',
581
+ toolInstallFailed: '{tool} did not install. Install it yourself from {url}, then run this again.',
582
+ toolStillMissing: '{tool} still was not found after installing. Close this terminal, open a new one and run this again.',
583
+ ghLoginNeeded: 'You are not signed in to GitHub. Submitting opens a public pull request from your GitHub account.',
584
+ ghLoginQ: 'Sign in to GitHub now?',
585
+ ghLoginNow: 'Sign in to GitHub now (press enter)',
586
+ ghLoginSkip: 'Not now, just keep the results',
587
+ ghLoginHint: 'A one-time code is shown next and your browser opens: type the code there and authorize, and this terminal carries on by itself.',
588
+ prWaiting: 'Waiting for the pull request\'s checks and merge…',
589
+ prStepCla: 'CLA signed',
590
+ prStepChecks: 'automatic checks running',
591
+ prCla: 'This is your first submission, so you sign the contributor agreement (CLA) once. Open this in your browser:\n {url}\nSign in with your GitHub account and agree.',
592
+ prClaQ: 'Signed?',
593
+ prClaDone: 'I have signed it (press enter to keep waiting)',
594
+ prLater: 'Look later (the pull request is open either way)',
595
+ prLaterNote: 'The pull request is still there: {url}. Once the CLA is signed it is checked and merged automatically; look any time in the browser.',
596
+ prMerged: 'Merged ✓ Here is your submission record: {url}',
597
+ prClosed: 'This pull request was closed: {url}',
598
+ prNeedsReview: 'It was not merged automatically and has gone to a maintainer for review (common reasons: the GitHub account is under 30 days old, or more than 5 submissions today). You need do nothing more: {url}',
599
+ prTimeout: 'Waited {m} minutes and it is not merged yet. The pull request is still there: {url}; look any time in the browser.',
530
600
  modelUnknown: '{host} does not list a model called "{model}". It currently supports: {list}. Pick another, or choose the default model.',
531
601
  updateAvail: 'A newer version {latest} is available (you are on {current}).',
532
602
  updateQ: 'Update?',
@@ -882,7 +952,7 @@ Layer 0 always runs first: it is free, and it disqualifies most problems before
882
952
  staticDone: '静的チェック:{verdicts}',
883
953
  drafting: 'テスト用の質問を作成中(モデル呼び出し 1 回、数分かかることがあります)…',
884
954
  hostPing: '{host} にサインイン済みか確認中…',
885
- hostNotReady: '{host} から応答がありません。サインインしていない可能性が高いです。先にその CLI で /login を実行してから、もう一度実行してください。({why})',
955
+ hostNotReady: '{host} はテスト呼び出しを完了できませんでした:{why}\nサインインしていない場合は、先にその CLI で /login を実行してから、もう一度実行してください。',
886
956
  drafted: '{c} 件の発動条項について {q} 問を作成しました(キャッシュ済み、次回は再利用)',
887
957
  queriesReused: 'キャッシュ済みの {n} 問({file})を再利用、質問作成の呼び出しを 1 回節約',
888
958
  negativesChecked: '否定例 {n} 件すべてに検証可能な借用語があります',
@@ -971,6 +1041,41 @@ Layer 0 always runs first: it is free, and it disqualifies most problems before
971
1041
  'chart.otherSkill': '別のスキルを使用:{name}',
972
1042
  'chart.more': '…ほか {n} 件は詳細レポートへ',
973
1043
  'chart.wrongTitle': '発動すべきでないのに発動した {n} 回',
1044
+ modelAccountHint: 'このアカウントで今使えます',
1045
+ modelCatalogHint: 'CodeBuddy のカタログにあります(アカウントで使えるとは限りません)',
1046
+ loginQ: '今すぐサインインしますか?',
1047
+ loginNow: '{host} に今すぐサインイン(Enter)',
1048
+ loginRuns: '{cmd} を実行します',
1049
+ loginSkip: '今はしない(発動率の測定をスキップ)',
1050
+ loginHintBrowser: '次にホスト自身のログインコマンドを実行します(通常はブラウザで完了)。終わるとここに戻り、再度確認します。',
1051
+ loginHintCodebuddy: '次に CodeBuddy の画面が開きます。そこで /login と入力してサインインし、/exit で終了するとここに戻って再度確認します。',
1052
+ toolMissing: 'このコンピューターに {tool} がありません。結果の提出に必要です。',
1053
+ toolNoAuto: 'このシステムでは {tool} を自動インストールできません。{url} の手順でインストールしてから、もう一度実行してください。',
1054
+ toolQ: '今すぐ {tool} をインストールしますか?',
1055
+ toolInstallNow: '{tool} を今すぐインストール(Enter)',
1056
+ toolRuns: '{cmd} を実行します',
1057
+ toolInstallSelf: '自分でインストールして、あとでもう一度実行する',
1058
+ toolInstalling: '{tool} をインストール中です。出力は下に表示されます。終わるまでお待ちください…',
1059
+ toolInstalled: '{tool} をインストールしました。',
1060
+ toolInstallFailed: '{tool} をインストールできませんでした。{url} の手順で自分でインストールしてから、もう一度実行してください。',
1061
+ toolStillMissing: 'インストール後も {tool} が見つかりません。ターミナルを閉じて開き直し、もう一度実行してください。',
1062
+ ghLoginNeeded: 'GitHub にサインインしていません。提出はあなたの GitHub アカウントで公開プルリクエストを作成します。',
1063
+ ghLoginQ: '今すぐ GitHub にサインインしますか?',
1064
+ ghLoginNow: 'GitHub に今すぐサインイン(Enter)',
1065
+ ghLoginSkip: '今はしない(結果は保存のみ)',
1066
+ ghLoginHint: '次にワンタイムコードが表示されブラウザが開きます。そこでコードを入力して承認すると、このターミナルは自動で続行します。',
1067
+ prWaiting: 'プルリクエストのチェックとマージを待っています…',
1068
+ prStepCla: 'CLA 署名',
1069
+ prStepChecks: '自動チェック実行中',
1070
+ prCla: '初めての提出なので、貢献者契約(CLA)に一度だけ署名します。ブラウザで次を開いてください:\n {url}\nGitHub アカウントでサインインして同意します。',
1071
+ prClaQ: '署名しましたか?',
1072
+ prClaDone: '署名しました(Enter で待機を続ける)',
1073
+ prLater: '後で見る(プルリクエストは作成済みです)',
1074
+ prLaterNote: 'プルリクエストはそのまま残っています:{url}。CLA に署名すると自動でチェック・マージされます。いつでもブラウザで確認できます。',
1075
+ prMerged: 'マージされました ✓ あなたの提出記録:{url}',
1076
+ prClosed: 'このプルリクエストはクローズされました:{url}',
1077
+ prNeedsReview: '自動マージされず、メンテナーのレビューに回りました(主な理由:GitHub アカウントが作成 30 日未満、または本日 5 件を超える提出)。これ以上の操作は不要です:{url}',
1078
+ prTimeout: '{m} 分待ちましたがまだマージされていません。プルリクエストは残っています:{url}。いつでもブラウザで確認できます。',
974
1079
  modelUnknown: '{host} のモデル一覧に「{model}」はありません。現在対応:{list}。別のものを選ぶか、既定のモデルを選んでください。',
975
1080
  updateAvail: '新しいバージョン {latest} があります(現在は {current})。',
976
1081
  updateQ: '更新しますか?',
package/lib/models.js CHANGED
@@ -1,5 +1,8 @@
1
1
  // Model names a host will accept, read from the host itself so the list is the user's own (it depends on the
2
2
  // account and the custom models they set up) and never goes stale inside this package. Any failure gives [].
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
3
6
  import { execFileSync, execSync } from 'node:child_process';
4
7
  import { launchSpec } from './spawn.js';
5
8
 
@@ -44,3 +47,35 @@ export function unknownModel(model, known) {
44
47
  if (!model || !known?.length || known.includes(model) || /^deepseek/i.test(model)) return null;
45
48
  return known;
46
49
  }
50
+
51
+ // `codebuddy --help` lists only what the account is signed in for right now (it can shrink to two entries), while the
52
+ // model catalog CodeBuddy last fetched stays in its local cache. Every string array called "models" inside those cache
53
+ // files is part of that catalog; the union with the help list is what the user remembers having been offered. A name
54
+ // from the catalog only is not promised to work for the account, so the list marks it and a failure shows the CLI's words.
55
+ export function catalogFromTexts(texts) {
56
+ const names = new Set();
57
+ const walk = (node, depth) => {
58
+ if (!node || depth > 8) return;
59
+ if (Array.isArray(node)) { node.forEach((n) => walk(n, depth + 1)); return; }
60
+ if (typeof node !== 'object') return;
61
+ for (const [k, v] of Object.entries(node)) {
62
+ if (k === 'models' && Array.isArray(v)) v.filter((x) => typeof x === 'string' && /^[\w.:-]{2,80}$/.test(x)).forEach((x) => names.add(x));
63
+ else walk(v, depth + 1);
64
+ }
65
+ };
66
+ for (const text of texts) { try { walk(JSON.parse(text), 0); } catch { /* not JSON */ } }
67
+ return [...names];
68
+ }
69
+
70
+ export function codebuddyCatalog(dir = path.join(os.homedir(), '.codebuddy', 'local_storage')) {
71
+ const texts = [];
72
+ try {
73
+ for (const f of fs.readdirSync(dir)) {
74
+ if (!f.endsWith('.info')) continue;
75
+ const file = path.join(dir, f);
76
+ if (fs.statSync(file).size > 2 * 1024 * 1024) continue;
77
+ texts.push(fs.readFileSync(file, 'utf8'));
78
+ }
79
+ } catch { /* no cache: only the help list */ }
80
+ return catalogFromTexts(texts);
81
+ }
@@ -0,0 +1,48 @@
1
+ // The wait for the trials. On a terminal the bar is drawn at once (0 of N) and redrawn every second with the seconds
2
+ // elapsed, so there is something on screen from the moment the trials start rather than after the first one finishes;
3
+ // a trial can take many seconds. `total` is known when a run starts; a resumed run learns it from its first trial.
4
+ // Without a terminal each finished trial is one line. finish() stops the redraw and ends the line.
5
+ import { t } from './i18n.js';
6
+ import { bar } from './chart.js';
7
+
8
+ export function makeProgress(total = 0, {
9
+ tty = Boolean(process.stdout.isTTY),
10
+ write = (s) => process.stdout.write(s),
11
+ line = (s) => process.stdout.write(`${s}\n`),
12
+ now = Date.now,
13
+ every = setInterval,
14
+ stopEvery = clearInterval,
15
+ } = {}) {
16
+ const started = now();
17
+ let finished = 0;
18
+ let of = total;
19
+ let timer = null;
20
+ let open = false;
21
+ const draw = () => {
22
+ open = true;
23
+ write(`\r\x1b[2K ${t('progressLine')} ${bar(of ? finished / of : 0, 24)} ${finished}/${of || '?'} ${t('waitElapsed', { s: Math.floor((now() - started) / 1000) })}`);
24
+ };
25
+ const stop = () => {
26
+ if (timer) stopEvery(timer);
27
+ timer = null;
28
+ if (tty && open) { write('\n'); open = false; }
29
+ };
30
+ if (tty && total) {
31
+ draw();
32
+ timer = every(draw, 1000);
33
+ }
34
+ const onTrial = ({ row, done, total: n }) => {
35
+ finished = done;
36
+ of = n;
37
+ if (tty) {
38
+ if (!timer) timer = every(draw, 1000);
39
+ draw();
40
+ if (done >= n) stop();
41
+ return;
42
+ }
43
+ const mark = { triggered: '✓', not_triggered: '·', other_skill: '↷' }[row.verdict] || '!';
44
+ line(` [${done}/${n}] ${mark} ${row.queryId}#${row.run} → ${row.verdict}`);
45
+ };
46
+ onTrial.finish = stop;
47
+ return onTrial;
48
+ }
package/lib/submit.js CHANGED
@@ -7,7 +7,8 @@ import fs from 'node:fs';
7
7
  import os from 'node:os';
8
8
  import path from 'node:path';
9
9
  import { execFileSync } from 'node:child_process';
10
- import { say, confirm } from './ui.js';
10
+ import { say, confirm, choose, hasTerminal } from './ui.js';
11
+ import { ensureGithub, watchPullRequest } from './github.js';
11
12
  import { errorText, t } from './i18n.js';
12
13
  import {
13
14
  SubmitError, buildPackage, gitFacts, gradeOfSummary, normalizeRepo, skillShaAtCommit, slug, writePackage,
@@ -139,6 +140,9 @@ export async function cmdSubmit(args, { exec = defaultExec, verifyRemote = skill
139
140
  const opts = parseSubmitArgs(args);
140
141
  const runDir = path.resolve(opts.dir);
141
142
  const skillDir = opts['skill-dir'] ? path.resolve(opts['skill-dir']) : path.resolve(runDir, '..', '..', '..');
143
+ // On a terminal, make sure git, the GitHub CLI and a sign-in exist before anything else: a machine with nothing on
144
+ // it gets each offered with enter. Skipped when the contributor's name was given, and under test fakes.
145
+ if (hasTerminal() && !opts['github-user'] && exec === defaultExec) await ensureGithub({ pick: choose, out: say });
142
146
  const gh = ghState(exec);
143
147
  const contributor = opts['github-user'] || gh.user;
144
148
  if (!contributor) throw new SubmitError('submitNoUser');
@@ -196,6 +200,8 @@ export async function cmdSubmit(args, { exec = defaultExec, verifyRemote = skill
196
200
  const url = openPullRequest({ pkgDir, pkg, gh, exec });
197
201
  say(`\n${t('submitPrOpened', { url })}`);
198
202
  say(t('submitNext'));
203
+ // the CLA link and the wait for the merge, with enter to continue (under test fakes there is nothing to wait for)
204
+ if (hasTerminal() && exec === defaultExec) await watchPullRequest(url, { pick: choose, out: say });
199
205
  return { pkgDir, pkg, url };
200
206
  } catch (err) {
201
207
  say(`\n${t('submitGhFail', { msg: String(err.stderr || err.message).trim().split(/\r?\n/).slice(-3).join(' ') })}`);
package/lib/trial.js CHANGED
@@ -124,18 +124,27 @@ const firstLine = (text) => String(text || '').split(LINE_BREAK).map((l) => l.tr
124
124
  // What a host CLI answered, as an error reason: the CLI's own words when it gave any. A CLI can exit 0 with nothing on
125
125
  // stdout and its complaint on stderr (CodeBuddy does for a custom model without an endpoint url), so a clean exit
126
126
  // status proves nothing; an empty or unreadable stdout is the failure.
127
+ // A CLI that fails in --output-format json still prints one JSON result: the reason is its `result` text ("Not logged in
128
+ // · Please run /login"), which sits after a long block of usage fields, so the raw first line is no reason at all.
129
+ // null when the text is not JSON; otherwise the reason ('' when the result is not an error).
130
+ function jsonProblem(out) {
131
+ let parsed;
132
+ try { parsed = JSON.parse(out); } catch { return null; }
133
+ const last = Array.isArray(parsed) ? parsed.filter((m) => m?.type === 'result').pop() : parsed;
134
+ if (!last || typeof last !== 'object') return '';
135
+ if (!last.is_error) return '';
136
+ return firstLine(typeof last.result === 'string' ? last.result : JSON.stringify(last.result ?? '')) || firstLine(last.error) || 'the CLI reported an error';
137
+ }
138
+
127
139
  export function hostAnswerProblem(r, timeout) {
128
140
  if (r.error) return r.error.code === 'ETIMEDOUT' ? `no answer within ${timeout}s` : String(r.error.message).split(LINE_BREAK)[0];
129
141
  if (r.signal) return `no answer within ${timeout}s`;
130
142
  const out = String(r.stdout || '');
131
- if (r.status !== 0) return firstLine(r.stderr) || firstLine(out) || `exit status ${r.status}`;
143
+ const fromJson = out.trim() ? jsonProblem(out) : null;
144
+ if (r.status !== 0) return firstLine(r.stderr) || fromJson || firstLine(out).slice(0, 160) || `exit status ${r.status}`;
132
145
  if (!out.trim()) return firstLine(r.stderr) || 'the CLI answered with nothing';
133
- try {
134
- const parsed = JSON.parse(out);
135
- const last = Array.isArray(parsed) ? parsed.filter((m) => m?.type === 'result').pop() : parsed;
136
- if (last?.is_error) return firstLine(last.result) || 'the CLI reported an error';
137
- } catch { return firstLine(out); }
138
- return null;
146
+ if (fromJson === null) return firstLine(out).slice(0, 160);
147
+ return fromJson || null;
139
148
  }
140
149
 
141
150
  export function pingHost(host, { bin, model = null, timeout = 90, spawn = spawnSync } = {}) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillgauntlet",
3
- "version": "1.3.1",
3
+ "version": "1.3.3",
4
4
  "description": "Check an agent skill before you install it: context cost, permission surface, code risk, instruction hijacking, description quality, trigger rate and gain.",
5
5
  "type": "module",
6
6
  "bin": {