co-maintainer 0.5.0 → 0.5.1-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 (175) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +5 -3
  3. package/dist/package.json +1 -1
  4. package/dist/src/ai/anthropic.d.ts +55 -0
  5. package/dist/src/ai/anthropic.js +262 -0
  6. package/dist/src/ai/batch.d.ts +2 -2
  7. package/dist/src/ai/batch.js +1 -1
  8. package/dist/src/ai/estimate.d.ts +4 -0
  9. package/dist/src/ai/estimate.js +17 -2
  10. package/dist/src/ai/mermaid_loop.js +7 -4
  11. package/dist/src/ai/openai.d.ts +45 -0
  12. package/dist/src/ai/openai.js +206 -0
  13. package/dist/src/ai/openrouter.d.ts +1 -1
  14. package/dist/src/ai/openrouter.js +3 -3
  15. package/dist/src/ai/pricing.js +1 -1
  16. package/dist/src/ai/provider.d.ts +6 -0
  17. package/dist/src/ai/provider.js +26 -3
  18. package/dist/src/ai/verify.d.ts +9 -1
  19. package/dist/src/ai/verify.js +81 -3
  20. package/dist/src/cli/args.d.ts +1 -2
  21. package/dist/src/cli/args.js +24 -20
  22. package/dist/src/cli/commands/config.d.ts +1 -1
  23. package/dist/src/cli/commands/config.js +10 -4
  24. package/dist/src/cli/commands/probe.js +3 -11
  25. package/dist/src/cli/commands/registry.d.ts +4 -4
  26. package/dist/src/cli/commands/registry.js +84 -10
  27. package/dist/src/cli/commands/review.js +18 -13
  28. package/dist/src/cli/commands/rollback.d.ts +1 -0
  29. package/dist/src/cli/commands/rollback.js +42 -0
  30. package/dist/src/cli/commands/serve.js +2 -3
  31. package/dist/src/cli/commands/set.js +71 -7
  32. package/dist/src/cli/commands/view.js +10 -9
  33. package/dist/src/cli/error.d.ts +4 -4
  34. package/dist/src/cli/error.js +4 -4
  35. package/dist/src/cli/main.d.ts +4 -4
  36. package/dist/src/cli/main.js +13 -8
  37. package/dist/src/cli/prompt.js +1 -1
  38. package/dist/src/cli/review_args.d.ts +9 -3
  39. package/dist/src/cli/review_args.js +28 -5
  40. package/dist/src/cli/review_github.d.ts +31 -0
  41. package/dist/src/cli/review_github.js +128 -0
  42. package/dist/src/cli/review_result.d.ts +27 -16
  43. package/dist/src/cli/review_result.js +7 -7
  44. package/dist/src/config.d.ts +28 -6
  45. package/dist/src/config.js +15 -1
  46. package/dist/src/git/default_branch.d.ts +1 -1
  47. package/dist/src/git/default_branch.js +1 -1
  48. package/dist/src/github/app.js +2 -2
  49. package/dist/src/github/app_manifest.d.ts +1 -7
  50. package/dist/src/github/app_manifest.js +2 -7
  51. package/dist/src/github/client.d.ts +4 -1
  52. package/dist/src/github/client.js +14 -2
  53. package/dist/src/github/gh.js +10 -7
  54. package/dist/src/github/pat.js +4 -4
  55. package/dist/src/github/permissions.d.ts +36 -0
  56. package/dist/src/github/permissions.js +334 -0
  57. package/dist/src/knowledge/facts.js +1 -1
  58. package/dist/src/knowledge/guide.d.ts +3 -3
  59. package/dist/src/knowledge/guide.js +5 -5
  60. package/dist/src/knowledge/probe.js +1 -2
  61. package/dist/src/knowledge/sections.d.ts +3 -3
  62. package/dist/src/knowledge/sections.js +3 -3
  63. package/dist/src/knowledge/skill.d.ts +1 -1
  64. package/dist/src/knowledge/skill.js +4 -4
  65. package/dist/src/knowledge/synthesis.d.ts +1 -1
  66. package/dist/src/knowledge/synthesis.js +5 -5
  67. package/dist/src/knowledge/types.d.ts +1 -1
  68. package/dist/src/local/codegraph_exclude.d.ts +1 -1
  69. package/dist/src/local/codegraph_exclude.js +1 -1
  70. package/dist/src/local/codegraph_prepare.js +1 -1
  71. package/dist/src/local/git_ops.d.ts +1 -1
  72. package/dist/src/local/git_ops.js +1 -1
  73. package/dist/src/local/git_parse.d.ts +2 -2
  74. package/dist/src/local/git_parse.js +2 -2
  75. package/dist/src/local/git_revision.d.ts +1 -1
  76. package/dist/src/local/git_revision.js +1 -1
  77. package/dist/src/local/git_untracked.d.ts +1 -1
  78. package/dist/src/local/git_untracked.js +1 -1
  79. package/dist/src/local/review_local.js +17 -14
  80. package/dist/src/local/review_lock.d.ts +1 -1
  81. package/dist/src/local/review_lock.js +1 -1
  82. package/dist/src/pr/checkout.js +2 -2
  83. package/dist/src/pr/findings.js +1 -1
  84. package/dist/src/pr/findings_json.d.ts +2 -2
  85. package/dist/src/pr/findings_json.js +1 -1
  86. package/dist/src/pr/github_revision.js +1 -1
  87. package/dist/src/pr/review_copy.d.ts +2 -2
  88. package/dist/src/pr/review_copy.js +2 -2
  89. package/dist/src/pr/reviewer.d.ts +6 -0
  90. package/dist/src/pr/reviewer.js +34 -28
  91. package/dist/src/remote/client.d.ts +4 -0
  92. package/dist/src/remote/client.js +84 -23
  93. package/dist/src/remote/fixtures.js +1 -1
  94. package/dist/src/remote/http.d.ts +10 -1
  95. package/dist/src/remote/http.js +15 -3
  96. package/dist/src/remote/schema.d.ts +3 -3
  97. package/dist/src/remote/schema.js +3 -3
  98. package/dist/src/remote/server/body.d.ts +1 -1
  99. package/dist/src/remote/server/body.js +1 -1
  100. package/dist/src/remote/server/byok_keys.d.ts +15 -0
  101. package/dist/src/remote/server/byok_keys.js +40 -0
  102. package/dist/src/remote/server/guides.js +2 -2
  103. package/dist/src/remote/server/routes.js +8 -1
  104. package/dist/src/remote/validate.js +15 -1
  105. package/dist/src/review/blocking.d.ts +2 -2
  106. package/dist/src/review/blocking.js +2 -2
  107. package/dist/src/review/carry_over.d.ts +2 -2
  108. package/dist/src/review/carry_over.js +2 -2
  109. package/dist/src/review/guides.js +1 -1
  110. package/dist/src/server/api/remote_tokens.js +2 -0
  111. package/dist/src/server/api/repos.js +51 -0
  112. package/dist/src/server/api/settings.js +10 -0
  113. package/dist/src/server/errors.d.ts +1 -1
  114. package/dist/src/server/http.js +1 -1
  115. package/dist/src/server/pages/activity.d.ts +2 -0
  116. package/dist/src/server/pages/activity.js +18 -4
  117. package/dist/src/server/pages/add_repo.js +61 -1
  118. package/dist/src/server/pages/analytics.js +18 -8
  119. package/dist/src/server/pages/cost.d.ts +21 -0
  120. package/dist/src/server/pages/cost.js +72 -0
  121. package/dist/src/server/pages/pr.js +5 -4
  122. package/dist/src/server/pages/repo.js +3 -2
  123. package/dist/src/server/pages/repo_prs.js +3 -2
  124. package/dist/src/server/pages/repo_remote.js +3 -2
  125. package/dist/src/server/pages/repo_settings.js +326 -12
  126. package/dist/src/server/pages/router.js +3 -2
  127. package/dist/src/server/pages/settings.js +48 -6
  128. package/dist/src/server/pages/styles.d.ts +1 -1
  129. package/dist/src/server/pages/styles.js +1 -1
  130. package/dist/src/services/credentials.js +8 -11
  131. package/dist/src/services/dashboard.d.ts +22 -9
  132. package/dist/src/services/dashboard.js +16 -2
  133. package/dist/src/services/probe.js +1 -1
  134. package/dist/src/services/remote_review.js +141 -35
  135. package/dist/src/services/remote_review_abort.d.ts +5 -1
  136. package/dist/src/services/remote_review_abort.js +9 -1
  137. package/dist/src/services/review.d.ts +1 -1
  138. package/dist/src/services/review.js +17 -5
  139. package/dist/src/services/review_policy.d.ts +116 -0
  140. package/dist/src/services/review_policy.js +489 -0
  141. package/dist/src/services/setup.d.ts +4 -3
  142. package/dist/src/services/setup.js +18 -16
  143. package/dist/src/services/setup_checklist.js +2 -2
  144. package/dist/src/services/webhook.d.ts +23 -0
  145. package/dist/src/services/webhook.js +141 -34
  146. package/dist/src/store/app_db.d.ts +5 -0
  147. package/dist/src/store/app_db.js +69 -13
  148. package/dist/src/store/backup.d.ts +31 -0
  149. package/dist/src/store/backup.js +158 -0
  150. package/dist/src/store/migrations.d.ts +1 -1
  151. package/dist/src/store/migrations.js +21 -1
  152. package/dist/src/store/remote_review_inputs.d.ts +3 -0
  153. package/dist/src/store/remote_review_inputs.js +3 -3
  154. package/dist/src/store/remote_tokens.d.ts +1 -1
  155. package/dist/src/store/remote_tokens.js +1 -1
  156. package/dist/src/store/repos.d.ts +2 -2
  157. package/dist/src/store/repos.js +1 -1
  158. package/dist/src/store/reviews.d.ts +21 -13
  159. package/dist/src/store/reviews.js +60 -15
  160. package/dist/src/store/rows.d.ts +10 -0
  161. package/dist/src/tools/codegraph.d.ts +4 -4
  162. package/dist/src/tools/codegraph.js +5 -5
  163. package/dist/src/tools/codegraph_exec.d.ts +1 -1
  164. package/dist/src/tools/codegraph_exec.js +1 -1
  165. package/dist/src/types.d.ts +12 -6
  166. package/dist/src/util/cost.d.ts +52 -0
  167. package/dist/src/util/cost.js +70 -0
  168. package/dist/src/util/log.d.ts +1 -1
  169. package/dist/src/util/log.js +2 -2
  170. package/dist/src/util/redact.js +1 -0
  171. package/dist/src/util/run_summary.d.ts +7 -2
  172. package/dist/src/util/run_summary.js +12 -2
  173. package/dist/src/util/time.d.ts +1 -1
  174. package/dist/src/util/time.js +1 -1
  175. package/package.json +1 -1
