@counterfact-labs/cli 0.2.5 → 0.2.6

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "verificationKey": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAbBab1PYdmBKXxCvoJ47toJhQvxPevo9nlZnb0zbDWoc=\n-----END PUBLIC KEY-----\n",
3
3
  "relayVersion": "relay-1.0.1",
4
- "localVersion": "0.2.5",
4
+ "localVersion": "0.2.6",
5
5
  "relayUrl": "https://relay.counterfactlabs.com:8443",
6
6
  "projectContext": "checks"
7
7
  }
@@ -1,5 +1,5 @@
1
1
  import { resolveFolderPath } from "../adapters/folder-path.js";
2
- import { START_FAILURE_MESSAGE } from "../contract/cli-refusals.js";
2
+ import { CliRefusalError } from "../contract/cli-refusals.js";
3
3
  import { isAbsolute, parse, relative, resolve, sep } from "node:path";
4
4
  export function resolveAgentFolder(path, cwd) {
5
5
  return resolve(cwd ?? process.cwd(), path);
@@ -8,10 +8,10 @@ export function resolveAgentFolder(path, cwd) {
8
8
  export async function resolveMcpAgentFolder(path, cwd, homeDir) {
9
9
  const [folder, launch, home] = await Promise.all([
10
10
  resolveFolderPath(resolveAgentFolder(path, cwd)), resolveFolderPath(cwd), resolveFolderPath(homeDir),
11
- ]).catch(() => { throw new Error(START_FAILURE_MESSAGE); });
11
+ ]).catch(() => { throw new CliRefusalError("folder_unavailable"); });
12
12
  const rel = relative(launch, folder);
13
13
  if (folder === parse(folder).root || folder === home || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
14
- throw new Error(START_FAILURE_MESSAGE);
14
+ throw new CliRefusalError("outside_project");
15
15
  }
16
16
  return folder;
17
17
  }
@@ -56,10 +56,10 @@ export async function analyze(path, options, ctx) {
56
56
  const development = startup.transport.CF_RELAY_DEV === "1" ? await loadDevelopmentRelay(startup.transport) : undefined;
57
57
  const release = development?.release ?? ctx.relayRuntime?.release ?? await loadInstalledRelease();
58
58
  if (release.projectContext !== "checks")
59
- throw new Error(unavailable);
59
+ throw new ApiError(503, "unavailable", unavailable);
60
60
  const token = await ctx.token?.();
61
61
  if (!token)
62
- throw new Error("Not signed in. Run counterfact-labs login.");
62
+ throw new ApiError(401, "unauthorized", cliApiMessages.unauthorized);
63
63
  const folder = resolveAgentFolder(path, ctx.cwd);
64
64
  const config = Object.freeze({ ...startup.transport, CF_RELAY_URL: startup.transport.CF_RELAY_URL ?? release.relayUrl,
65
65
  CF_LOCAL_RELAY: "1", CF_CONSOLE_URL: ctx.api.baseUrl, CF_CONSOLE_TOKEN: token });
@@ -86,6 +86,7 @@ export async function analyze(path, options, ctx) {
86
86
  }
87
87
  },
