co-maintainer 0.4.13 → 0.5.0-beta.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 (140) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +57 -38
  3. package/dist/main.js +2 -3
  4. package/dist/package.json +21 -15
  5. package/dist/src/ai/batch.d.ts +24 -1
  6. package/dist/src/ai/batch.js +75 -23
  7. package/dist/src/ai/estimate.d.ts +40 -0
  8. package/dist/src/ai/estimate.js +113 -0
  9. package/dist/src/ai/fake.d.ts +1 -1
  10. package/dist/src/ai/fake.js +1 -1
  11. package/dist/src/ai/hetzner.js +1 -1
  12. package/dist/src/ai/openrouter.d.ts +13 -0
  13. package/dist/src/ai/openrouter.js +66 -12
  14. package/dist/src/ai/pricing.d.ts +14 -0
  15. package/dist/src/ai/pricing.js +77 -0
  16. package/dist/src/ai/provider.js +8 -0
  17. package/dist/src/ai/verify.d.ts +25 -0
  18. package/dist/src/ai/verify.js +78 -0
  19. package/dist/src/cli/args.js +25 -29
  20. package/dist/src/cli/commands/config.d.ts +44 -0
  21. package/dist/src/cli/commands/config.js +220 -0
  22. package/dist/src/cli/commands/probe.js +70 -103
  23. package/dist/src/cli/commands/registry.d.ts +57 -0
  24. package/dist/src/cli/commands/registry.js +713 -0
  25. package/dist/src/cli/commands/review.js +66 -43
  26. package/dist/src/cli/commands/serve.d.ts +4 -2
  27. package/dist/src/cli/commands/serve.js +14 -10
  28. package/dist/src/cli/commands/set.d.ts +2 -1
  29. package/dist/src/cli/commands/set.js +63 -17
  30. package/dist/src/cli/commands/view.d.ts +1 -0
  31. package/dist/src/cli/commands/view.js +180 -0
  32. package/dist/src/cli/error.d.ts +39 -0
  33. package/dist/src/cli/error.js +64 -0
  34. package/dist/src/cli/main.d.ts +7 -0
  35. package/dist/src/cli/main.js +82 -1
  36. package/dist/src/cli/prompt.js +21 -7
  37. package/dist/src/cli/review_args.d.ts +4 -0
  38. package/dist/src/cli/review_args.js +22 -9
  39. package/dist/src/cli/review_output.d.ts +2 -2
  40. package/dist/src/cli/review_output.js +7 -6
  41. package/dist/src/cli/review_result.d.ts +59 -8
  42. package/dist/src/cli/review_result.js +145 -103
  43. package/dist/src/config.d.ts +15 -0
  44. package/dist/src/config.js +31 -0
  45. package/dist/src/github/app.d.ts +13 -0
  46. package/dist/src/github/app.js +23 -0
  47. package/dist/src/github/app_manifest.d.ts +50 -0
  48. package/dist/src/github/app_manifest.js +138 -0
  49. package/dist/src/github/client.js +7 -1
  50. package/dist/src/github/collect.js +1 -1
  51. package/dist/src/github/gh.js +75 -18
  52. package/dist/src/knowledge/facts.js +8 -3
  53. package/dist/src/knowledge/guide.d.ts +8 -0
  54. package/dist/src/knowledge/guide.js +32 -8
  55. package/dist/src/knowledge/probe.js +13 -3
  56. package/dist/src/knowledge/sections.d.ts +9 -0
  57. package/dist/src/knowledge/sections.js +16 -0
  58. package/dist/src/knowledge/skill.d.ts +5 -0
  59. package/dist/src/knowledge/skill.js +68 -21
  60. package/dist/src/knowledge/synthesis.d.ts +17 -1
  61. package/dist/src/knowledge/synthesis.js +70 -15
  62. package/dist/src/knowledge/types.d.ts +5 -0
  63. package/dist/src/local/codegraph_prepare.d.ts +3 -2
  64. package/dist/src/local/codegraph_prepare.js +4 -2
  65. package/dist/src/local/git_ops.d.ts +5 -4
  66. package/dist/src/local/git_ops.js +8 -9
  67. package/dist/src/local/review_local.js +36 -14
  68. package/dist/src/pr/checkout.js +14 -3
  69. package/dist/src/pr/codegraph_tools.js +3 -3
  70. package/dist/src/pr/diff_summary.js +3 -3
  71. package/dist/src/pr/findings.js +16 -5
  72. package/dist/src/pr/findings_json.d.ts +26 -0
  73. package/dist/src/pr/findings_json.js +187 -0
  74. package/dist/src/pr/review_copy.d.ts +20 -0
  75. package/dist/src/pr/review_copy.js +40 -0
  76. package/dist/src/pr/reviewer.d.ts +2 -1
  77. package/dist/src/pr/reviewer.js +84 -91
  78. package/dist/src/remote/client.js +42 -42
  79. package/dist/src/remote/http.d.ts +5 -0
  80. package/dist/src/remote/http.js +49 -0
  81. package/dist/src/remote/server/guides.d.ts +22 -0
  82. package/dist/src/remote/server/guides.js +81 -0
  83. package/dist/src/remote/server/routes.js +24 -0
  84. package/dist/src/review/blocking.d.ts +35 -0
  85. package/dist/src/review/blocking.js +45 -0
  86. package/dist/src/review/carry_over.d.ts +12 -1
  87. package/dist/src/review/carry_over.js +35 -9
  88. package/dist/src/review/engine.d.ts +1 -0
  89. package/dist/src/review/guides.js +28 -1
  90. package/dist/src/server/api/installations.js +1 -1
  91. package/dist/src/server/api/repos.js +86 -3
  92. package/dist/src/server/api/settings.js +3 -3
  93. package/dist/src/server/app.d.ts +3 -0
  94. package/dist/src/server/app.js +12 -5
  95. package/dist/src/server/pages/activity.d.ts +1 -0
  96. package/dist/src/server/pages/activity.js +2 -2
  97. package/dist/src/server/pages/add_repo.js +73 -5
  98. package/dist/src/server/pages/client.d.ts +1 -1
  99. package/dist/src/server/pages/client.js +1 -1
  100. package/dist/src/server/pages/home.d.ts +6 -1
  101. package/dist/src/server/pages/home.js +25 -2
  102. package/dist/src/server/pages/knowledge.js +1 -1
  103. package/dist/src/server/pages/layout.js +2 -2
  104. package/dist/src/server/pages/repo.d.ts +1 -1
  105. package/dist/src/server/pages/repo.js +21 -2
  106. package/dist/src/server/pages/repo_prs.d.ts +5 -1
  107. package/dist/src/server/pages/repo_prs.js +36 -1
  108. package/dist/src/server/pages/repo_remote.js +2 -2
  109. package/dist/src/server/pages/repo_settings.js +2 -2
  110. package/dist/src/server/pages/router.d.ts +6 -0
  111. package/dist/src/server/pages/router.js +129 -9
  112. package/dist/src/server/pages/settings.d.ts +1 -1
  113. package/dist/src/server/pages/settings.js +87 -9
  114. package/dist/src/server/pages/styles.d.ts +1 -1
  115. package/dist/src/server/pages/styles.js +1 -1
  116. package/dist/src/services/probe.d.ts +28 -0
  117. package/dist/src/services/probe.js +133 -0
  118. package/dist/src/services/remake_cron.d.ts +1 -1
  119. package/dist/src/services/remake_cron.js +3 -3
  120. package/dist/src/services/remote_review.js +14 -4
  121. package/dist/src/services/review.d.ts +11 -0
  122. package/dist/src/services/review.js +39 -11
  123. package/dist/src/services/setup.js +37 -19
  124. package/dist/src/services/setup_checklist.d.ts +10 -0
  125. package/dist/src/services/setup_checklist.js +87 -0
  126. package/dist/src/store/app_db.js +5 -2
  127. package/dist/src/store/cache_db.d.ts +4 -0
  128. package/dist/src/store/cache_db.js +27 -4
  129. package/dist/src/store/deliveries.d.ts +7 -0
  130. package/dist/src/store/deliveries.js +15 -0
  131. package/dist/src/tools/codegraph.d.ts +30 -0
  132. package/dist/src/tools/codegraph.js +49 -12
  133. package/dist/src/types.d.ts +10 -0
  134. package/dist/src/util/log.d.ts +3 -0
  135. package/dist/src/util/log.js +12 -0
  136. package/dist/src/util/run_summary.d.ts +33 -0
  137. package/dist/src/util/run_summary.js +47 -0
  138. package/dist/src/util/webhook_reachability.d.ts +11 -0
  139. package/dist/src/util/webhook_reachability.js +64 -0
  140. package/package.json +21 -15