@@ -0,0 +1,206 @@
1
+ import { getEnv } from "../util/runtime.js";
2
+ import { CliError, EXIT_RUNTIME, EXIT_USAGE, networkFailure, } from "../cli/error.js";
3
+ /** `CM_OPENAI_URL` points at a local fake in tests. Unset means the real
4
+ * endpoint, so production behavior is unchanged. */
5
+ export function openAiBase() {
6
+ const endpoint = getEnv("CM_OPENAI_URL") ?? "https://api.openai.com/v1/responses";
7
+ return endpoint.replace(/\/responses\/?$/, "");
8
+ }
9
+ /** A failed response as actionable text. Only `error.message` is
10
+ * shown: the raw body can carry request ids that mean nothing in a terminal. */
11
+ export function openAiError(model, status, body) {
12
+ let detail = "";
13
+ let code = "";
14
+ try {
15
+ const parsed = JSON.parse(body);
16
+ detail = String(parsed.error?.message ?? "");
17
+ code = String(parsed.error?.code ?? "");
18
+ }
19
+ catch {
20
+ detail = body.trim().slice(0, 200);
21
+ }
22
+ if (status === 401) {
23
+ return new CliError("openai_unauthorized", "OpenAI rejected the API key.", "Set a new one with co-maintainer set --token=...", EXIT_USAGE);
24
+ }
25
+ if (status === 400 && (code === "model_not_found" || /model/i.test(detail))) {
26
+ return new CliError("openai_unknown_model", `OpenAI does not know the model ${model}.`, "Pick one at https://platform.openai.com/docs/models and set it with co-maintainer set --high-model=...", EXIT_USAGE);
27
+ }
28
+ return new CliError("openai_failed", `OpenAI failed with ${status}${detail ? `: ${detail}` : "."}`, undefined, EXIT_RUNTIME);
29
+ }
30
+ /** The Chat-Completions-shaped message list every caller already builds
31
+ * (`chatBody` does the same fallback for OpenRouter). */
32
+ function effectiveMessages(request) {
33
+ return (request.messages ?? [
34
+ ...(request.system
35
+ ? [{ role: "system", content: request.system }]
36
+ : []),
37
+ { role: "user", content: request.prompt },
38
+ ]);
39
+ }
40
+ /** `{type:"function",function:{name,parameters}}` (what `mermaid.ts` and the
41
+ * codegraph tools build) to Responses' flat shape. */
42
+ function toResponsesTool(tool) {
43
+ const fn = (tool.function ?? {});
44
+ return {
45
+ type: "function",
46
+ name: String(fn.name ?? ""),
47
+ description: fn.description ? String(fn.description) : undefined,
48
+ parameters: fn.parameters,
49
+ };
50
+ }
51
+ /** A tool call and its result do not live inside a message the way Chat
52
+ * Completions nests them; each is its own item in `input`. */
53
+ function toResponsesInput(messages) {
54
+ const input = [];
55
+ let instructions;
56
+ for (const message of messages) {
57
+ if (message.role === "system") {
58
+ instructions = instructions
59
+ ? `${instructions}\n\n${message.content ?? ""}`
60
+ : String(message.content ?? "");
61
+ continue;
62
+ }
63
+ if (message.role === "tool") {
64
+ input.push({
65
+ type: "function_call_output",
66
+ call_id: message.tool_call_id,
67
+ output: message.content ?? "",
68
+ });
69
+ continue;
70
+ }
71
+ if (message.tool_calls?.length) {
72
+ if (message.content) {
73
+ input.push({ role: "assistant", content: message.content });
74
+ }
75
+ for (const call of message.tool_calls) {
76
+ input.push({
77
+ type: "function_call",
78
+ call_id: call.id,
79
+ name: call.function.name,
80
+ arguments: call.function.arguments,
81
+ });
82
+ }
83
+ continue;
84
+ }
85
+ input.push({ role: message.role, content: message.content ?? "" });
86
+ }
87
+ return { instructions, input };
88
+ }
89
+ function responsesBody(model, request) {
90
+ const { instructions, input } = toResponsesInput(effectiveMessages(request));
91
+ const schema = request.responseFormat?.json_schema;
92
+ return {
93
+ model,
94
+ ...(instructions ? { instructions } : {}),
95
+ input,
96
+ max_output_tokens: request.maxTokens,
97
+ ...(request.reasoningEffort
98
+ ? { reasoning: { effort: request.reasoningEffort } }
99
+ : {}),
100
+ ...(request.tools?.length
101
+ ? {
102
+ tools: request.tools.map(toResponsesTool),
103
+ tool_choice: "auto",
104
+ }
105
+ : {}),
106
+ ...(schema
107
+ ? {
108
+ text: {
109
+ format: {
110
+ type: "json_schema",
111
+ name: schema.name,
112
+ schema: schema.schema,
113
+ strict: schema.strict ?? false,
114
+ },
115
+ },
116
+ }
117
+ : {}),
118
+ };
119
+ }
120
+ function parseResponsesBody(json, model) {
121
+ const output = Array.isArray(json.output)
122
+ ? json.output
123
+ : [];
124
+ const texts = [];
125
+ const toolCalls = [];
126
+ for (const item of output) {
127
+ if (item.type === "function_call" && item.call_id && item.name) {
128
+ toolCalls.push({
129
+ id: item.call_id,
130
+ type: "function",
131
+ function: { name: item.name, arguments: item.arguments ?? "{}" },
132
+ });
133
+ continue;
134
+ }
135
+ if (item.type !== "message")
136
+ continue;
137
+ for (const part of item.content ?? []) {
138
+ if (part.type === "refusal") {
139
+ throw new CliError("openai_refusal", `OpenAI refused to answer: ${part.refusal ?? "no reason given"}`, undefined, EXIT_RUNTIME);
140
+ }
141
+ if (part.type === "output_text" && part.text)
142
+ texts.push(part.text);
143
+ }
144
+ }
145
+ const usage = json.usage;
146
+ return {
147
+ text: texts.join(""),
148
+ ...(toolCalls.length ? { toolCalls } : {}),
149
+ tokensIn: Number(usage?.input_tokens ?? 0),
150
+ tokensOut: Number(usage?.output_tokens ?? 0),
151
+ // OpenAI's Responses API reports no dollar cost, so this is always
152
+ // unknown (`costReasonText`'s "provider_did_not_report").
153
+ cost: undefined,
154
+ model,
155
+ provider: "openai",
156
+ };
157
+ }
158
+ export class OpenAiProvider {
159
+ supportsTools = true;
160
+ endpoint = `${openAiBase()}/responses`;
161
+ apiKey;
162
+ model;
163
+ constructor(apiKey, model) {
164
+ if (!apiKey)
165
+ throw new Error("OpenAI requires an API key");
166
+ this.apiKey = apiKey;
167
+ this.model = model;
168
+ }
169
+ async post(body) {
170
+ try {
171
+ return await fetch(this.endpoint, {
172
+ method: "POST",
173
+ headers: {
174
+ Authorization: `Bearer ${this.apiKey}`,
175
+ "Content-Type": "application/json",
176
+ },
177
+ body: JSON.stringify(body),
178
+ });
179
+ }
180
+ catch (error) {
181
+ throw networkFailure(this.endpoint, error);
182
+ }
183
+ }
184
+ async complete(request) {
185
+ let response = await this.post(responsesBody(this.model, request));
186
+ // A model may reject `text.format` next to tools. The schema is only a
187
+ // hint (the prompt also asks for a fenced JSON block), so one retry
188
+ // without it beats failing the review outright.
189
+ if (response.status === 400 && request.responseFormat) {
190
+ const detail = await response.text();
191
+ if (/text\.format|json_schema|structured/i.test(detail)) {
192
+ const { responseFormat: _dropped, ...withoutSchema } = request;
193
+ response = await this.post(responsesBody(this.model, withoutSchema));
194
+ if (response.ok) {
195
+ return parseResponsesBody((await response.json()), this.model);
196
+ }
197
+ throw openAiError(this.model, response.status, await response.text());
198
+ }
199
+ throw openAiError(this.model, response.status, detail);
200
+ }
201
+ if (!response.ok) {
202
+ throw openAiError(this.model, response.status, await response.text());
203
+ }
204
+ return parseResponsesBody((await response.json()), this.model);
205
+ }
206
+ }
@@ -1,6 +1,6 @@
1
1
  import type { AiProvider, AiRequest, AiResponse } from "../types.ts";