88
88
  onError: failure => {
89
+ ctx.onCheckFailure?.(failure);
89
90
  if (failure.agentChoice)
90
91
  ctx.onAgentChoice?.(failure.agentChoice);
91
92
  clearProgress();
@@ -1,6 +1,9 @@
1
1
  import { money } from "../output.js";
2
- export async function balance(ctx) {
3
- const value = await ctx.api.request("balance");
4
- ctx.write(`Balance: ${money(value.balanceMicro)}\nTop up: ${value.topUpUrl}\n`);
2
+ export async function balance(ctx, options = {}) {
3
+ const response = await ctx.api.request("balance");
4
+ const value = { ...response, freeAnalysisAvailable: response.freeAnalysisAvailable ?? false };
5
+ options.onLoaded?.(value);
6
+ const freeAnalysis = value.freeAnalysisAvailable ? "Your first analysis is free.\n" : "";
7
+ ctx.write(`Balance: ${money(value.balanceMicro)}\n${freeAnalysis}Top up: ${value.topUpUrl}\n`);
5
8
  return 0;
6
9
  }
@@ -16,8 +16,10 @@ export function commandContext(options = {}) {
16
16
  const findingSettings = options.findingSettings ?? readFindingSettings(options.env);
17
17
  return {
18
18
  findingNumbers: findingSettings.numbersEnabled,
19
+ signInRetry: (options.env ?? process.env).CF_SIGN_IN_RETRY !== "0",
19
20
  startup, signal: options.signal, relayRuntime: options.relayRuntime, cwd: options.cwd ?? process.cwd(),
20
21
  token: () => readCredentials(homeDir, api.baseUrl), onCheckComplete: options.onCheckComplete, onCheckStart: options.onCheckStart, onAgentChoice: options.onAgentChoice,
22
+ onCheckFailure: options.onCheckFailure,
21
23
  api, homeDir, write, openBrowser: options.openBrowser ?? openBrowser,
22
24
  signInInput: options.signInInput ?? terminalSignInInput(), readEmail: options.readEmail ?? userEmail,
23
25
  sleep: options.sleep ?? (async (ms) => { await delay(ms, undefined, { signal: options.signal }); }), now: options.now ?? Date.now,
@@ -0,0 +1,8 @@
1
+ import { CliRefusalError, failureReason, START_FAILURE_MESSAGE } from "../contract/cli-refusals.js";
2
+ import { errorText } from "./api.js";
3
+ export function mcpFailure(error, enabled) {
4
+ // Only outside_project changed its sentence; the other local refusals already use the old sentences.
5
+ const message = !enabled && error instanceof CliRefusalError && error.code === "outside_project" ? START_FAILURE_MESSAGE
6
+ : errorText(error);
7
+ return { message, ...(enabled ? { reason: failureReason(error) } : {}) };
8
+ }
package/dist/cli/mcp.js CHANGED
@@ -1,15 +1,16 @@
1
1
  import { createInterface } from "node:readline";
2
2
  import { once } from "node:events";
3
3
  import { resolveFolderPath } from "../adapters/folder-path.js";
4
- import { FINISH_FAILURE_MESSAGE, START_FAILURE_MESSAGE } from "../contract/cli-refusals.js";
5
- import { errorCode, errorText } from "./api.js";
4
+ import { CliRefusalError, failureReason } from "../contract/cli-refusals.js";
5
+ import { mcpFailure } from "./mcp-failure.js";
6
+ import { errorCode } from "./api.js";
6
7
  import { commandContext } from "./context.js";
7
8
  import { analyze } from "./commands/analyze.js";
8
9
  import { CLI_VERSION } from "../contract/cli-version.js";
9
10
  import { finding } from "./commands/finding.js";
10
11
  import { balance } from "./commands/balance.js";
11
12
  import { ANALYZE_TOOL_NAME, analyzeToolAllowed } from "./claude-code-permission.js";
12
- import { signInStatus, startSignIn } from "./sign-in.js";
13
+ import { signInStatus, startSignIn, waitForSignIn, SignInExpiredError, SignInDeniedError } from "./sign-in.js";
13
14
  import { readEmailSignIn } from "../adapters/settings.js";
14
15
  import { emailSignInMessages } from "../contract/email-sign-in-messages.js";
15
16
  import { EMAIL_SIGN_IN_TERMS, normalizeSignInEmail, validSignInCode, startEmailSignIn, signInWithCode } from "./email-sign-in.js";
@@ -23,11 +24,11 @@ import { readFindingSettings } from "./finding-settings.js";
23
24
  const legacyTools = [
24
25
  {
25
26
  name: ANALYZE_TOOL_NAME,
26
- description: "Analyze the agent in a folder on this computer. Each call runs one analysis and charges the user's balance, so call it only when the user asks for an analysis. After edits, suggest an analysis and wait for the user to ask. Prices are listed on the top-up page and website. If the balance doesn't cover the analysis, show the returned message and top-up link.",
27
+ description: "Analyze the agent in a folder on this computer. Each call runs one analysis and charges the user's balance unless a free analysis is available, so call it only when the user asks for an analysis. A free first analysis needs no top-up. After edits, suggest an analysis and wait for the user to ask. Prices are listed on the top-up page and website. If the balance doesn't cover the analysis, show the returned message and top-up link.",
27
28
  inputSchema: { type: "object", properties: { path: { type: "string", description: "The agent folder on this computer." }, proposal: { ...agentProposalInputSchema, description: "Optional: the files you believe make up the agent, with each file's role. Paths are relative to the agent folder." } }, required: ["path"], additionalProperties: false },
28
29
  },
29
30
  { name: "get_finding", description: "Get a finding's explanation, quoted lines, and suggested fixes.", inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"], additionalProperties: false } },
30
- { name: "get_balance", description: "Get the balance and top-up link.", inputSchema: { type: "object", properties: {}, additionalProperties: false } },
31
+ { name: "get_balance", description: "Get the balance and top-up link, and whether a free first analysis is available. A free first analysis needs no top-up.", inputSchema: { type: "object", properties: {}, additionalProperties: false } },
31
32
  { name: "sign_in_status", description: "Check whether this computer is signed in to Counterfact, and whether the user has allowed the analysis tool in Claude Code. With wait: true, first wait for a sign-in started by sign_in to finish.", inputSchema: { type: "object", properties: { wait: { type: "boolean" } }, additionalProperties: false } },
32
33
  { name: "sign_in", description: "Open the Counterfact sign-in page in the user's browser. Then call sign_in_status with wait: true.", inputSchema: { type: "object", properties: {}, additionalProperties: false } },
33
34
  ];
@@ -43,6 +44,7 @@ export async function serveMcp(input, output, options = {}) {
43
44
  const audienceChoice = (options.env ?? process.env).CF_FINDING_AUDIENCE;
44
45
  const cwd = options.cwd ?? process.cwd();
45
46
  const guarded = (options.env ?? process.env).CF_MCP_PATH_GUARD !== "0";
47
+ const failureReasons = (options.env ?? process.env).CF_MCP_FAILURE_REASONS !== "0";
46
48
  const launch = guarded ? resolveFolderPath(cwd).catch(() => null) : Promise.resolve(cwd);
47
49
  const lastAnalysis = new Map();
48
50
  const findingSettings = readFindingSettings(options.env);
@@ -78,6 +80,7 @@ export async function serveMcp(input, output, options = {}) {
78
80
  ...tool, inputSchema: numberedFindingInputSchema,
79
81
  } : tool).concat(fixesEnabled ? fixRequestTools : []);
80
82
  let pendingSignIn;
83
+ let signInFailure;
81
84
  const requests = new Map();
82
85
  let queue = Promise.resolve();
83
86
  const lines = createInterface({ input, crlfDelay: Infinity });
@@ -111,6 +114,8 @@ export async function serveMcp(input, output, options = {}) {
111
114
  if (name === "sign_in_with_code" && !validSignInCode(args.code))
112
115
  throw new ProtocolError(-32602, emailSignInMessages.invalid_code);
113
116
  let text = "", exitCode = 0;
117
+ let reason;
118
+ let freeAnalysisAvailable = false;
114
119
  let agentChoice;
115
120
  let findingIds;
116
121
  let findings, projectId;
@@ -122,6 +127,7 @@ export async function serveMcp(input, output, options = {}) {
122
127
  let signIn;
123
128
  try {
124
129
  const ctx = commandContext({ ...options, env: { ...(options.env ?? process.env), CF_FINDING_AUDIENCE: audienceChoice },
130
+ onCheckFailure: failure => { reason = failure.reason; options.onCheckFailure?.(failure); },
125
131
  emailSignIn, findingSettings, signal, onCheckStart: () => {
126
132
  started = true;
127
133
  if (guarded && analysisFolder)
@@ -160,20 +166,32 @@ export async function serveMcp(input, output, options = {}) {
160
166
  } });
161
167
  }
162
168
  else if (name === "get_balance")
163
- await balance(ctx);
169
+ await balance(ctx, { onLoaded: value => { freeAnalysisAvailable = value.freeAnalysisAvailable; } });
164
170
  else if (name === "sign_in_status") {
165
171
  const pending = pendingSignIn;
166
172
  const allowed = await analyzeToolAllowed(ctx.homeDir, cwd);
167
173
  if (args.wait === true && pending) {
168
- const cancel = () => pending.controller.abort();
169
- signal.addEventListener("abort", cancel, { once: true });
170
- try {
171
- if (signal.aborted)
172
- cancel();
173
- await pending.flow.done;
174
+ if (pending.flow.retryEnabled) {
175
+ try {
176
+ await waitForSignIn(pending.flow, signal);
177
+ }
178
+ catch (error) {
179
+ if (signal.aborted)
180
+ throw error;
181
+ // The flow stores its failure; collect the usual status fields before reporting it.
182
+ }
174
183
  }
175
- finally {
176
- signal.removeEventListener("abort", cancel);
184
+ else {
185
+ const cancel = () => pending.controller.abort();
186
+ signal.addEventListener("abort", cancel, { once: true });
187
+ try {
188
+ if (signal.aborted)
189
+ cancel();
190
+ await pending.flow.done;
191
+ }
192
+ finally {
193
+ signal.removeEventListener("abort", cancel);
194
+ }
177
195
  }
178
196
  }
179
197
  signIn = { ...await signInStatus(ctx), analyzeToolAllowed: allowed };
@@ -185,17 +203,31 @@ export async function serveMcp(input, output, options = {}) {
185
203
  if (suggestion)
186
204
  signIn.suggestedEmail = suggestion;
187
205
  }
206
+ if (signInFailure)
207
+ signal.throwIfAborted();
208
+ const failure = signInFailure;
209
+ signInFailure = undefined;
210
+ if (failure && !signIn.signedIn) {
211
+ const shown = mcpFailure(failure.error, failureReasons);
212
+ text = (failure.error instanceof SignInExpiredError
213
+ ? emailSignInMessages.mcp_expired : failure.error instanceof SignInDeniedError
214
+ ? emailSignInMessages.mcp_cancelled : shown.message) + "\n";
215
+ exitCode = errorCode(failure.error);
216
+ reason = shown.reason;
217
+ }
188
218
  }
189
219
  else if (emailFlow) {
190
220
  if (pendingSignIn)
191
221
  throw new Error(emailSignInMessages.browser_sign_in_pending);
192
222
  if (name === "sign_in") {
193
223
  const start = await startEmailSignIn(ctx, email);
224
+ signInFailure = undefined;
194
225
  signIn = { email: email, expiresIn: start.expiresIn };
195
226
  text = `We sent a sign-in code to ${email}. Ask the user for the code from their email, then call sign_in_with_code with the same email and their code. Do not repeat the code.`;
196
227
  }
197
228
  else {
198
229
  await signInWithCode(ctx, email, args.code);
230
+ signInFailure = undefined;
199
231
  signIn = { signedIn: true };
200
232
  text = "Signed in.";
201
233
  }
@@ -206,7 +238,7 @@ export async function serveMcp(input, output, options = {}) {
206
238
  else {
207
239
  let pending = pendingSignIn;
208
240
  if (!pending) {
209
- // The flow outlives this call; a later wait call can cancel its polling.
241
+ // With retry on, the browser flow belongs to the session, so a cancelled status wait leaves it running.
210
242
  const controller = new AbortController();
211
243
  const cancel = () => controller.abort();
212
244
  signal.addEventListener("abort", cancel, { once: true });
@@ -215,11 +247,16 @@ export async function serveMcp(input, output, options = {}) {
215
247
  const flow = await startSignIn(commandContext({ ...options, emailSignIn, findingSettings, homeDir: ctx.homeDir, baseUrl: ctx.api.baseUrl, signal: controller.signal }));
216
248
  pending = { flow, controller, opened: false };
217
249
  pendingSignIn = pending;
250
+ signInFailure = undefined;
218
251
  const current = pending;
219
252
  const clear = () => { if (pendingSignIn === current)
220
253
  pendingSignIn = undefined; };
221
254
  // Handle background failures even when the client never calls wait.
222
- void flow.done.then(clear, clear);
255
+ void flow.done.then(clear, error => {
256
+ if (flow.retryEnabled && pendingSignIn === current && !current.controller.signal.aborted)
257
+ signInFailure = { error };
258
+ clear();
259
+ });
223
260
  pending.opened = await ctx.openBrowser(flow.pageUrl);
224
261
  }
225
262
  finally {
@@ -234,11 +271,11 @@ export async function serveMcp(input, output, options = {}) {
234
271
  else {
235
272
  const launchFolder = await launch;
236
273
  if (guarded && !launchFolder)
237
- throw new Error(START_FAILURE_MESSAGE);
274
+ throw new CliRefusalError("launch_folder_unavailable");
238
275
  const folder = guarded ? await resolveMcpAgentFolder(args.path, launchFolder, ctx.homeDir) : resolveAgentFolder(args.path, cwd);
239
276
  const last = lastAnalysis.get(folder);
240
277
  if (guarded && last !== undefined && ctx.now() - last < 10 * 60 * 1000) {
241
- throw new Error(FINISH_FAILURE_MESSAGE);
278
+ throw new CliRefusalError("repeat_too_soon");
242
279
  }
243
280
  analysisFolder = folder;
244
281
  try {
@@ -251,13 +288,17 @@ export async function serveMcp(input, output, options = {}) {
251
288
  }
252
289
  }
253
290
  catch (error) {
254
- text += errorText(error) + "\n";
291
+ const failure = mcpFailure(error, failureReasons);
292
+ text += failure.message + "\n";
293
+ reason = failure.reason;
255
294
  exitCode = errorCode(error);
256
295
  }
257
296
  // Some clients (Claude Code among them) give the model only the structured result when one is present, so it
258
297
  // repeats the text: without it the model cannot read the result.
259
298
  return { content: [{ type: "text", text }], isError: exitCode !== 0,
260
299
  structuredContent: { text, exitCode, ...agentChoice, ...(findingIds ? { findingIds } : {}), ...(url ? { url } : {}), ...signIn,
300
+ ...(exitCode !== 0 && failureReasons ? { reason: reason ?? failureReason(undefined) } : {}),
301
+ ...(freeAnalysisAvailable ? { freeAnalysisAvailable } : {}),
261
302
  ...(findings ? { findings, projectId } : {}), ...(loadedFinding ? { finding: loadedFinding } : {}),
262
303
  ...(fixRequests ? { fixRequests } : {}), ...claimed, ...(waitingCount !== undefined ? { waitingFixes: waitingCount } : {}) } };
263
304
  }
@@ -10,7 +10,7 @@ import { resolveFolderPath } from "../adapters/folder-path.js";
10
10
  import { AgentChoiceError } from "../contract/agent-choice.js";
11
11
  import { savedAnalysisUrl } from "../contract/console-url.js";
12
12
  import { prepareRelayCheck } from "./relay-preparation.js";
13
- import { cliRefusals, COMMITTED_FILES_MESSAGE, FINISH_FAILURE_MESSAGE, RESULTS_TOO_LARGE_MESSAGE, refusalExitCode } from "../contract/cli-refusals.js";
13
+ import { cliRefusals, COMMITTED_FILES_MESSAGE, failureReason, FINISH_FAILURE_MESSAGE, RESULTS_TOO_LARGE_MESSAGE, refusalExitCode } from "../contract/cli-refusals.js";
14
14
  import { ConsoleClient, ConsoleError } from "../gatekeeper/console.js";
15
15
  import { GatekeeperClient } from "../gatekeeper/client.js";
16
16
  import { connectRelay, relayOrigin } from "../gatekeeper/connection.js";
@@ -252,7 +252,7 @@ async function runRelayLifecycle(folder, options, existing) {
252
252
  || error.message.startsWith("The saved completion") || error.message === COMPLETION_SETTINGS_MISMATCH_MESSAGE);
253
253
  const message = known ? error.message : FINISH_FAILURE_MESSAGE;
254
254
  try {
255
- options.onError?.({ code: error instanceof ConsoleError ? error.code : undefined, message,
255
+ options.onError?.({ code: error instanceof ConsoleError ? error.code : undefined, reason: failureReason(error), message,
256
256
  ...(error instanceof AgentChoiceError ? { agentChoice: error.agentChoice } : {}) });
257
257
  stderr(`${message}\n`);
258
258
  if (status && !(error instanceof ConsoleError && error.code === "low_balance"))
@@ -1,6 +1,13 @@
1
1
  import { ApiError } from "./api.js";
2
2
  import { readCredentials, saveCredentials } from "./credentials.js";
3
3
  import { emailSignInMessages } from "../contract/email-sign-in-messages.js";
4
+ import { cliApiMessages } from "../contract/cli-api-messages.js";
5
+ export class SignInExpiredError extends ApiError {
6
+ constructor() { super(400, "expired", emailSignInMessages.browser_expired); }
7
+ }
8
+ export class SignInDeniedError extends Error {
9
+ constructor() { super(emailSignInMessages.cancelled); }
10
+ }
4
11
  async function pollSignIn(ctx, device) {
5
12
  const until = ctx.now() + device.expiresIn * 1000;
6
13
  while (ctx.now() < until) {
@@ -15,12 +22,64 @@ async function pollSignIn(ctx, device) {
15
22
  if (poll.status === "denied")
16
23
  throw new Error(emailSignInMessages.cancelled);
17
24
  }
18
- throw new Error("The sign-in code expired. Run counterfact-labs login to try again.");
25
+ throw new SignInExpiredError();
26
+ }
27
+ async function pollSignInWithRetry(ctx, device) {
28
+ const until = ctx.now() + device.expiresIn * 1000;
29
+ const interval = device.interval * 1000;
30
+ let delay = interval;
31
+ while (ctx.now() < until) {
32
+ await ctx.sleep(Math.min(delay, until - ctx.now()));
33
+ if (ctx.now() >= until)
34
+ break;
35
+ let poll;
36
+ try {
37
+ poll = await ctx.api.request("device/token", { deviceCode: device.deviceCode }, false, { timeoutMs: Math.min(120_000, until - ctx.now()) });
38
+ }
39
+ catch (error) {
40
+ ctx.signal?.throwIfAborted();
41
+ if (!(error instanceof ApiError) || !(error.status === 0 || error.status === 408 || error.status === 429
42
+ || (error.status >= 500 && error.status < 600)))
43
+ throw error;
44
+ // A temporary failure cannot tell us whether the user approved the code.
45
+ delay = Math.max(interval, error.retryAfterMs);
46
+ continue;
47
+ }
48
+ delay = interval;
49
+ if (!poll)
50
+ throw new Error(cliApiMessages.invalid_response);
51
+ if (poll.status === "ok") {
52
+ // The server has consumed the code, so keep its token even after local expiry.
53
+ await saveCredentials(ctx.homeDir, ctx.api.baseUrl, poll.token);
54
+ return "signed_in";
55
+ }
56
+ if (poll.status === "expired")
57
+ break;
58
+ if (poll.status === "denied")
59
+ throw new SignInDeniedError();
60
+ }
61
+ throw new SignInExpiredError();
19
62
  }
20
63
  export async function startSignIn(ctx) {
21
64
  const device = await ctx.api.request("device", {}, false);
22
65
  return { userCode: device.userCode, verificationUri: device.verificationUri,
23
- pageUrl: `${device.verificationUri}?code=${encodeURIComponent(device.userCode)}`, done: pollSignIn(ctx, device) };
66
+ pageUrl: `${device.verificationUri}?code=${encodeURIComponent(device.userCode)}`, retryEnabled: ctx.signInRetry,
67
+ done: ctx.signInRetry ? pollSignInWithRetry(ctx, device) : pollSignIn(ctx, device) };
68
+ }
69
+ export async function waitForSignIn(flow, signal) {
70
+ signal.throwIfAborted();
71
+ let cancel;
72
+ const cancelled = new Promise((_resolve, reject) => {
73
+ cancel = () => reject(signal.reason);
74
+ signal.addEventListener("abort", cancel, { once: true });
75
+ });
76
+ // A cancelled tool wait must leave the shared browser sign-in running.
77
+ try {
78
+ return await Promise.race([flow.done, cancelled]);
79
+ }
80
+ finally {
81
+ signal.removeEventListener("abort", cancel);
82
+ }
24
83
  }
25
84
  export async function signInStatus(ctx) {
26
85
  if (!await readCredentials(ctx.homeDir, ctx.api.baseUrl))
@@ -2,4 +2,6 @@ export const cliApiMessages = Object.freeze({
2
2
  unreachable: "Can't reach Counterfact. Try again shortly.",
3
3
  invalid_response: "Counterfact returned an invalid response. Try again shortly.",
4
4
  request_failed: "The request could not be completed. Try again shortly.",
5
+ unauthorized: "Not signed in. Run counterfact-labs login.",
6
+ invalid_console_url: "COUNTERFACT_URL must be an HTTP or HTTPS address without credentials or query fields.",
5
7
  });
@@ -1,4 +1,6 @@
1
1
  import { FOLDER_TOO_LARGE_MESSAGE } from "../analysis-core/errors.js";
2
+ import { cliApiMessages } from "./cli-api-messages.js";
3
+ import { emailSignInMessages } from "./email-sign-in-messages.js";
2
4
  export const START_FAILURE_MESSAGE = "This couldn't start. Try again.";
3
5
  export const RESERVATION_EXPIRED_MESSAGE = "This reservation expired. Start a new analysis.";
4
6
  export const FINISH_FAILURE_MESSAGE = "The analysis couldn't finish. Try again.";
@@ -7,7 +9,13 @@ export const COMMITTED_FILES_MESSAGE = "Counterfact analyzes the committed versi
7
9
  const unsupportedVersion = "This version of Counterfact can't run analyses. Update it: npm install -g @counterfact-labs/cli@latest.";
8
10
  // The console separates over-limit costs from version or route mismatches, so each code gets its own recovery guidance.
9
11
  const analysisTooLarge = "This analysis is too large to run at once. Choose a smaller folder, such as one agent's folder.";
10
- /** Shared public refusals keep mode admission and reservation errors consistent. */
12
+ export const localRefusals = {
13
+ outside_project: "Choose a folder inside this project, or start Claude Code or Codex in the folder you want to analyze.",
14
+ launch_folder_unavailable: START_FAILURE_MESSAGE,
15
+ folder_unavailable: START_FAILURE_MESSAGE,
16
+ repeat_too_soon: FINISH_FAILURE_MESSAGE,
17
+ };
18
+ /** Both console clients use the same public messages for server refusals. */
11
19
  export const cliRefusals = {
12
20
  folder_too_large: FOLDER_TOO_LARGE_MESSAGE,
13
21
  low_balance: "Your balance doesn't cover this analysis. Add credit at https://app.counterfactlabs.com/account?status=low_balance#balance",
@@ -25,3 +33,60 @@ export const cliRefusals = {
25
33
  stale_quote: START_FAILURE_MESSAGE,
26
34
  };
27
35
  export function refusalExitCode(code) { return code === "low_balance" ? 2 : 1; }
36
+ export class CliRefusalError extends Error {
37
+ code;
38
+ constructor(code) {
39
+ super(Object.hasOwn(localRefusals, code) ? localRefusals[code] : cliRefusals[code]);
40
+ this.code = code;
41
+ }
42
+ }
43
+ const otherReasons = {
44
+ unavailable: "console_unavailable",
45
+ invalid_configuration: "invalid_configuration",
46
+ invalid_request: "invalid_request",
47
+ unauthorized: "unauthorized",
48
+ not_allowed: "not_allowed",
49
+ not_found: "not_found",
50
+ rate_limited: "rate_limited",
51
+ reservation_closed: "reservation_closed",
52
+ reservation_closing: "reservation_closing",
53
+ delivery_incomplete: "delivery_incomplete",
54
+ check_conflict: "check_conflict",
55
+ invalid_email: "invalid_email",
56
+ wrong_code: "wrong_code",
57
+ expired: "expired",
58
+ too_many_attempts: "too_many_attempts",
59
+ email_unavailable: "email_unavailable",
60
+ request_failed: "request_failed",
61
+ };
62
+ const messageReasons = {
63
+ [cliRefusals.release_unavailable]: "release_unavailable",
64
+ [cliApiMessages.invalid_response]: "invalid_response",
65
+ [emailSignInMessages.browser_sign_in_pending]: "browser_sign_in_pending",
66
+ [emailSignInMessages.cancelled]: "cancelled",
67
+ };
68
+ const reasonValues = [
69
+ ...Object.keys(cliRefusals),
70
+ ...Object.keys(localRefusals),
71
+ ...Object.values(otherReasons), ...Object.values(messageReasons),
72
+ "console_unreachable", "invalid_console_url", "relay_unreachable", "results_not_saved",
73
+ ];
74
+ const ownedReasons = new Set(reasonValues);
75
+ /** Only fixed reasons may cross the MCP boundary; diagnostics can contain customer data. */
76
+ export function failureReason(error) {
77
+ if (!(error instanceof Error))
78
+ return "request_failed";
79
+ if (error instanceof CliRefusalError)
80
+ return error.code;
81
+ if ("reason" in error && typeof error.reason === "string" && ownedReasons.has(error.reason))
82
+ return error.reason;
83
+ const code = "code" in error ? error.code : undefined;
84
+ if (typeof code === "string") {
85
+ if (Object.hasOwn(cliRefusals, code))
86
+ return code;
87
+ if (Object.hasOwn(otherReasons, code))
88
+ return otherReasons[code];
89
+ return "request_failed";
90
+ }
91
+ return Object.hasOwn(messageReasons, error.message) ? messageReasons[error.message] : "request_failed";
92
+ }
@@ -1 +1 @@
1
- export const CLI_VERSION = "0.2.5";
1
+ export const CLI_VERSION = "0.2.6";
@@ -1,5 +1,6 @@
1
1
  import { cliApiMessages } from "./cli-api-messages.js";
2
2
  const rateLimited = "Too many sign-in requests. Try again later.";
3
+ // Holds the sentences both sign-in flows share, so browser expiry lives here too.
3
4
  export const emailSignInMessages = Object.freeze({
4
5
  invalid_email: "Enter a valid email address.",
5
6
  invalid_code: "Enter the code from your email.",
@@ -11,8 +12,11 @@ export const emailSignInMessages = Object.freeze({
11
12
  : `Too many sign-in requests. Try again in ${seconds} ${seconds === 1 ? "second" : "seconds"}.`,
12
13
  email_unavailable: "Email sign-in isn't available. Use browser sign-in instead.",
13
14
  browser_sign_in_pending: "A browser sign-in is still in progress. Finish it before starting email sign-in.",
15
+ browser_expired: "The sign-in code expired. Run counterfact-labs login to try again.",
14
16
  unreachable: cliApiMessages.unreachable,
15
17
  invalid_response: cliApiMessages.invalid_response,
16
18
  request_failed: cliApiMessages.request_failed,
17
19
  cancelled: "Sign-in was cancelled. Run counterfact-labs login to try again.",
20
+ mcp_expired: "The sign-in code expired. Call sign_in to try again.",
21
+ mcp_cancelled: "Sign-in was cancelled. Call sign_in to try again.",
18
22
  });
@@ -11,11 +11,13 @@ export class ApiError extends Error {
11
11
  status;
12
12
  code;
13
13
  retryAfterMs;
14
- constructor(status, code, message, retryAfterMs = 0) {
14
+ reason;
15
+ constructor(status, code, message, retryAfterMs = 0, reason) {
15
16
  super(message);
16
17
  this.status = status;
17
18
  this.code = code;
18
19
  this.retryAfterMs = retryAfterMs;
20
+ this.reason = reason;
19
21
  }
20
22
  }
21
23
  const emailRoutes = {
@@ -100,9 +102,16 @@ export class ApiClient {
100
102
  this.token = token;
101
103
  this.fetcher = fetcher;
102
104
  this.signal = signal;
103
- const url = new URL(baseUrl);
104
- if (!consoleSchemeAllowed(url) || url.username || url.password || url.search || url.hash)
105
- throw new Error("COUNTERFACT_URL must be an HTTP or HTTPS address without credentials or query fields.");
105
+ let url;
106
+ try {
107
+ url = new URL(baseUrl);
108
+ }
109
+ catch (error) {
110
+ throw new ApiError(0, "invalid_console_url", errorText(error), 0, "invalid_console_url");
111
+ }
112
+ if (!consoleSchemeAllowed(url) || url.username || url.password || url.search || url.hash) {
113
+ throw new ApiError(0, "invalid_console_url", cliApiMessages.invalid_console_url, 0, "invalid_console_url");
114
+ }
106
115
  this.baseUrl = url.href.replace(/\/+$/, "");
107
116
  }
108
117
  async request(path, body, authenticated = true, options = {}) {
@@ -118,7 +127,7 @@ export class ApiClient {
118
127
  if (authenticated) {
119
128
  const token = await this.token();
120
129
  if (!token)
121
- throw new Error("Not signed in. Run counterfact-labs login.");
130
+ throw new ApiError(401, "unauthorized", cliApiMessages.unauthorized);
122
131
  headers.authorization = `Bearer ${token}`;
123
132
  }
124
133
  if (body !== undefined)
@@ -144,7 +153,7 @@ export class ApiClient {
144
153
  }
145
154
  catch {
146
155
  throwIfAborted();
147
- throw new ApiError(0, "unreachable", cliApiMessages.unreachable);
156
+ throw new ApiError(0, "unreachable", cliApiMessages.unreachable, 0, "console_unreachable");
148
157
  }
149
158
  throwIfAborted();
150
159
  if (emailRoute && response.ok && response.status !== emailRoute.successStatus) {
@@ -161,7 +170,7 @@ export class ApiClient {
161
170
  if (response.ok) {
162
171
  if (error instanceof SyntaxError)
163
172
  throw new Error(cliApiMessages.invalid_response);
164
- throw new ApiError(0, "unreachable", cliApiMessages.unreachable);
173
+ throw new ApiError(0, "unreachable", cliApiMessages.unreachable, 0, "console_unreachable");
165
174
  }
166
175
  }
167
176
  throwIfAborted();
@@ -2,6 +2,7 @@ import { RELAY_MAX_FRAME_BYTES, deliveryReceipt, relayAnswer, relayAnswerFrame,
2
2
  import { unredactedAnswer } from "../contract/public-report.js";
3
3
  export class RelayConnectionError extends Error {
4
4
  name = "RelayConnectionError";
5
+ reason = "relay_unreachable";
5
6
  constructor() { super("Relay connection failed"); }
6
7
  }
7
8
  /** Both carriers retry the same confirmation, never another paid request. */
@@ -6,7 +6,7 @@ import { cliRefusals, FINISH_FAILURE_MESSAGE, RESERVATION_EXPIRED_MESSAGE, RESUL
6
6
  import { cliRelayReservationRequestSchema, consoleCreatedSchema, consoleIntakeResultSchema, consoleReservationSchema, consoleStatusSchema, LOCAL_FINDINGS_BODY_LIMIT, localFindingsPayloadSchema, LOCAL_START_BODY_LIMIT, LOCAL_START_EXECUTION_HEADER, localStartRequestSchema, localStartResponseSchema, serializeLocalStartMetadata } from "../contract/console.js";
7
7
  const RESPONSE_LIMIT = 64 * 1024;
8
8
  const RESERVATION_BODY_LIMIT = 64 * 1024;
9
- const messages = {
9
+ export const consoleErrorMessages = {
10
10
  ...cliRefusals,
11
11
  invalid_configuration: START_FAILURE_MESSAGE,
12
12
  invalid_request: FINISH_FAILURE_MESSAGE,
@@ -22,13 +22,24 @@ const messages = {
22
22
  };
23
23
  export class ConsoleError extends Error {
24
24
  code;
25
- constructor(code = "unavailable", context) {
26
- super(context === "start" && (code === "unavailable" || code === "invalid_request" || code === "not_found") ? START_FAILURE_MESSAGE
27
- : context === "intake" && code === "reservation_expired" ? RESERVATION_EXPIRED_MESSAGE
28
- // Only saving results can be too large; a rejected status, heartbeat or fail request just could not finish.
29
- : context === "intake" && code === "invalid_request" ? RESULTS_TOO_LARGE_MESSAGE : messages[code]);
25
+ reason;
26
+ constructor(code = "unavailable", context, localReason) {
27
+ let message = consoleErrorMessages[code];
28
+ let reason = code === "unavailable" ? "results_not_saved" : code;
29
+ if (context === "start" && (code === "unavailable" || code === "invalid_request" || code === "not_found")) {
30
+ message = START_FAILURE_MESSAGE;
31
+ if (code === "unavailable")
32
+ reason = "console_unavailable";
33
+ }
34
+ else if (context === "intake" && code === "reservation_expired")
35
+ message = RESERVATION_EXPIRED_MESSAGE;
36
+ // Only saving results can be too large; a rejected status, heartbeat or fail request just could not finish.
37
+ else if (context === "intake" && code === "invalid_request")
38
+ message = RESULTS_TOO_LARGE_MESSAGE;
39
+ super(message);
30
40
  this.name = "ConsoleError";
31
41
  this.code = code;
42
+ this.reason = localReason ?? reason;
32
43
  }
33
44
  }
34
45
  export const isConsoleToken = (token) => /^[\x21-\x7e]{1,4096}$/.test(token);
@@ -38,21 +49,24 @@ export class ConsoleClient {
38
49
  #token;
39
50
  #timeoutMs;
40
51
  constructor(options) {
52
+ let url;
41
53
  try {
42
- const url = new URL(options.url);
43
- const timeout = options.timeoutMs ?? 15_000;
54
+ url = new URL(options.url);
44
55
  if (!consoleSchemeAllowed(url)
45
- || url.username || url.password || url.pathname !== "/" || url.search || url.hash
46
- || !isConsoleToken(options.token)
47
- || !Number.isSafeInteger(timeout) || timeout <= 0 || timeout > 120_000)
48
- throw new ConsoleError("invalid_configuration");
49
- this.#url = url;
50
- this.#token = options.token;
51
- this.#timeoutMs = timeout;
56
+ || url.username || url.password || url.pathname !== "/" || url.search || url.hash) {
57
+ throw new ConsoleError("invalid_configuration", undefined, "invalid_console_url");
58
+ }
52
59
  }
53
60
  catch {
54
- throw new ConsoleError("invalid_configuration");
61
+ throw new ConsoleError("invalid_configuration", undefined, "invalid_console_url");
62
+ }
63
+ const timeout = options.timeoutMs ?? 15_000;
64
+ if (!isConsoleToken(options.token) || !Number.isSafeInteger(timeout) || timeout <= 0 || timeout > 120_000) {
65
+ throw new ConsoleError("invalid_configuration", undefined, "request_failed");
55
66
  }
67
+ this.#url = url;
68
+ this.#token = options.token;
69
+ this.#timeoutMs = timeout;
56
70
  }
57
71
  async start(input, metadata) {
58
72
  let headers;
@@ -152,7 +166,7 @@ export class ConsoleClient {
152
166
  const decoded = JSON.parse(Buffer.concat(chunks).toString("utf8"));
153
167
  if (!(options.statuses ?? [200]).includes(status)) {
154
168
  const code = decoded && typeof decoded === "object" && "code" in decoded ? decoded.code : undefined;
155
- throw new ConsoleError(typeof code === "string" && Object.hasOwn(messages, code) ? code : "unavailable");
169
+ throw new ConsoleError(typeof code === "string" && Object.hasOwn(consoleErrorMessages, code) ? code : "unavailable");
156
170
  }
157
171
  const result = responseSchema.parse(decoded);
158
172
  for (const [field, expected] of Object.entries(identities)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@counterfact-labs/cli",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "homepage": "https://counterfactlabs.com",
5
5
  "relaySource": "https://github.com/counterfact-labs/sealed-relay",
6
6
  "type": "module",