@@ -6,9 +6,12 @@ import { GhClient } from "../../github/gh.js";
6
6
  import { log, startHeartbeat, timed, withCliLogsToStderr, } from "../../util/log.js";
7
7
  import { runLocalReview } from "../../local/review_local.js";
8
8
  import { runRemoteReview } from "../../remote/client.js";
9
- import { printLocalReview, reviewExitCode } from "../review_output.js";
9
+ import { reviewExitCode } from "../review_output.js";
10
10
  import { buildPrReviewJson, resolvedFromFirstReview, reviewExitCodeFromResolved, } from "../review_result.js";
11
11
  import { parseFindings } from "../../pr/findings.js";
12
+ import { exitWith } from "../error.js";
13
+ import { printRunSummary, summaryFromMetrics } from "../../util/run_summary.js";
14
+ import { formatHumanReview, humanFindingsFromResolved, } from "../review_result.js";
12
15
  export async function runReviewFromCli(args) {
13
16
  const parsed = await parseReviewArgs(args);
14
17
  if (parsed.mode === "local") {
@@ -35,50 +38,70 @@ async function runReviewPr(options, cli) {
35
38
  const operationStarted = performance.now();
36
39
  const aiMetrics = emptyAiMetrics();
37
40
  const stopHeartbeat = startHeartbeat("reviewing pull request");
38
- if (options.auth !== "gh") {
39
- throw new Error("review supports gh authentication only");
40
- }
41
- log("review", `reading PR #${options.prNumber} in ${options.repo} via gh`);
42
- const result = await timed("review GitHub collection and AI", options.logTime, () => reviewPullRequest(new GhClient(options.debug), options, async (response) => {
43
- aiMetrics.calls++;
44
- aiMetrics.tokensIn += response.tokensIn;
45
- aiMetrics.tokensOut += response.tokensOut;
46
- if (response.cost === undefined)
47
- aiMetrics.costKnown = false;
48
- else
49
- aiMetrics.cost += response.cost;
50
- await recordAiCost(options.repo, "review_pull_request", response);
51
- }));
52
- const durationMs = Math.round(performance.now() - operationStarted);
53
- const usage = {
54
- tokensIn: aiMetrics.tokensIn,
55
- tokensOut: aiMetrics.tokensOut,
56
- costUsd: aiMetrics.costKnown ? aiMetrics.cost : null,
57
- };
58
- const codegraphState = options.useCodegraph ? "used" : "disabled";
59
- const findings = resolvedFromFirstReview(parseFindings(result.text), new Map());
60
- if (cli.json) {
61
- console.log(buildPrReviewJson({
62
- repo: options.repo,
63
- prNumber: options.prNumber,
64
- markdown: result.text,
65
- guideBuiltAt: result.guideBuiltAt,
66
- codegraphState,
67
- codegraphReason: null,
68
- usage,
69
- durationMs,
41
+ try {
42
+ if (options.auth !== "gh") {
43
+ throw new Error("review supports gh authentication only");
44
+ }
45
+ log("review", `reading PR #${options.prNumber} in ${options.repo} via gh`);
46
+ const result = await timed("review GitHub collection and AI", options.logTime, () => reviewPullRequest(new GhClient(options.debug), options, async (response) => {
47
+ aiMetrics.calls++;
48
+ aiMetrics.tokensIn += response.tokensIn;
49
+ aiMetrics.tokensOut += response.tokensOut;
50
+ if (response.cost === undefined)
51
+ aiMetrics.costKnown = false;
52
+ else
53
+ aiMetrics.cost += response.cost;
54
+ await recordAiCost(options.repo, "review_pull_request", response);
70
55
  }));
71
- }
72
- else {
73
- printLocalReview(`co-maintainer review · ${options.repo} · PR #${options.prNumber}`, result.text);
74
- if (options.logTime) {
75
- log("time", `AI total · calls=${aiMetrics.calls} · input=${aiMetrics.tokensIn} tokens · output=${aiMetrics.tokensOut} tokens · cost=${aiMetrics.costKnown ? aiMetrics.cost.toFixed(4) : "unknown"}`);
76
- console.error(`[time] total review · ${((performance.now() - operationStarted) / 1000).toFixed(2)}s`);
56
+ const durationMs = Math.round(performance.now() - operationStarted);
57
+ const usage = {
58
+ tokensIn: aiMetrics.tokensIn,
59
+ tokensOut: aiMetrics.tokensOut,
60
+ costUsd: aiMetrics.costKnown ? aiMetrics.cost : null,
61
+ };
62
+ // The engine reports the codegraph state it actually reached, instead of
63
+ // the old `useCodegraph ? "used" : "disabled"` that claimed "used" while
64
+ // stderr said codegraph was missing (CORE-43 / F23).
65
+ const codegraphState = result.codegraphState;
66
+ const resolved = resolvedFromFirstReview(parseFindings(result.text), new Map());
67
+ if (cli.json) {
68
+ console.log(buildPrReviewJson({
69
+ repo: options.repo,
70
+ prNumber: options.prNumber,
71
+ markdown: result.text,
72
+ guideBuiltAt: result.guideBuiltAt,
73
+ codegraphState,
74
+ codegraphReason: null,
75
+ usage,
76
+ durationMs,
77
+ reviewBlocking: options.reviewBlocking,
78
+ }));
77
79
  }
80
+ else {
81
+ // One presentation layer for local, PR and remote reviews (CORE-43).
82
+ console.log("\n" +
83
+ formatHumanReview({
84
+ title: `co-maintainer review · ${options.repo} · PR #${options.prNumber}`,
85
+ guideBuiltAt: result.guideBuiltAt,
86
+ codegraphState,
87
+ findings: humanFindingsFromResolved(resolved, options.reviewBlocking),
88
+ }) +
89
+ "\n");
90
+ printRunSummary(summaryFromMetrics(aiMetrics, performance.now() - operationStarted));
91
+ if (options.logTime) {
92
+ log("time", `AI total · calls=${aiMetrics.calls} · input=${aiMetrics.tokensIn} tokens · output=${aiMetrics.tokensOut} tokens · cost=${aiMetrics.costKnown ? aiMetrics.cost.toFixed(4) : "unknown"}`);
93
+ console.error(`[time] total review · ${((performance.now() - operationStarted) / 1000).toFixed(2)}s`);
94
+ }
95
+ }
96
+ exitWith(cli.json
97
+ ? reviewExitCodeFromResolved(resolved, options.reviewBlocking)
98
+ : reviewExitCode(result.text, options.reviewBlocking));
99
+ }
100
+ finally {
101
+ // A heartbeat left running on the error path keeps the event loop alive
102
+ // forever, so the process would never drain and the exit code would never
103
+ // be applied. CORE-11.
104
+ stopHeartbeat();
78
105
  }
79
- stopHeartbeat();
80
- process.exit(cli.json
81
- ? reviewExitCodeFromResolved(findings)
82
- : reviewExitCode(result.text));
83
106
  });
84
107
  }
@@ -1,8 +1,10 @@
1
1
  import type { UserConfig } from "../../config.ts";
2
2
  import type { AuthMethods, PasswordStore } from "../../server/auth.ts";
3
3
  import { type Platform } from "../../util/runtime.ts";
4
- /** `undefined` on Linux, otherwise one line naming the platform (a pure
5
- * function so it is testable without actually being off Linux). */
4
+ /** `undefined` on Linux, otherwise one line naming the platform. The
5
+ * recommendation is spelled out rather than pointing at an internal
6
+ * document, so the message stands on its own and links to the public
7
+ * troubleshooting page for the details. */
6
8
  export declare function platformWarning(os: Platform): string | undefined;
7
9
  export declare function resolveTrustProxy(args: string[], env?: (name: string) => string | undefined): boolean;
8
10
  /** `--password=` replaces the stored password. With no flag the stored one is
@@ -1,4 +1,4 @@
1
- import { readConfig } from "../../config.js";
1
+ import { readConfig, resolveAppPrivateKey, appPrivateKeyFileMissing, } from "../../config.js";
2
2
  import { appDbPath, closeAppDb, openAppDb } from "../../store/app_db.js";
3
3
  import { createApp } from "../../server/app.js";
4
4
  import { configPasswordStore } from "../../server/auth.js";
@@ -12,15 +12,15 @@ import { startRemoteWatchdog } from "../../remote/server/sessions.js";
12
12
  import { passwordProblem } from "../../util/password.js";
13
13
  import { serveHttp } from "../../server/http.js";
14
14
  import { currentPlatform, getEnv } from "../../util/runtime.js";
15
- /** `undefined` on Linux, otherwise one line naming the platform (a pure
16
- * function so it is testable without actually being off Linux). */
15
+ import { die } from "../error.js";
16
+ /** `undefined` on Linux, otherwise one line naming the platform. The
17
+ * recommendation is spelled out rather than pointing at an internal
18
+ * document, so the message stands on its own and links to the public
19
+ * troubleshooting page for the details. */
17
20
  export function platformWarning(os) {
18
21
  if (os === "linux")
19
22
  return undefined;
20
- return `running on ${os}. Linux (WSL included) is the recommended platform for serve — see PLAN.md Decision 5.`;
21
- }
22
- function die(message) {
23
- throw new Error(message);
23
+ return `running on ${os}. Linux (WSL included) is the recommended platform for serve. See https://co-maintainer.com/docs/troubleshooting.html`;
24
24
  }
25
25
  function generatePassword() {
26
26
  const bytes = new Uint8Array(12);
@@ -68,7 +68,7 @@ export function resolveAuthMethods(args, config) {
68
68
  const password = disableAuth ? false : !config.passwordAuthDisabled;
69
69
  const github = enableAuth ? true : Boolean(config.githubAuthEnabled);
70
70
  if (!password && !github) {
71
- die("at least one sign-in method is required; drop --disable-auth=password or pass --enable-auth=github");
71
+ die("at least one sign-in method is required. Drop --disable-auth=password or pass --enable-auth=github");
72
72
  }
73
73
  return { password, github };
74
74
  }
@@ -124,16 +124,19 @@ export async function runServe(args) {
124
124
  }
125
125
  const config = readConfig();
126
126
  const webhookUrl = resolveWebhookUrl(args, port, config.webhookUrl);
127
- if (!config.githubAppId || !config.githubAppPrivateKey) {
127
+ if (!config.githubAppId || !resolveAppPrivateKey(config)) {
128
128
  console.log("[serve] GitHub App is not configured yet. Add it in the dashboard settings or with co-maintainer set.");
129
129
  }
130
+ if (appPrivateKeyFileMissing(config)) {
131
+ console.log(`[serve] warning: the GitHub App private key file listed in config.json cannot be read (${config.githubAppPrivateKeyPath}). The App will not work until it is restored or replaced.`);
132
+ }
130
133
  const auth = resolveAuthMethods(args, config);
131
134
  let githubOAuth;
132
135
  if (auth.github) {
133
136
  if (!config.githubOAuthClientId ||
134
137
  !config.githubOAuthClientSecret ||
135
138
  !config.githubOAuthAllowedUser) {
136
- die("GitHub sign-in requires OAuth credentials; run:\n" +
139
+ die("GitHub sign-in requires OAuth credentials. Run:\n" +
137
140
  " co-maintainer set --github-oauth-client-id=... --github-oauth-client-secret=... --github-oauth-allowed-user=...");
138
141
  }
139
142
  githubOAuth = {
@@ -187,6 +190,7 @@ export async function runServe(args) {
187
190
  trustProxy,
188
191
  auth,
189
192
  githubOAuth,
193
+ loginHint: getEnv("CM_LOGIN_HINT"),
190
194
  });
191
195
  const server = serveHttp((req, remoteAddr) => app.fetch(req, remoteAddr), port);
192
196
  console.log(`[serve] listening on http://localhost:${port}`);
@@ -1,6 +1,7 @@
1
1
  /** `co-maintainer set --token=... --ai=... --low-model=... --high-model=...
2
2
  * --auth=... --github-app-id=... --github-app-private-key=... (or
3
- * --github-app-private-key-file=path) --github-webhook-secret=...` —
3
+ * --github-app-private-key-file=path or --github-app-private-key-path=path)
4
+ * --github-webhook-secret=...` —
4
5
  * persists global defaults, including secrets, to config.json so every
5
6
  * other command can skip both the flag and the interactive prompt. See
6
7
  * docs/md/configuration.md for the tradeoff. */
@@ -1,9 +1,9 @@
1
- import { writeUserConfig } from "../../config.js";
1
+ import { readConfig, configPath, writeUserConfig } from "../../config.js";
2
2
  import { hashPassword, passwordProblem } from "../../util/password.js";
3
3
  import { readTextFile } from "../../util/runtime.js";
4
- function die(message) {
5
- throw new Error(message);
6
- }
4
+ import { verifyOpenRouter } from "../../ai/verify.js";
5
+ import { die } from "../error.js";
6
+ import { renderCommandHelp, renderGlobalHelp } from "./registry.js";
7
7
  function text(args, name) {
8
8
  return args
9
9
  .find((arg) => arg.startsWith(`--${name}=`))
@@ -20,22 +20,19 @@ const secretFields = new Set([
20
20
  ]);
21
21
  /** `co-maintainer set --token=... --ai=... --low-model=... --high-model=...
22
22
  * --auth=... --github-app-id=... --github-app-private-key=... (or
23
- * --github-app-private-key-file=path) --github-webhook-secret=...` —
23
+ * --github-app-private-key-file=path or --github-app-private-key-path=path)
24
+ * --github-webhook-secret=...` —
24
25
  * persists global defaults, including secrets, to config.json so every
25
26
  * other command can skip both the flag and the interactive prompt. See
26
27
  * docs/md/configuration.md for the tradeoff. */
27
28
  export async function runSet(args) {
28
29
  if (args.includes("--help") || args.includes("-h") || args.length === 0) {
29
- console.log("Usage: co-maintainer set --token=... --ai=none|openrouter|hetzner --low-model=... --high-model=... --auth=gh|pat --github-pat=...");
30
- console.log(" --github-app-id=... --github-app-private-key=... | --github-app-private-key-file=path");
31
- console.log(" --github-webhook-secret=...");
32
- console.log(" --github-oauth-client-id=... --github-oauth-client-secret=... --github-oauth-allowed-user=...");
33
- console.log(" --password=... --disable-auth=password --enable-auth=github");
34
- console.log("Writes to the user config file; unset an entry with --unset=name (e.g. --unset=token).");
30
+ console.log(renderCommandHelp("set") ?? renderGlobalHelp());
35
31
  return;
36
32
  }
37
33
  const known = [
38
34
  "token",
35
+ "ai-key",
39
36
  "ai",
40
37
  "low-model",
41
38
  "high-model",
@@ -44,6 +41,7 @@ export async function runSet(args) {
44
41
  "github-app-id",
45
42
  "github-app-private-key",
46
43
  "github-app-private-key-file",
44
+ "github-app-private-key-path",
47
45
  "github-webhook-secret",
48
46
  "github-oauth-client-id",
49
47
  "github-oauth-client-secret",
@@ -52,10 +50,14 @@ export async function runSet(args) {
52
50
  "enable-auth",
53
51
  "remote-host",
54
52
  "remote-token",
53
+ "review-blocking",
55
54
  "password",
56
55
  "unset",
57
56
  ];
57
+ const verify = !args.includes("--no-verify");
58
58
  for (const arg of args) {
59
+ if (arg === "--no-verify")
60
+ continue;
59
61
  if (!arg.startsWith("--") ||
60
62
  !known.some((name) => arg.startsWith(`--${name}=`))) {
61
63
  die(`Unknown option: ${arg}`);
@@ -69,12 +71,17 @@ export async function runSet(args) {
69
71
  if (auth && !["gh", "pat"].includes(auth)) {
70
72
  die("--auth must be one of: gh, pat");
71
73
  }
72
- if (text(args, "github-app-private-key") &&
73
- text(args, "github-app-private-key-file")) {
74
- die("Pass only one of --github-app-private-key or --github-app-private-key-file");
74
+ const providedKeySources = [
75
+ text(args, "github-app-private-key"),
76
+ text(args, "github-app-private-key-file"),
77
+ text(args, "github-app-private-key-path"),
78
+ ].filter((value) => value !== undefined);
79
+ if (providedKeySources.length > 1) {
80
+ die("Pass only one of --github-app-private-key, --github-app-private-key-file, or --github-app-private-key-path");
75
81
  }
76
82
  const fieldByFlag = {
77
83
  token: "token",
84
+ "ai-key": "token",
78
85
  ai: "ai",
79
86
  "low-model": "lowModel",
80
87
  "high-model": "highModel",
@@ -82,6 +89,7 @@ export async function runSet(args) {
82
89
  "github-pat": "githubPat",
83
90
  "github-app-id": "githubAppId",
84
91
  "github-app-private-key": "githubAppPrivateKey",
92
+ "github-app-private-key-path": "githubAppPrivateKeyPath",
85
93
  "github-webhook-secret": "githubWebhookSecret",
86
94
  "github-oauth-client-id": "githubOAuthClientId",
87
95
  "github-oauth-client-secret": "githubOAuthClientSecret",
@@ -90,6 +98,7 @@ export async function runSet(args) {
90
98
  "enable-auth": "githubAuthEnabled",
91
99
  "remote-host": "remoteHost",
92
100
  "remote-token": "remoteToken",
101
+ "review-blocking": "reviewBlocking",
93
102
  password: "dashboardPasswordHash",
94
103
  };
95
104
  const unset = new Set(args
@@ -110,7 +119,7 @@ export async function runSet(args) {
110
119
  const highModel = text(args, "high-model");
111
120
  if (highModel)
112
121
  patch.highModel = highModel;
113
- const token = text(args, "token");
122
+ const token = text(args, "token") ?? text(args, "ai-key");
114
123
  if (token)
115
124
  patch.token = token;
116
125
  const githubPat = text(args, "github-pat");
@@ -134,6 +143,17 @@ export async function runSet(args) {
134
143
  die(`Could not read ${privateKeyFile}: ${String(error)}`);
135
144
  }
136
145
  }
146
+ // The path form keeps the key on disk: only the location is stored, and it
147
+ // is read at startup. Passing one of the other two forms also clears a
148
+ // previously saved path so the inline key or file wins unambiguously.
149
+ const privateKeyPath = text(args, "github-app-private-key-path");
150
+ if (privateKeyPath) {
151
+ patch.githubAppPrivateKeyPath = privateKeyPath;
152
+ patch.githubAppPrivateKey = undefined;
153
+ }
154
+ else if (githubAppPrivateKey || privateKeyFile) {
155
+ patch.githubAppPrivateKeyPath = undefined;
156
+ }
137
157
  const oauthClientId = text(args, "github-oauth-client-id");
138
158
  if (oauthClientId)
139
159
  patch.githubOAuthClientId = oauthClientId;
@@ -161,6 +181,13 @@ export async function runSet(args) {
161
181
  const remoteToken = text(args, "remote-token");
162
182
  if (remoteToken)
163
183
  patch.remoteToken = remoteToken;
184
+ const reviewBlocking = text(args, "review-blocking");
185
+ if (reviewBlocking) {
186
+ if (!["model", "severity"].includes(reviewBlocking)) {
187
+ die("--review-blocking must be one of: model, severity");
188
+ }
189
+ patch.reviewBlocking = reviewBlocking;
190
+ }
164
191
  const password = text(args, "password");
165
192
  if (password) {
166
193
  const problem = passwordProblem(password);
@@ -169,14 +196,33 @@ export async function runSet(args) {
169
196
  patch.dashboardPasswordHash = await hashPassword(password);
170
197
  }
171
198
  if (Object.keys(patch).length === 0) {
172
- die("Nothing to set; pass --token=, --ai=, --low-model=, --high-model=, --auth=, --github-pat=, " +
173
- "--github-app-id=, --github-app-private-key(-file)=, --github-webhook-secret=, " +
199
+ die("Nothing to set. Pass --token= (or --ai-key=), --ai=, --low-model=, --high-model=, --auth=, --github-pat=, " +
200
+ "--github-app-id=, --github-app-private-key(-file|-path)=, --github-webhook-secret=, " +
174
201
  "--github-oauth-client-id=, --github-oauth-client-secret=, --github-oauth-allowed-user=, " +
202
+ "--remote-host=, --remote-token=, --review-blocking=model|severity, " +
175
203
  "--password=, --disable-auth=password, --enable-auth=github, or --unset=name");
176
204
  }
205
+ // Verify the key and model before writing, so a typo fails here instead of
206
+ // after a review has already fetched and cloned the pull request (CORE-22).
207
+ // A field present in the patch wins, including when its value is `undefined`
208
+ // — that is an unset, and there is nothing left to verify.
209
+ const before = readConfig();
210
+ const effective = (field) => field in patch ? patch[field] : before[field];
211
+ const effectiveAi = effective("ai");
212
+ const effectiveToken = effective("token");
213
+ const effectiveHighModel = effective("highModel");
214
+ if (verify && effectiveAi === "openrouter" && effectiveToken) {
215
+ const result = await verifyOpenRouter(String(effectiveToken), effectiveHighModel === undefined ? undefined : String(effectiveHighModel));
216
+ if (result.status === "rejected")
217
+ throw result.error;
218
+ if (result.status === "unreachable") {
219
+ console.error(`[set] could not verify the key and model (${result.reason}); saved anyway.`);
220
+ }
221
+ }
177
222
  await writeUserConfig(patch);
178
223
  const summary = Object.entries(patch).map(([key, value]) => value === undefined
179
224
  ? `${key} (unset)`
180
225
  : `${key}=${secretFields.has(key) ? "•".repeat(8) : value}`);
181
226
  console.log(`[set] updated: ${summary.join(", ")}`);
227
+ console.log(`Saved to ${configPath()}`);
182
228
  }
@@ -0,0 +1 @@
1
+ export declare function runView(args: string[]): Promise<void>;
@@ -0,0 +1,180 @@
1
+ /** `co-maintainer view`: print the guides co-maintainer already generated
2
+ * (CORE-23, F25). Read-only — the counterpart to `config` for the generated
3
+ * knowledge rather than the settings.
4
+ *
5
+ * Two sources, one presentation (CORE-44): the guide directory on this machine,
6
+ * or the servers a remote review token can read (`--remote`). Local and remote
7
+ * differ only in where the guide list comes from, so `--list`, a single guide
8
+ * and the every-guide headers are all rendered by the same code.
9
+ *
10
+ * Naming: the plan calls the guide kinds `skill`, `codebase`,
11
+ * `review-guide`, `detailed-guide`; `--list` and the headers show the real
12
+ * file names, and a kind name is accepted too so a user never has to type
13
+ * `PR_REVIEW_GUIDE.md` unless they want to. */
14
+ import { readConfig, reposDir } from "../../config.js";
15
+ import { detectRemoteRepo } from "../../local/git_ops.js";
16
+ import { CliError, EXIT_USAGE } from "../error.js";
17
+ import { renderCommandHelp, unknownOptionMessage } from "./registry.js";
18
+ import { readDir, readTextFile, stat } from "../../util/runtime.js";
19
+ import { baseUrl, die, readApiError, remoteFetch } from "../../remote/http.js";
20
+ /** The four generated guides, in the order a reader wants them: the
21
+ * contribution skill first, then the codebase notes, then the two review
22
+ * guides (short, then the evidence behind it). */
23
+ const GUIDES = [
24
+ { kind: "skill", file: "SKILL.md" },
25
+ { kind: "codebase", file: "CODEBASE.md" },
26
+ { kind: "review-guide", file: "PR_REVIEW_GUIDE.md" },
27
+ { kind: "detailed-guide", file: "PR_REVIEW_DETAILED_GUIDE.md" },
28
+ ];
29
+ /** Resolves a user-supplied name to a guide. Accepts the plan's kind names
30
+ * (`review-guide`), the file name (`PR_REVIEW_GUIDE.md`), and the bare stem
31
+ * (`PR_REVIEW_GUIDE`), case-insensitively. */
32
+ function resolveGuide(name) {
33
+ const wanted = name.toLowerCase();
34
+ const match = GUIDES.find((guide) => guide.kind === wanted ||
35
+ guide.file.toLowerCase() === wanted ||
36
+ guide.file.toLowerCase().replace(/\.md$/, "") === wanted);
37
+ if (!match) {
38
+ throw new CliError("unknown_guide", `Unknown guide: ${name}.`, `Pass one of ${GUIDES.map((guide) => guide.kind).join(", ")}, or --list.`, EXIT_USAGE);
39
+ }
40
+ return match;
41
+ }
42
+ /** `2026-09-22` from an ISO timestamp, so a header says when the guide was
43
+ * built without a database lookup. */
44
+ function builtDate(iso) {
45
+ return iso.slice(0, 10);
46
+ }
47
+ /** A human size for `--list`, so a glance says whether a guide is present. */
48
+ function humanSize(bytes) {
49
+ if (bytes < 1024)
50
+ return `${bytes} B`;
51
+ return `${(bytes / 1024).toFixed(1)} KB`;
52
+ }
53
+ /** The guides that actually exist on disk, in {@link GUIDES} order first so a
54
+ * local listing reads like a remote one. A file co-maintainer did not write
55
+ * (an older name, a hand-written note) is kept, after the four known ones. */
56
+ async function readLocalGuides(repo) {
57
+ const dir = `${reposDir()}/${repo}`;
58
+ const names = [];
59
+ try {
60
+ for await (const entry of readDir(dir)) {
61
+ if (entry.isFile)
62
+ names.push(entry.name);
63
+ }
64
+ }
65
+ catch {
66
+ return [];
67
+ }
68
+ const order = (name) => {
69
+ const index = GUIDES.findIndex((guide) => guide.file === name);
70
+ return index === -1 ? GUIDES.length : index;
71
+ };
72
+ const guides = [];
73
+ for (const file of names.sort((a, b) => order(a) - order(b) || a.localeCompare(b))) {
74
+ try {
75
+ const info = await stat(`${dir}/${file}`);
76
+ guides.push({
77
+ kind: GUIDES.find((guide) => guide.file === file)?.kind ?? "other",
78
+ file,
79
+ size: info.size,
80
+ builtAt: info.mtime.toISOString(),
81
+ content: await readTextFile(`${dir}/${file}`),
82
+ });
83
+ }
84
+ catch {
85
+ // Vanished between the listing and the read: skip it rather than fail
86
+ // the whole directory.
87
+ }
88
+ }
89
+ return guides;
90
+ }
91
+ /** The guides a remote review server holds for `repo` (CORE-44). The token is
92
+ * the same one `review --remote` uses, so a host already configured for remote
93
+ * review needs no extra setup. */
94
+ async function readRemoteGuides(repo) {
95
+ const config = readConfig();
96
+ const host = config.remoteHost;
97
+ const token = config.remoteToken;
98
+ if (!host || !token) {
99
+ die("remote_not_configured", "Remote review is not configured.", "co-maintainer config set remote-host https://your-server " +
100
+ "&& co-maintainer config set remote-token <token>");
101
+ }
102
+ const response = await remoteFetch(host, token, `/api/remote/guides?repo=${encodeURIComponent(repo)}`, { method: "GET" });
103
+ if (!response.ok) {
104
+ die("remote_guides_failed", `The server at ${baseUrl(host)} could not list the guides for ${repo}.`, await readApiError(response));
105
+ }
106
+ const body = (await response.json());
107
+ return body.guides ?? [];
108
+ }
109
+ /** Resolves the repo: the explicit `owner/repo` when given, otherwise the
110
+ * git remote of the current directory. */
111
+ async function resolveRepo(args) {
112
+ const positional = args.find((arg) => !arg.startsWith("-"));
113
+ if (positional && /^[^/]+\/[^/]+$/.test(positional))
114
+ return positional;
115
+ return detectRemoteRepo(process.cwd(), positional);
116
+ }
117
+ /** Prints one guide list: `--list` rows, the raw single guide, or every guide
118
+ * behind its own header. Shared by the local and remote sources so the two
119
+ * cannot drift into different outputs. */
120
+ function printGuides(repo, guides, options) {
121
+ if (options.list) {
122
+ const width = Math.max(...guides.map((guide) => guide.file.length));
123
+ for (const guide of guides) {
124
+ console.log(`${guide.file.padEnd(width)} ${humanSize(guide.size).padStart(8)} ${builtDate(guide.builtAt)}`);
125
+ }
126
+ return;
127
+ }
128
+ if (options.name) {
129
+ // A single guide is printed raw, with no header, so `view ... | pbcopy`
130
+ // and `view ... > file` produce exactly the markdown.
131
+ const wanted = resolveGuide(options.name);
132
+ const found = guides.find((guide) => guide.file === wanted.file);
133
+ if (!found) {
134
+ throw new CliError("guide_missing", `${repo} has no ${wanted.file}.`, `co-maintainer view ${repo} --list`, EXIT_USAGE);
135
+ }
136
+ process.stdout.write(found.content);
137
+ return;
138
+ }
139
+ // Every guide, each behind a header that says which file and when it was
140
+ // built, so a scroll through the output never loses its place.
141
+ console.log(guides
142
+ .map((guide) => `== ${guide.file} · built ${builtDate(guide.builtAt)} ==\n${guide.content.trimEnd()}`)
143
+ .join("\n\n"));
144
+ }
145
+ export async function runView(args) {
146
+ if (args.includes("--help") || args.includes("-h")) {
147
+ console.log(renderCommandHelp("view") ??
148
+ "Usage: co-maintainer view [owner/repo] [guide]");
149
+ return;
150
+ }
151
+ const remote = args.includes("--remote");
152
+ for (const arg of args) {
153
+ if (arg.startsWith("--") &&
154
+ !["--list", "--path", "--remote", "--help", "-h"].includes(arg)) {
155
+ throw new CliError("usage", unknownOptionMessage(arg));
156
+ }
157
+ }
158
+ // The directory belongs to this machine, so it cannot answer for a server.
159
+ if (remote && args.includes("--path")) {
160
+ throw new CliError("usage", "--path and --remote cannot be combined.", "Drop --remote to print the local guide directory.", EXIT_USAGE);
161
+ }
162
+ const repo = await resolveRepo(args);
163
+ const name = args.find((arg) => !arg.startsWith("-") && arg !== repo);
164
+ if (!remote && args.includes("--path")) {
165
+ console.log(`${reposDir()}/${repo}`);
166
+ return;
167
+ }
168
+ const guides = remote
169
+ ? await readRemoteGuides(repo)
170
+ : await readLocalGuides(repo);
171
+ if (guides.length === 0) {
172
+ throw new CliError("no_guides", remote
173
+ ? `The server has no guides for ${repo}.`
174
+ : `No guides for ${repo}.`, `co-maintainer probe ${repo}`, EXIT_USAGE);
175
+ }
176
+ printGuides(repo, guides, {
177
+ list: args.includes("--list"),
178
+ name,
179
+ });
180
+ }
@@ -0,0 +1,39 @@
1
+ /** One error shape for every CLI failure (CORE-10).
2
+ *
3
+ * The message is the first line the user reads, `hint` is an optional
4
+ * follow-up line printed as `Hint: ...`, and `exitCode` is the documented
5
+ * contract: 1 only for blocking review findings, 2 for usage and precondition
6
+ * errors, 3 for runtime failures. Anything thrown that is not a `CliError` is
7
+ * treated as a runtime failure (3), which is the safe default for the network,
8
+ * provider and filesystem errors the CLI does not classify yet.
9
+ */
10
+ export declare const EXIT_FINDINGS = 1;
11
+ export declare const EXIT_USAGE = 2;
12
+ export declare const EXIT_RUNTIME = 3;
13
+ export declare class CliError extends Error {
14
+ code: string;
15
+ hint?: string;
16
+ exitCode: number;
17
+ constructor(code: string, message: string, hint?: string, exitCode?: number);
18
+ }
19
+ /** Throws a usage or precondition error. Kept as a helper so the existing
20
+ * `die(...)` call sites read the same as before. */
21
+ export declare function die(message: string, code?: string): never;
22
+ /** Ends the CLI with `code`, letting the event loop drain first.
23
+ *
24
+ * `process.exit` while undici's fetch connection pool is still open asserts in
25
+ * libuv on Windows (`Assertion failed: !(handle->flags & UV_HANDLE_CLOSING),
26
+ * file src\win\async.c, line 94`) and the process dies with 0xC0000409 instead
27
+ * of the intended code. Every provider and remote call uses fetch, so this is
28
+ * reachable from any error path after a request. Setting `exitCode` and letting
29
+ * the loop empty is what the probe confirms is crash-free and immediate (the
30
+ * undici pool does not keep the process alive). CORE-11. */
31
+ export declare function exitWith(code: number): void;
32
+ /** Renders an error the way the CLI prints it: the message, then `Hint: ...`
33
+ * when there is one. Used where an error becomes a log line rather than the
34
+ * top-level exit (CORE-12), so a hint is not lost just because the failure was
35
+ * quarantined. */
36
+ export declare function formatError(error: unknown): string;
37
+ /** A failed `fetch` as a message that names the host and the cause (CORE-12).
38
+ * Node's own text is only `fetch failed`, which says neither. */
39
+ export declare function networkFailure(url: string, error: unknown): CliError;