2
2
  import { CliError } from "../cli/error.ts";
3
- /** Provider failures as actionable text (CORE-12, F24/F28). A 401 and a 400 for
3
+ /** Provider failures as actionable text. A 401 and a 400 for
4
4
  * an unknown model are preconditions the user can fix, so they are usage
5
5
  * errors; anything else is a runtime failure. Only the provider's own
6
6
  * `error.message` is surfaced: the raw body can carry account metadata
@@ -1,7 +1,7 @@
1
1
  import { chatBody, parseChatResponse } from "./provider.js";
2
2
  import { getEnv } from "../util/runtime.js";
3
3
  import { CliError, EXIT_RUNTIME, EXIT_USAGE, networkFailure, } from "../cli/error.js";
4
- /** Provider failures as actionable text (CORE-12, F24/F28). A 401 and a 400 for
4
+ /** Provider failures as actionable text. A 401 and a 400 for
5
5
  * an unknown model are preconditions the user can fix, so they are usage
6
6
  * errors; anything else is a runtime failure. Only the provider's own
7
7
  * `error.message` is surfaced: the raw body can carry account metadata
@@ -54,7 +54,7 @@ export class OpenRouterProvider {
54
54
  });
55
55
  }
56
56
  catch (error) {
57
- // `fetch failed` alone names neither the host nor the cause (CORE-12).
57
+ // `fetch failed` alone names neither the host nor the cause.
58
58
  throw networkFailure(this.endpoint, error);
59
59
  }
60
60
  }
@@ -62,7 +62,7 @@ export class OpenRouterProvider {
62
62
  let response = await this.post(chatBody(this.model, request));
63
63
  // A provider may refuse `response_format` next to `tools`. The schema is
64
64
  // only a hint — the prompt also asks for a fenced JSON block — so one
65
- // retry without it is better than failing the review (CORE-40).
65
+ // retry without it is better than failing the review.
66
66
  if (response.status === 400 && request.responseFormat) {
67
67
  const detail = await response.text();
68
68
  if (/response_format|json_schema|structured/i.test(detail)) {
@@ -1,4 +1,4 @@
1
- /** OpenRouter model prices for the probe estimate (CORE-24).
1
+ /** OpenRouter model prices for the probe estimate.
2
2
  *
3
3
  * `/api/v1/models` is public: no key needed. The list is large and changes
4
4
  * slowly, so it is cached for 24 hours in cache.db. A network failure is not
@@ -1,4 +1,10 @@
1
1
  import type { AiProvider, AiRequest, AiResponse, Json, Options } from "../types.ts";
2
+ /** The one list of accepted `--ai` values, so a new or retired provider is
3
+ * added or removed in a single place instead of the half dozen copies
4
+ * Hetzner's removal had to chase down. */
5
+ export declare const AI_PROVIDERS: readonly ["none", "openrouter", "openai", "anthropic"];
6
+ /** Hetzner was dropped in 0.5.1. Naming it beats a generic "must be one of". */
7
+ export declare function rejectRetiredProvider(value: string | undefined): void;
2
8
  export declare function createAiProvider(options: Options, model: string): AiProvider | undefined;
