@mmerterden/multi-agent-pipeline 20.16.0 → 20.17.1

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.
Files changed (60) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +6 -5
  3. package/README.tr.md +6 -5
  4. package/docs/architecture.md +3 -3
  5. package/docs/ecosystem.md +5 -5
  6. package/docs/facts.json +14 -12
  7. package/docs/features.md +10 -2
  8. package/install/catalog-history.json +1 -1
  9. package/install/claude.mjs +41 -6
  10. package/install/codex.mjs +3 -3
  11. package/manifest.json +62 -49
  12. package/package.json +1 -1
  13. package/pipeline/commands/multi-agent/bug-bash/SKILL.md +154 -0
  14. package/pipeline/commands/multi-agent/review/SKILL.md +13 -9
  15. package/pipeline/commands/multi-agent/setup/SKILL.md +8 -6
  16. package/pipeline/commands/multi-agent/sync/SKILL.md +4 -3
  17. package/pipeline/commands/multi-agent/update/SKILL.md +5 -1
  18. package/pipeline/commands/sim-test.md +71 -6
  19. package/pipeline/lib/plugin-skill-roots.mjs +80 -0
  20. package/pipeline/lib/usage-lifecycle.mjs +83 -0
  21. package/pipeline/multi-agent-refs/cross-cli-contract.md +20 -4
  22. package/pipeline/multi-agent-refs/features/assertion-judge.md +76 -0
  23. package/pipeline/multi-agent-refs/features/exploratory-testing.md +140 -0
  24. package/pipeline/multi-agent-refs/features/skill-conformance.md +1 -1
  25. package/pipeline/multi-agent-refs/features/usage-reporting.md +129 -36
  26. package/pipeline/multi-agent-refs/features/valven.md +30 -3
  27. package/pipeline/multi-agent-refs/help/en.md +2 -0
  28. package/pipeline/multi-agent-refs/help/tr.md +2 -0
  29. package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
  30. package/pipeline/multi-agent-refs/phases/phase-4-commit.md +2 -2
  31. package/pipeline/schemas/bug-bash-verification.schema.json +41 -0
  32. package/pipeline/schemas/explore-run.schema.json +108 -0
  33. package/pipeline/schemas/prefs.schema.json +24 -3
  34. package/pipeline/scripts/_stack-routing.mjs +2 -0
  35. package/pipeline/scripts/bug-bash.mjs +379 -0
  36. package/pipeline/scripts/explore-findings.mjs +410 -0
  37. package/pipeline/scripts/gen-skills-index.mjs +1 -2
  38. package/pipeline/scripts/pr-timeline.mjs +135 -0
  39. package/pipeline/scripts/pre-commit-check.sh +4 -2
  40. package/pipeline/scripts/skill-conformance.mjs +17 -71
  41. package/pipeline/scripts/smoke-cross-cli-behavior.sh +1 -1
  42. package/pipeline/scripts/uninstall.mjs +29 -8
  43. package/pipeline/scripts/usage-identity.mjs +311 -26
  44. package/pipeline/scripts/usage-register.mjs +268 -62
  45. package/pipeline/scripts/usage-report.mjs +59 -7
  46. package/pipeline/scripts/valven-gate.mjs +292 -11
  47. package/pipeline/skills/.skill-manifest.json +18 -10
  48. package/pipeline/skills/.skills-index.json +28 -6
  49. package/pipeline/skills/shared/README.md +6 -4
  50. package/pipeline/skills/shared/core/multi-agent-bug-bash/SKILL.md +150 -0
  51. package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +1 -1
  52. package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +5 -1
  53. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +4 -3
  54. package/pipeline/skills/shared/core/multi-agent-update/SKILL.md +4 -1
  55. package/pipeline/skills/shared/external/e2e-testing/SKILL.md +124 -0
  56. package/pipeline/skills/shared/external/e2e-testing/references/document-fixtures.md +65 -0
  57. package/pipeline/skills/shared/external/valven-rules/SKILL.md +93 -0
  58. package/pipeline/skills/shared/{core → external}/valven-rules/references/rules.yml +52 -6
  59. package/pipeline/skills/skills-index.md +4 -2
  60. package/pipeline/skills/shared/core/valven-rules/SKILL.md +0 -62