3
9
  export declare function parseChatResponse(json: Json, provider: AiResponse["provider"], model: string): AiResponse;
4
10
  export declare function chatBody(model: string, request: AiRequest): Json;
@@ -1,12 +1,35 @@
1
- import { HetznerProvider } from "./hetzner.js";
2
1
  import { OpenRouterProvider } from "./openrouter.js";
2
+ import { OpenAiProvider } from "./openai.js";
3
+ import { AnthropicProvider } from "./anthropic.js";
4
+ import { die } from "../cli/error.js";
5
+ /** The one list of accepted `--ai` values, so a new or retired provider is
6
+ * added or removed in a single place instead of the half dozen copies
7
+ * Hetzner's removal had to chase down. */
8
+ export const AI_PROVIDERS = [
9
+ "none",
10
+ "openrouter",
11
+ "openai",
12
+ "anthropic",
13
+ ];
14
+ /** Hetzner was dropped in 0.5.1. Naming it beats a generic "must be one of". */
15
+ export function rejectRetiredProvider(value) {
16
+ if (value === "hetzner") {
17
+ die("Hetzner is no longer supported. Use openrouter or none.");
18
+ }
19
+ }
3
20
  export function createAiProvider(options, model) {
4
21
  if (options.ai === "none")
5
22
  return undefined;
6
23
  if (options.ai === "openrouter") {
7
24
  return new OpenRouterProvider(options.aiToken ?? "", model);
8
25
  }
9
- return new HetznerProvider(options.aiToken ?? "", model);
26
+ if (options.ai === "openai") {
27
+ return new OpenAiProvider(options.aiToken ?? "", model);
28
+ }
29
+ if (options.ai === "anthropic") {
30
+ return new AnthropicProvider(options.aiToken ?? "", model);
31
+ }
32
+ die(`${options.ai} support is not finished yet. Use openrouter or none.`);
10
33
  }
11
34
  export function parseChatResponse(json, provider, model) {
12
35
  const choice = Array.isArray(json.choices)
@@ -59,7 +82,7 @@ export function chatBody(model, request) {
59
82
  ...(request.tools?.length
60
83
  ? { tools: request.tools, tool_choice: "auto" }
61
84
  : {}),
62
- // A review asks for structured output (CORE-40). The schema is sent even
85
+ // A review asks for structured output. The schema is sent even
63
86
  // when tools are present, because most providers accept the pair; one that
64
87
  // rejects it gets a single retry without the schema (see
65
88
  // `OpenRouterProvider.complete`), and the prompt also asks for a fenced
@@ -1,4 +1,4 @@
1
- /** Pre-flight checks for `config set` (CORE-22, F35).
1
+ /** Pre-flight checks for `config set`.
2
2
  *
3
3
  * A wrong API key or an unknown model used to be accepted by `set` and only
4
4
  * surfaced during a review, after the pull request had been fetched and
@@ -23,3 +23,11 @@ export type VerifyResult = {
23
23
  * lists it. The first failure decides: a bad key is reported before a model
24
24
  * question, since the model check would fail on the key too. */
25
25
  export declare function verifyOpenRouter(apiKey: string, model?: string): Promise<VerifyResult>;
26
+ /** Same shape as {@link verifyOpenRouter}: `/models` both authenticates the
27
+ * key and, when `model` is given, confirms OpenAI actually has it. */
28
+ export declare function verifyOpenAi(apiKey: string, model?: string): Promise<VerifyResult>;
29
+ /** Anthropic authenticates the key and lists models at `/models`, but with its
30
+ * own headers (`x-api-key` + `anthropic-version`) instead of a Bearer token.
31
+ * Without a model the call only proves the key; with one it also confirms
32
+ * Anthropic serves it. */
33
+ export declare function verifyAnthropic(apiKey: string, model?: string): Promise<VerifyResult>;
@@ -1,4 +1,4 @@
1
- /** Pre-flight checks for `config set` (CORE-22, F35).
1
+ /** Pre-flight checks for `config set`.
2
2
  *
3
3
  * A wrong API key or an unknown model used to be accepted by `set` and only
4
4
  * surfaced during a review, after the pull request had been fetched and
@@ -7,6 +7,8 @@
7
7
  * user's mistake, so it warns and saves instead of refusing. */
8
8
  import { CliError, EXIT_USAGE } from "../cli/error.js";
9
9
  import { getEnv } from "../util/runtime.js";
10
+ import { openAiBase } from "./openai.js";
11
+ import { anthropicBase, ANTHROPIC_VERSION } from "./anthropic.js";
10
12
  /** The chat endpoint the provider uses, with `/chat/completions` removed.
11
13
  * `CM_OPENROUTER_URL` points at a local fake in tests, so the base follows it
12
14
  * and the verification hits the same server the review would. */
@@ -15,11 +17,11 @@ export function openRouterBase() {
15
17
  "https://openrouter.ai/api/v1/chat/completions";
16
18
  return endpoint.replace(/\/chat\/completions\/?$/, "");
17
19
  }
18
- async function getJson(url, apiKey) {
20
+ async function getJson(url, apiKey, headers) {
19
21
  let response;
20
22
  try {
21
23
  response = await fetch(url, {
22
- headers: { Authorization: `Bearer ${apiKey}` },
24
+ headers: headers ?? { Authorization: `Bearer ${apiKey}` },
23
25
  });
24
26
  }
25
27
  catch (error) {
@@ -76,3 +78,79 @@ export async function verifyOpenRouter(apiKey, model) {
76
78
  }
77
79
  return { status: "ok" };
78
80
  }
81
+ /** Same shape as {@link verifyOpenRouter}: `/models` both authenticates the
82
+ * key and, when `model` is given, confirms OpenAI actually has it. */
83
+ export async function verifyOpenAi(apiKey, model) {
84
+ const base = openAiBase();
85
+ const models = await getJson(`${base}/models`, apiKey);
86
+ if ("unreachable" in models) {
87
+ return { status: "unreachable", reason: models.unreachable };
88
+ }
89
+ if (models.status === 401) {
90
+ return {
91
+ status: "rejected",
92
+ error: new CliError("openai_unauthorized", "OpenAI rejected the API key.", "Check the key at https://platform.openai.com/api-keys and pass it again.", EXIT_USAGE),
93
+ };
94
+ }
95
+ if (models.status >= 400) {
96
+ return {
97
+ status: "unreachable",
98
+ reason: `OpenAI returned ${models.status}`,
99
+ };
100
+ }
101
+ if (!model)
102
+ return { status: "ok" };
103
+ const rows = models.body?.data;
104
+ if (!Array.isArray(rows)) {
105
+ return { status: "unreachable", reason: "OpenAI returned no model list" };
106
+ }
107
+ if (!rows.some((row) => row?.id === model)) {
108
+ return {
109
+ status: "rejected",
110
+ error: new CliError("openai_unknown_model", `OpenAI does not know the model ${model}.`, "Pick one at https://platform.openai.com/docs/models and set it with co-maintainer set --high-model=...", EXIT_USAGE),
111
+ };
112
+ }
113
+ return { status: "ok" };
114
+ }
115
+ /** Anthropic authenticates the key and lists models at `/models`, but with its
116
+ * own headers (`x-api-key` + `anthropic-version`) instead of a Bearer token.
117
+ * Without a model the call only proves the key; with one it also confirms
118
+ * Anthropic serves it. */
119
+ export async function verifyAnthropic(apiKey, model) {
120
+ const base = anthropicBase();
121
+ const models = await getJson(`${base}/models`, apiKey, {
122
+ "x-api-key": apiKey,
123
+ "anthropic-version": ANTHROPIC_VERSION,
124
+ });
125
+ if ("unreachable" in models) {
126
+ return { status: "unreachable", reason: models.unreachable };
127
+ }
128
+ if (models.status === 401 || models.status === 403) {
129
+ return {
130
+ status: "rejected",
131
+ error: new CliError("anthropic_unauthorized", "Anthropic rejected the API key.", "Check the key at https://console.anthropic.com/settings/keys and pass it again.", EXIT_USAGE),
132
+ };
133
+ }
134
+ if (models.status >= 400) {
135
+ return {
136
+ status: "unreachable",
137
+ reason: `Anthropic returned ${models.status}`,
138
+ };
139
+ }
140
+ if (!model)
141
+ return { status: "ok" };
142
+ const rows = models.body?.data;
143
+ if (!Array.isArray(rows)) {
144
+ return {
145
+ status: "unreachable",
146
+ reason: "Anthropic returned no model list",
147
+ };
148
+ }
149
+ if (!rows.some((row) => row?.id === model)) {
150
+ return {
151
+ status: "rejected",
152
+ error: new CliError("anthropic_unknown_model", `Anthropic does not know the model ${model}.`, "Pick one at https://docs.anthropic.com/en/docs/about-claude/models and set it with co-maintainer set --high-model=...", EXIT_USAGE),
153
+ };
154
+ }
155
+ return { status: "ok" };
156
+ }
@@ -1,5 +1,4 @@
1
1
  import type { Options } from "../types.ts";
2
- export declare const defaultLowModel = "openai/gpt-oss-120b";
3
- /** Plan §8.7 — `--json` must not prompt. */
2
+ /** `--json` must not prompt. */
4
3
  export declare function setCliInteractive(value: boolean): void;
5
4
  export declare function parseArgs(args: string[]): Promise<Options>;
@@ -2,6 +2,7 @@ import { prepareConfig } from "../config.js";
2
2
  import { getEnv } from "../util/runtime.js";
3
3
  import { askLine } from "./prompt.js";
4
4
  import { die } from "./error.js";
5
+ import { AI_PROVIDERS, rejectRetiredProvider } from "../ai/provider.js";
5
6
  import { detectRemoteRepo } from "../local/git_ops.js";
6
7
  import { reviewBlockingFrom } from "../review/blocking.js";
7
8
  import { renderCommandHelp, renderGlobalHelp, unknownCommandMessage, unknownOptionMessage, } from "./commands/registry.js";
@@ -13,10 +14,8 @@ function numberOption(value, name) {
13
14
  return number;
14
15
  }
15
16
  const commands = ["probe", "init", "sync", "remake", "review"];
16
- export const defaultLowModel = "openai/gpt-oss-120b";
17
- const defaultHighModel = "openai/gpt-5.6-luna";
18
17
  let cliInteractive = true;
19
- /** Plan §8.7 — `--json` must not prompt. */
18
+ /** `--json` must not prompt. */
20
19
  export function setCliInteractive(value) {
21
20
  cliInteractive = value;
22
21
  }
@@ -24,10 +23,10 @@ async function ask(label, fallback, required = false) {
24
23
  if (!cliInteractive) {
25
24
  if (fallback !== undefined && fallback !== "")
26
25
  return fallback;
27
- die(`Missing ${label}; pass it as a CLI option when running without an interactive terminal`);
26
+ die(`Missing ${label}. Pass it as a CLI option when running without an interactive terminal`);
28
27
  }
29
28
  if (process.stdin.isTTY !== true) {
30
- die(`Missing ${label}; pass it as a CLI option when running without an interactive terminal`);
29
+ die(`Missing ${label}. Pass it as a CLI option when running without an interactive terminal`);
31
30
  }
32
31
  const value = await askLine(label, fallback);
33
32
  if (required && !value)
@@ -57,7 +56,7 @@ export async function parseArgs(args) {
57
56
  process.exit(0);
58
57
  }
59
58
  // `probe` (and only probe, for now) may omit the repo and let the current
60
- // directory's git remote name it (CORE-24).
59
+ // directory's git remote name it.
61
60
  let repoName = repo;
62
61
  if ((!repoName || repoName.startsWith("-")) && command === "probe") {
63
62
  repoName = await detectRemoteRepo(process.cwd());
@@ -168,9 +167,10 @@ export async function parseArgs(args) {
168
167
  }
169
168
  const explicitAi = rest.some((arg) => arg.startsWith("--ai="));
170
169
  const configuredAiRaw = env("CO_MAINTAINER_AI") ?? repoConfig.ai ?? config.ai;
170
+ rejectRetiredProvider(configuredAiRaw);
171
171
  if (configuredAiRaw &&
172
- !["none", "openrouter", "hetzner"].includes(configuredAiRaw)) {
173
- die("CO_MAINTAINER_AI/config.ai must be none, openrouter, or hetzner");
172
+ !AI_PROVIDERS.includes(configuredAiRaw)) {
173
+ die(`CO_MAINTAINER_AI/config.ai must be one of: ${AI_PROVIDERS.join(", ")}`);
174
174
  }
175
175
  const configuredAi = configuredAiRaw;
176
176
  const configuredAuthRaw = env("CO_MAINTAINER_AUTH") ?? repoConfig.auth ?? config.auth;
@@ -187,46 +187,50 @@ export async function parseArgs(args) {
187
187
  die("--auth=pat requires a GitHub token. Pass --github-pat=..., set GITHUB_TOKEN/GH_TOKEN, " +
188
188
  "or run: co-maintainer set --github-pat=...");
189
189
  }
190
- let ai = choice("ai", ["none", "openrouter", "hetzner"], configuredAi ?? "none");
190
+ rejectRetiredProvider(text("ai"));
191
+ let ai = choice("ai", AI_PROVIDERS, configuredAi ?? "none");
191
192
  if (command === "review") {
192
- if (explicitAi &&
193
- choice("ai", ["none", "openrouter", "hetzner"], "none") !== "openrouter") {
193
+ if (explicitAi && choice("ai", AI_PROVIDERS, "none") !== "openrouter") {
194
194
  die("review supports OpenRouter only");
195
195
  }
196
196
  ai = "openrouter";
197
197
  }
198
198
  else if (!explicitAi && !configuredAi && command !== "probe") {
199
- const selected = await ask("AI provider (openrouter|hetzner)", "openrouter");
200
- if (!["openrouter", "hetzner"].includes(selected)) {
201
- die("AI provider must be openrouter or hetzner");
199
+ const selected = await ask(`AI provider (${AI_PROVIDERS.join("|")})`, "openrouter");
200
+ rejectRetiredProvider(selected);
201
+ if (!AI_PROVIDERS.includes(selected)) {
202
+ die(`AI provider must be one of: ${AI_PROVIDERS.join(", ")}`);
202
203
  }
203
204
  ai = selected;
204
205
  }
205
206
  let aiToken = text("token") ??
206
207
  env("CO_MAINTAINER_TOKEN") ??
207
208
  (ai === "openrouter" ? env("OPENROUTER_API_KEY") : undefined) ??
208
- (ai === "hetzner" ? env("HETZNER_API_KEY") : undefined) ??
209
209
  config.token;
210
210
  let lowModel = text("low-model") ??
211
211
  env("OPENROUTER_LOW_MODEL") ??
212
- env("HETZNER_LOW_MODEL") ??
213
212
  env("LOW_MODEL") ??
214
213
  repoConfig.lowModel ??
215
214
  config.lowModel;
216
215
  let highModel = text("high-model") ??
217
216
  env("OPENROUTER_HIGH_MODEL") ??
218
- env("HETZNER_HIGH_MODEL") ??
219
217
  env("HIGH_MODEL") ??
220
218
  repoConfig.highModel ??
221
219
  config.highModel;
222
220
  if (command === "review") {
223
221
  aiToken ??= await ask("openrouter API key", undefined, true);
224
- highModel ??= await ask("high model", defaultHighModel, true);
222
+ // Review summarizes large files with the low model, so it needs one just
223
+ // as init and sync do. Without a default to fall back on, ask here too.
224
+ lowModel ??= await ask("low model", undefined, true);
225
+ highModel ??= await ask("high model", undefined, true);
225
226
  }
226
227
  else if (ai !== "none" && command !== "probe") {
228
+ // No house pick for any provider: models go stale and a shipped build
229
+ // cannot fetch a fresher one, so the model to use is the user's call,
230
+ // set once in config or passed every time (Murat, 2026-09-28).
227
231
  aiToken ??= await ask(`${ai} API key`, undefined, true);
228
- lowModel ??= await ask("low model", defaultLowModel, true);
229
- highModel ??= await ask("high model", defaultHighModel, true);
232
+ lowModel ??= await ask("low model", undefined, true);
233
+ highModel ??= await ask("high model", undefined, true);
230
234
  }
231
235
  if (command !== "review" &&
232
236
  ai !== "none" &&
@@ -37,7 +37,7 @@ export declare function resolveField(name: string): string;
37
37
  * `defaults` are nested maps, not settings, so they are not listed.
38
38
  * `satisfies` makes this the type's own vocabulary: a name that is not a
39
39
  * `UserConfig` key is a compile error rather than a silently undefined read. */
40
- export declare const KNOWN_FIELDS: readonly ["auth", "ai", "lowModel", "highModel", "token", "githubPat", "githubAppId", "githubAppPrivateKey", "githubAppPrivateKeyPath", "githubWebhookSecret", "webhookUrl", "githubOAuthClientId", "githubOAuthClientSecret", "githubOAuthAllowedUser", "passwordAuthDisabled", "githubAuthEnabled", "dashboardPasswordHash", "maxConcurrentJobs", "reviewBlocking", "remoteHost", "remoteToken", "remoteNoticeShownFor", "remoteSyncTimeoutSeconds", "maxConcurrentRemoteReviewsPerToken", "remoteToolOutputMaxChars"];
40
+ export declare const KNOWN_FIELDS: readonly ["auth", "ai", "lowModel", "highModel", "token", "githubPat", "githubAppId", "githubAppPrivateKey", "githubAppPrivateKeyPath", "githubWebhookSecret", "webhookUrl", "githubOAuthClientId", "githubOAuthClientSecret", "githubOAuthAllowedUser", "passwordAuthDisabled", "githubAuthEnabled", "dashboardPasswordHash", "maxConcurrentJobs", "reviewBlocking", "remoteHost", "remoteToken", "remoteNoticeShownFor", "remoteSyncTimeoutSeconds", "maxConcurrentRemoteReviewsPerToken", "remoteToolOutputMaxChars", "remoteByok", "remoteByokDefault", "reviewPolicy"];
41
41
  /** `co-maintainer config <sub> ...`. `set` is handled by runSet, which this
42
42
  * command delegates to so there is one implementation of the write rules. */
43
43
  export declare function runConfig(args: string[]): Promise<void>;
@@ -1,5 +1,5 @@
1
1
  /** The `config` command: read and edit the user config without opening the
2
- * file (CORE-22, F12). `set` is `config set` under its own name, so both share
2
+ * file. `set` is `config set` under its own name, so both share
3
3
  * the mapping and masking here.
4
4
  *
5
5
  * The key list is derived from `UserConfig` at runtime where possible: a
@@ -18,6 +18,7 @@ export const SECRET_FIELDS = new Set([
18
18
  "githubOAuthClientSecret",
19
19
  "remoteToken",
20
20
  "dashboardPasswordHash",
21
+ "remoteByok",
21
22
  ]);
22
23
  /** `lowModel` -> `low-model`. The inverse of {@link configKey}. */
23
24
  export function flagName(field) {
@@ -38,13 +39,15 @@ export function maskSecret(value) {
38
39
  /** The env var that supplies each field, mirroring args.ts. Only the fields
39
40
  * that actually read env are listed. */
40
41
  export const ENV_BY_FIELD = {
41
- token: ["CO_MAINTAINER_TOKEN", "OPENROUTER_API_KEY", "HETZNER_API_KEY"],
42
+ token: ["CO_MAINTAINER_TOKEN", "OPENROUTER_API_KEY"],
42
43
  githubPat: ["GITHUB_TOKEN", "GH_TOKEN"],
43
44
  ai: ["CO_MAINTAINER_AI"],
44
45
  auth: ["CO_MAINTAINER_AUTH"],
45
- lowModel: ["OPENROUTER_LOW_MODEL", "HETZNER_LOW_MODEL", "LOW_MODEL"],
46
- highModel: ["OPENROUTER_HIGH_MODEL", "HETZNER_HIGH_MODEL", "HIGH_MODEL"],
46
+ lowModel: ["OPENROUTER_LOW_MODEL", "LOW_MODEL"],
47
+ highModel: ["OPENROUTER_HIGH_MODEL", "HIGH_MODEL"],
47
48
  webhookUrl: ["CM_WEBHOOK_URL"],
49
+ remoteHost: ["CM_REMOTE_HOST"],
50
+ remoteToken: ["CM_REMOTE_TOKEN"],
48
51
  reviewBlocking: ["CO_MAINTAINER_REVIEW_BLOCKING"],
49
52
  };
50
53
  /** Renders one field for people. A secret is masked; the password hash, which
@@ -151,6 +154,9 @@ export const KNOWN_FIELDS = [
151
154
  "remoteSyncTimeoutSeconds",
152
155
  "maxConcurrentRemoteReviewsPerToken",
153
156
  "remoteToolOutputMaxChars",
157
+ "remoteByok",
158
+ "remoteByokDefault",
159
+ "reviewPolicy",
154
160
  ];
155
161
  /** `co-maintainer config <sub> ...`. `set` is handled by runSet, which this
156
162
  * command delegates to so there is one implementation of the write rules. */
@@ -1,7 +1,7 @@
1
1
  import { clientFor, runInitOrRemake } from "../../services/setup.js";
2
2
  import { probePlan } from "../../services/probe.js";
3
3
  import { log, startHeartbeat } from "../../util/log.js";
4
- import { estimateInit, readJobHistory } from "../../ai/estimate.js";
4
+ import { estimateCostLine, estimateInit, readJobHistory, } from "../../ai/estimate.js";
5
5
  import { loadPrices } from "../../ai/pricing.js";
6
6
  import { cacheSet } from "../../store/cache_db.js";
7
7
  export async function runProbe(options) {
@@ -25,7 +25,7 @@ export async function runProbe(options) {
25
25
  for (const reason of analysis.reasons)
26
26
  console.log(` - ${reason}`);
27
27
  // The estimate is separate from the recommendation: the flags say what to
28
- // read, this says what that costs in time and dollars (CORE-24). Prices are
28
+ // read, this says what that costs in time and dollars. Prices are
29
29
  // only fetched when an OpenRouter model would actually be billed, so a
30
30
  // plain `--ai=none` probe makes no network call.
31
31
  const needsPrices = options.ai === "openrouter" &&
@@ -50,15 +50,7 @@ export async function runProbe(options) {
50
50
  console.log(` AI jobs: ${estimate.extract} extract + ${estimate.synth} synth`);
51
51
  console.log(` tokens: ${range(estimate.tokensIn)} in · ${range(estimate.tokensOut)} out`);
52
52
  console.log(` time: ${secondsRange(estimate.seconds)}s`);
53
- if (estimate.usd) {
54
- console.log(` cost: $${estimate.usd[0].toFixed(4)}-$${estimate.usd[1].toFixed(4)}`);
55
- }
56
- else if (needsPrices && prices.source === "unavailable") {
57
- console.log(" cost: unknown (could not read OpenRouter prices)");
58
- }
59
- else {
60
- console.log(" cost: set --ai=openrouter with both models to estimate dollars");
61
- }
53
+ console.log(` cost: ${estimateCostLine(estimate, options.ai, needsPrices && prices.source === "unavailable")}`);
62
54
  console.log(` basis: ${estimate.basis === "history"
63
55
  ? "this repository's recorded jobs"
64
56
  : "the cm-dx-lab calibration"}; an estimate, not a bill`);