@@ -81,11 +81,15 @@ export const PIPELINE_AGENT_FILES = [
81
81
  * Non-multi-agent core skills the installer ships from
82
82
  * pipeline/skills/shared/core/ (the multi-agent* dirs are matched by prefix).
83
83
  */
84
- export const PIPELINE_CORE_SKILL_DIRS = [
85
- "apple-archive-compliance",
86
- "google-play-compliance",
87
- "valven-rules",
88
- ];
84
+ export const PIPELINE_CORE_SKILL_DIRS = ["apple-archive-compliance", "google-play-compliance"];
85
+
86
+ /**
87
+ * Skills an older install placed in the skills dir that now reach the host from
88
+ * a marketplace plugin (Claude Code) or the external catalog (Copilot, Codex).
89
+ * An install upgraded since then has already pruned them; one that was not
90
+ * still carries the old copy, so uninstall removes these names too.
91
+ */
92
+ export const PIPELINE_RETIRED_SKILL_DIRS = ["valven-rules"];
89
93
 
90
94
  /**
91
95
  * Files the installer ships into ~/.claude/templates (install/templates/).
@@ -323,7 +327,7 @@ function rmManifestFiles(hostDir, keepInLib) {
323
327
  * @param {string} path
324
328
  */
325
329
  function rmIfExists(path) {
326
- if (!existsSync(path)) return false;
330
+ if (!existsSync(path) && !isSymlink(path)) return false;
327
331
  if (dryRun) {
328
332
  report("would remove", path);
329
333
  return false;
@@ -538,6 +542,15 @@ function rmExternalDeliveredSkills(skillsDir) {
538
542
  return n;
539
543
  }
540
544
 
545
+ /** True for a symlink, dangling or not; existsSync follows the link and misses a dangling one. */
546
+ function isSymlink(path) {
547
+ try {
548
+ return lstatSync(path).isSymbolicLink();
549
+ } catch {
550
+ return false;
551
+ }
552
+ }
553
+
541
554
  function rmMatchingDirs(parent, predicate) {
542
555
  if (!existsSync(parent)) return 0;
543
556
  let count = 0;
@@ -879,7 +892,11 @@ export async function main() {
879
892
  name === "figma-common" ||
880
893
  name === "figma-to-component",
881
894
  );
882
- n += rmMatchingDirs(skills, (name) => PIPELINE_CORE_SKILL_DIRS.includes(name));
895
+ n += rmMatchingDirs(
896
+ skills,
897
+ (name) =>
898
+ PIPELINE_CORE_SKILL_DIRS.includes(name) || PIPELINE_RETIRED_SKILL_DIRS.includes(name),
899
+ );
883
900
  n += rmExternalDeliveredSkills(skills);
884
901
  if (n > 0) console.log(` removed ${n} skill dir(s) under ${skills}`);
885
902
  rmIfExists(join(skills, ".skills-index.json"));
@@ -914,7 +931,11 @@ export async function main() {
914
931
  name === "figma-common" ||
915
932
  name === "figma-to-component",
916
933
  );
917
- n += rmMatchingDirs(skills, (name) => PIPELINE_CORE_SKILL_DIRS.includes(name));
934
+ n += rmMatchingDirs(
935
+ skills,
936
+ (name) =>
937
+ PIPELINE_CORE_SKILL_DIRS.includes(name) || PIPELINE_RETIRED_SKILL_DIRS.includes(name),
938
+ );
918
939
  n += rmPluginDeliveredSkills(skills);
919
940
  n += rmExternalDeliveredSkills(skills);
920
941
  if (n > 0) console.log(` removed ${n} skill dir(s) under ${skills}`);
@@ -2,23 +2,44 @@
2
2
  /**
3
3
  * usage-identity.mjs - the GitHub account this machine reports usage under.
4
4
  *
5
- * The reporting identity is the logged-in GitHub account that can read the
6
- * pipeline repository (`usageLog.accessRepo`, default
5
+ * The reporting identity is the GitHub account on this machine that can read
6
+ * the pipeline repository (`usageLog.accessRepo`, default
7
7
  * mmerterden/multi-agent-pipeline). Access to that repository is what grants
8
8
  * use of the pipeline, so it is also what decides whose name a run carries.
9
9
  * The account `gh` happens to have active is not used as such: a machine with
10
- * a work account and a personal account logged in reports under whichever of
11
- * the two actually holds access.
10
+ * a work account and a personal account reports under whichever of the two
11
+ * actually holds access.
12
12
  *
13
- * Resolution: every github.com account `gh auth status` lists is checked once,
14
- * in order `usageLog.reportAs` (when it is one of them), the active account,
15
- * then the rest. Each check runs `gh api repos/<accessRepo>` with that
16
- * account's token in the child's environment (GH_TOKEN), never on argv and
17
- * never written anywhere. The first account that can read the repository wins.
13
+ * Resolution stops at the first account that can read the repository:
18
14
  *
19
- * The result is cached in prefs as `usageLog.login` + `usageLog.loginCheckedAt`
20
- * (and `usageLog.loginRepo`; `usageLog.loginReason` says why none qualified), re-checked after LOGIN_TTL_MS, or after
21
- * NO_LOGIN_TTL_MS when no account had access, or on `--refresh`.
15
+ * 1. gh: every github.com account `gh auth status` lists, in order
16
+ * `usageLog.reportAs` (when it is one of them), the active account, then
17
+ * the rest. Each check runs `gh api repos/<accessRepo>` with that
18
+ * account's token in the child's environment (GH_TOKEN).
19
+ * 2. credential: the token git's credential helper holds for
20
+ * https://github.com (`git credential fill`, prompts disabled). Its login
21
+ * comes from `GET /user`, its access from `GET /repos/<accessRepo>`, both
22
+ * through gh with GH_TOKEN, or straight to the API when gh is missing.
23
+ * 3. ssh: each private key `ssh -G github.com` names, plus ~/.ssh/id_ed25519,
24
+ * id_ecdsa and id_rsa, when its `.pub` sits next to it. `ssh -T` with only
25
+ * that key names the login ("Hi <login>!"), and
26
+ * `git ls-remote git@github.com:<accessRepo>.git HEAD` with the same key
27
+ * proves access. Batch mode throughout, so a key that needs a passphrase
28
+ * and is not in ssh-agent is skipped rather than prompted for.
29
+ *
30
+ * No token is ever put on an argv, printed or written anywhere. A missing gh,
31
+ * git or ssh binary, or any failed check, only means "no candidate".
32
+ *
33
+ * When no account qualifies the login is null and `reason` says what was tried
34
+ * in one line; nothing is reported under another account.
35
+ *
36
+ * The result is cached in prefs as `usageLog.login`, `usageLog.loginMethod`
37
+ * (gh | credential | ssh), `usageLog.loginKey` (the key path, ssh only),
38
+ * `usageLog.loginRepo`, `usageLog.loginCheckedAt` and `usageLog.loginReason`
39
+ * (why none qualified), re-checked after LOGIN_TTL_MS, after NO_LOGIN_TTL_MS
40
+ * when no account had access, when the cached key file is gone, or on
41
+ * `--refresh`. A cache entry marked `usageLog.loginFallback` (written by
42
+ * 20.17.0) is never served.
22
43
  *
23
44
  * Usage:
24
45
  * node usage-identity.mjs [--refresh] [--prefs <path>] [--json]
@@ -30,7 +51,7 @@
30
51
 
31
52
  import { existsSync, readFileSync } from "node:fs";
32
53
  import { execFileSync, spawnSync } from "node:child_process";
33
- import { homedir } from "node:os";
54
+ import { homedir, userInfo } from "node:os";
34
55
  import { join } from "node:path";
35
56
  import { pathToFileURL } from "node:url";
36
57
  import { lockTimeoutFrom, updateJsonFileSync } from "../lib/json-file-lock.mjs";
@@ -39,7 +60,13 @@ export const ACCESS_REPO_DEFAULT = "mmerterden/multi-agent-pipeline";
39
60
  export const LOGIN_TTL_MS = 7 * 24 * 3_600_000;
40
61
  export const NO_LOGIN_TTL_MS = 24 * 3_600_000;
41
62
  const GH_TIMEOUT_MS = 6000;
63
+ export const SSH_TIMEOUT_MS = 12000;
64
+ const SSH_CONNECT_TIMEOUT_S = 8;
65
+ const MAX_SSH_KEYS = 8;
42
66
  const HOST = "github.com";
67
+ const API_DEFAULT = "https://api.github.com";
68
+ const DEFAULT_KEYS = ["id_ed25519", "id_ecdsa", "id_rsa"];
69
+ export const LOGIN_METHODS = ["gh", "credential", "ssh"];
43
70
  const LOGIN_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,38}$/;
44
71
  const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
45
72
 
@@ -59,6 +86,28 @@ function ghBin(env) {
59
86
  return env.MA_GH_BIN || "gh";
60
87
  }
61
88
 
89
+ function gitBin(env) {
90
+ return env.MA_GIT_BIN || "git";
91
+ }
92
+
93
+ function sshBin(env) {
94
+ return env.MA_SSH_BIN || "ssh";
95
+ }
96
+
97
+ export function sshKeygenBin(env = process.env) {
98
+ return env.MA_SSH_KEYGEN_BIN || "ssh-keygen";
99
+ }
100
+
101
+ function homeOf(env) {
102
+ return env.HOME || homedir();
103
+ }
104
+
105
+ /** `~/...` for a path under the home directory, for status lines. */
106
+ export function displayPath(path, env = process.env) {
107
+ const home = homeOf(env);
108
+ return path && home && path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
109
+ }
110
+
62
111
  export function accessRepoOf(prefs) {
63
112
  const r = prefs?.global?.usageLog?.accessRepo;
64
113
  return typeof r === "string" && REPO_RE.test(r.trim()) ? r.trim() : ACCESS_REPO_DEFAULT;
@@ -157,8 +206,182 @@ function orderCandidates(accounts, preferred) {
157
206
  }
158
207
 
159
208
  /**
160
- * Check each logged-in account against the access repository, live.
161
- * Returns { login, reason } where login is null when no account has access.
209
+ * The token git's credential helper holds for https://github.com, or "".
210
+ * Prompts are disabled (terminal, askpass and helper UI), so a
211
+ * machine without a stored credential answers at once. The token is read from
212
+ * the helper's stdout and only ever passed on in a child's environment.
213
+ */
214
+ export function credentialToken(env = process.env) {
215
+ const r = spawnSync(gitBin(env), ["credential", "fill"], {
216
+ input: `protocol=https\nhost=${HOST}\n\n`,
217
+ encoding: "utf8",
218
+ timeout: GH_TIMEOUT_MS,
219
+ stdio: ["pipe", "pipe", "ignore"],
220
+ env: {
221
+ ...env,
222
+ GIT_TERMINAL_PROMPT: "0",
223
+ GCM_INTERACTIVE: "never",
224
+ GIT_ASKPASS: "",
225
+ SSH_ASKPASS: "",
226
+ },
227
+ });
228
+ if (r.status !== 0 || typeof r.stdout !== "string") return "";
229
+ const line = r.stdout.split("\n").find((l) => l.startsWith("password="));
230
+ return line ? line.slice("password=".length).trim() : "";
231
+ }
232
+
233
+ function apiBase(env) {
234
+ const v = String(env.MA_GITHUB_API_URL || "");
235
+ return /^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/.test(v) ? v : API_DEFAULT;
236
+ }
237
+
238
+ const API_CHECK = `
239
+ const base = process.env.MA_CHECK_API;
240
+ const repo = process.env.MA_CHECK_REPO;
241
+ const headers = {
242
+ Authorization: "Bearer " + process.env.MA_CHECK_TOKEN,
243
+ Accept: "application/vnd.github+json",
244
+ "User-Agent": "multi-agent-pipeline",
245
+ };
246
+ const get = (path) =>
247
+ fetch(base + path, { headers, redirect: "error", signal: AbortSignal.timeout(${GH_TIMEOUT_MS}) });
248
+ (async () => {
249
+ const u = await get("/user");
250
+ if (!u.ok) process.exit(1);
251
+ const login = (await u.json())?.login;
252
+ const r = await get("/repos/" + repo);
253
+ if (!r.ok) process.exit(1);
254
+ process.stdout.write(String(login || ""));
255
+ })().catch(() => process.exit(1));
256
+ `;
257
+
258
+ /**
259
+ * The login a token belongs to, when that login can read `repo`; else null.
260
+ * Through gh when it is installed, otherwise a direct API call from a child
261
+ * node process. Either way the token travels in the child's environment.
262
+ */
263
+ function tokenLoginWithAccess(token, repo, env) {
264
+ const who = spawnSync(ghBin(env), ["api", "user", "--jq", ".login"], {
265
+ encoding: "utf8",
266
+ timeout: GH_TIMEOUT_MS,
267
+ stdio: ["ignore", "pipe", "ignore"],
268
+ env: { ...env, GH_TOKEN: token, GH_HOST: HOST, GITHUB_TOKEN: "" },
269
+ });
270
+ if (who.error?.code === "ENOENT") {
271
+ const r = spawnSync(process.execPath, ["-e", API_CHECK], {
272
+ encoding: "utf8",
273
+ timeout: GH_TIMEOUT_MS * 2,
274
+ stdio: ["ignore", "pipe", "ignore"],
275
+ env: { ...env, MA_CHECK_TOKEN: token, MA_CHECK_REPO: repo, MA_CHECK_API: apiBase(env) },
276
+ });
277
+ const login = (r.stdout || "").trim();
278
+ return r.status === 0 && LOGIN_RE.test(login) ? login : null;
279
+ }
280
+ const login = (who.stdout || "").trim();
281
+ if (who.status !== 0 || !LOGIN_RE.test(login)) return null;
282
+ return canRead(repo, token, env) ? login : null;
283
+ }
284
+
285
+ function shQuote(s) {
286
+ return `'${String(s).replace(/'/g, "'\\''")}'`;
287
+ }
288
+
289
+ const SSH_FLAGS = [
290
+ "-o",
291
+ "IdentitiesOnly=yes",
292
+ "-o",
293
+ "BatchMode=yes",
294
+ "-o",
295
+ `ConnectTimeout=${SSH_CONNECT_TIMEOUT_S}`,
296
+ "-o",
297
+ "StrictHostKeyChecking=accept-new",
298
+ ];
299
+
300
+ function sshEnv(env) {
301
+ return { ...env, SSH_ASKPASS_REQUIRE: "never", GIT_TERMINAL_PROMPT: "0" };
302
+ }
303
+
304
+ function expandIdentity(raw, env) {
305
+ const home = homeOf(env);
306
+ let user = env.USER || "";
307
+ if (!user) {
308
+ try {
309
+ user = userInfo().username;
310
+ } catch {
311
+ user = "";
312
+ }
313
+ }
314
+ const parts = raw
315
+ .trim()
316
+ .replace(/^"(.*)"$/, "$1")
317
+ .replace(/^~(?=\/|$)/, home)
318
+ .split("%%")
319
+ .map((part) => part.replace(/%d/g, home).replace(/%u/g, user).replace(/%h/g, HOST));
320
+ return parts.some((part) => part.includes("%")) ? null : parts.join("%");
321
+ }
322
+
323
+ function usableKey(path) {
324
+ if (!path) return false;
325
+ if (path.endsWith(".pub")) return existsSync(path);
326
+ return existsSync(path) && existsSync(`${path}.pub`);
327
+ }
328
+
329
+ /**
330
+ * Private-key paths worth trying for github.com: what `ssh -G github.com`
331
+ * names, then the default key names under ~/.ssh, each kept only when its
332
+ * `.pub` exists (a `.pub` named directly stands for a key held by an agent).
333
+ * Empty when ssh is not installed.
334
+ */
335
+ export function sshKeyCandidates(env = process.env) {
336
+ const r = spawnSync(sshBin(env), ["-G", HOST], {
337
+ encoding: "utf8",
338
+ timeout: GH_TIMEOUT_MS,
339
+ stdio: ["ignore", "pipe", "ignore"],
340
+ env,
341
+ });
342
+ if (r.error) return [];
343
+ const listed = [];
344
+ for (const line of (r.stdout || "").split("\n")) {
345
+ const m = line.match(/^identityfile\s+(.+)$/i);
346
+ if (m) listed.push(expandIdentity(m[1], env));
347
+ }
348
+ const defaults = DEFAULT_KEYS.map((n) => join(homeOf(env), ".ssh", n));
349
+ const out = [];
350
+ for (const p of [...listed, ...defaults]) {
351
+ if (p && !out.includes(p) && usableKey(p)) out.push(p);
352
+ }
353
+ return out.slice(0, MAX_SSH_KEYS);
354
+ }
355
+
356
+ /** The GitHub login an SSH key authenticates as, from `ssh -T`; else null. */
357
+ export function sshLogin(key, env = process.env) {
358
+ const r = spawnSync(sshBin(env), ["-i", key, ...SSH_FLAGS, "-T", `git@${HOST}`], {
359
+ encoding: "utf8",
360
+ timeout: SSH_TIMEOUT_MS,
361
+ stdio: ["ignore", "pipe", "pipe"],
362
+ env: sshEnv(env),
363
+ });
364
+ if (r.error) return null;
365
+ const m = `${r.stderr || ""}\n${r.stdout || ""}`.match(/Hi ([^\s!]+)!/);
366
+ return m && LOGIN_RE.test(m[1]) ? m[1] : null;
367
+ }
368
+
369
+ function sshCanRead(key, repo, env) {
370
+ const command = [shQuote(sshBin(env)), "-i", shQuote(key), ...SSH_FLAGS].join(" ");
371
+ const r = spawnSync(gitBin(env), ["ls-remote", `git@${HOST}:${repo}.git`, "HEAD"], {
372
+ encoding: "utf8",
373
+ timeout: SSH_TIMEOUT_MS,
374
+ stdio: ["ignore", "ignore", "ignore"],
375
+ env: { ...sshEnv(env), GIT_SSH_COMMAND: command },
376
+ });
377
+ return r.status === 0;
378
+ }
379
+
380
+ /**
381
+ * Check the accounts on this machine against the access repository, live, in
382
+ * the order gh, git credential helper, SSH keys. Returns
383
+ * { login, method, key, reason }: the first account with access, else login
384
+ * null with a one-line reason naming what was tried.
162
385
  */
163
386
  export function checkAccess({
164
387
  repo = ACCESS_REPO_DEFAULT,
@@ -166,26 +389,70 @@ export function checkAccess({
166
389
  env = process.env,
167
390
  } = {}) {
168
391
  const accounts = listAccounts({ env });
169
- if (accounts.length === 0) {
392
+ for (const a of orderCandidates(accounts, preferred)) {
393
+ const token = tokenFor(a.login, env);
394
+ if (token && canRead(repo, token, env)) {
395
+ return { login: a.login, method: "gh", key: null, reason: `can read ${repo}` };
396
+ }
397
+ }
398
+
399
+ const cred = credentialToken(env);
400
+ const credLogin = cred ? tokenLoginWithAccess(cred, repo, env) : null;
401
+ if (credLogin) {
170
402
  return {
171
- login: null,
172
- reason: `no GitHub account is logged in to gh, so ${repo} cannot be checked`,
403
+ login: credLogin,
404
+ method: "credential",
405
+ key: null,
406
+ reason: `can read ${repo}`,
173
407
  };
174
408
  }
175
- for (const a of orderCandidates(accounts, preferred)) {
176
- const token = tokenFor(a.login, env);
177
- if (token && canRead(repo, token, env)) return { login: a.login, reason: `can read ${repo}` };
409
+
410
+ const keys = sshKeyCandidates(env);
411
+ const sshLogins = [];
412
+ for (const key of keys) {
413
+ const login = sshLogin(key, env);
414
+ if (!login) continue;
415
+ sshLogins.push(login);
416
+ if (sshCanRead(key, repo, env)) {
417
+ return {
418
+ login,
419
+ method: "ssh",
420
+ key,
421
+ reason: `can read ${repo}`,
422
+ };
423
+ }
178
424
  }
425
+
426
+ const ghPart = accounts.length ? accounts.map((a) => a.login).join(", ") : "none";
427
+ const credPart = cred ? "a token without access" : "none";
428
+ const sshPart = keys.length
429
+ ? `${keys.length} tried${sshLogins.length ? ` (${[...new Set(sshLogins)].join(", ")})` : ""}`
430
+ : "none";
179
431
  return {
180
432
  login: null,
181
- reason: `none of the logged-in GitHub accounts (${accounts.map((a) => a.login).join(", ")}) can read ${repo}`,
433
+ method: null,
434
+ key: null,
435
+ reason: `no GitHub account on this machine can read ${repo} (gh: ${ghPart}; git credential: ${credPart}; SSH keys: ${sshPart})`,
182
436
  };
183
437
  }
184
438
 
439
+ function cachedMethod(u) {
440
+ if (u.loginMethod === undefined || u.loginMethod === null) return "gh";
441
+ return LOGIN_METHODS.includes(u.loginMethod) ? u.loginMethod : null;
442
+ }
443
+
185
444
  function cacheFresh(u, repo, now) {
186
445
  const at = Date.parse(u?.loginCheckedAt || "");
187
446
  if (Number.isNaN(at)) return false;
188
447
  if ((u.loginRepo || ACCESS_REPO_DEFAULT) !== repo) return false;
448
+ if (u.loginFallback === true) return false;
449
+ if (u.login) {
450
+ const method = cachedMethod(u);
451
+ if (!method) return false;
452
+ if (method === "ssh" && !(typeof u.loginKey === "string" && usableKey(u.loginKey))) {
453
+ return false;
454
+ }
455
+ }
189
456
  const ttl = u.login ? LOGIN_TTL_MS : NO_LOGIN_TTL_MS;
190
457
  return now - at < ttl && now >= at;
191
458
  }
@@ -224,11 +491,20 @@ export function resolveReportingLogin({
224
491
  const repo = accessRepoOf(prefs);
225
492
  if (!refresh && cacheFresh(u, repo, now)) {
226
493
  if (typeof u.login === "string" && LOGIN_RE.test(u.login)) {
227
- return { login: u.login, reason: `cached: can read ${repo}`, cached: true };
494
+ const method = cachedMethod(u);
495
+ return {
496
+ login: u.login,
497
+ method,
498
+ key: method === "ssh" ? u.loginKey : null,
499
+ reason: `cached: can read ${repo}`,
500
+ cached: true,
501
+ };
228
502
  }
229
503
  return {
230
504
  login: null,
231
- reason: u.loginReason || `cached: no logged-in GitHub account can read ${repo}`,
505
+ method: null,
506
+ key: null,
507
+ reason: u.loginReason || `cached: no GitHub account on this machine can read ${repo}`,
232
508
  cached: true,
233
509
  };
234
510
  }
@@ -239,6 +515,9 @@ export function resolveReportingLogin({
239
515
  prefsPath,
240
516
  {
241
517
  login: result.login,
518
+ loginMethod: result.method,
519
+ loginKey: result.method === "ssh" ? result.key : null,
520
+ loginFallback: undefined,
242
521
  loginRepo: repo,
243
522
  loginCheckedAt: new Date(now).toISOString(),
244
523
  loginReason: result.login ? null : result.reason,
@@ -263,10 +542,16 @@ function main(argv) {
263
542
  });
264
543
  if (opts.json) {
265
544
  process.stdout.write(
266
- `${JSON.stringify({ login: r.login, reason: r.reason, cached: r.cached })}\n`,
545
+ `${JSON.stringify({ login: r.login, method: r.method, key: r.key, reason: r.reason, cached: r.cached })}\n`,
267
546
  );
268
547
  } else if (r.login) {
269
- process.stdout.write(`usage identity: ${r.login} (${r.reason})\n`);
548
+ const via =
549
+ r.method === "ssh"
550
+ ? `SSH key ${displayPath(r.key)}`
551
+ : r.method === "credential"
552
+ ? "git credential helper"
553
+ : "gh";
554
+ process.stdout.write(`usage identity: ${r.login} (${via}; ${r.reason})\n`);
270
555
  } else {
271
556
  process.stdout.write(`usage identity: none - ${r.reason}; nothing will be reported\n`);
272
557
  }