@counterfact-labs/cli 0.1.3 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. package/README.md +176 -42
  2. package/bundle/client-release.json +7 -0
  3. package/bundle/sealed.json +908 -0
  4. package/dist/adapters/anthropic.js +151 -0
  5. package/dist/adapters/completion.js +112 -0
  6. package/dist/adapters/durable-write.js +34 -0
  7. package/dist/adapters/files.js +140 -0
  8. package/dist/adapters/folder-path.js +6 -0
  9. package/dist/adapters/git-ignore.js +49 -0
  10. package/dist/adapters/git.js +44 -0
  11. package/dist/adapters/ignored-files.js +36 -0
  12. package/dist/adapters/instructions.js +35 -0
  13. package/dist/adapters/models.js +51 -0
  14. package/dist/adapters/output-paths.js +92 -0
  15. package/dist/adapters/prompt-cache.js +28 -0
  16. package/dist/adapters/public-instructions.js +36 -0
  17. package/dist/adapters/quality.js +6 -0
  18. package/dist/adapters/relay-plan.js +15 -0
  19. package/dist/adapters/release.js +57 -0
  20. package/dist/adapters/report.js +29 -0
  21. package/dist/adapters/repository-files.js +199 -0
  22. package/dist/adapters/repository-root.js +120 -0
  23. package/dist/adapters/results.js +9 -0
  24. package/dist/adapters/settings.js +25 -0
  25. package/dist/adapters/snapshot.js +68 -0
  26. package/dist/analysis-core/analysis/analysis-flags.js +1 -0
  27. package/dist/analysis-core/analysis/code-agents/skipped-files.js +1 -0
  28. package/dist/analysis-core/analysis/finding-quality/config.js +1 -0
  29. package/dist/analysis-core/analysis/finding-quality/policy.js +1 -0
  30. package/dist/analysis-core/analysis/headlines/attachment.js +1 -0
  31. package/dist/analysis-core/analysis/headlines/input.js +1 -0
  32. package/dist/analysis-core/analysis/headlines/types.js +1 -0
  33. package/dist/analysis-core/analysis/schemas.js +1 -0
  34. package/dist/analysis-core/analysis/source-notes/types.js +1 -0
  35. package/dist/analysis-core/chunks/2PSNOSJ2.js +1 -0
  36. package/dist/analysis-core/chunks/3227KXHF.js +1 -0
  37. package/dist/analysis-core/chunks/326F4XNG.js +1 -0
  38. package/dist/analysis-core/chunks/3MTXB4MK.js +6 -0
  39. package/dist/analysis-core/chunks/4F6ZIHXU.js +1 -0
  40. package/dist/analysis-core/chunks/57M4OQJM.js +1 -0
  41. package/dist/analysis-core/chunks/6YCABS5T.js +1 -0
  42. package/dist/analysis-core/chunks/724WZMYW.js +2 -0
  43. package/dist/analysis-core/chunks/74FPPPIG.js +1 -0
  44. package/dist/analysis-core/chunks/7QDOHWSY.js +27 -0
  45. package/dist/analysis-core/chunks/BZNHHLNP.js +1 -0
  46. package/dist/analysis-core/chunks/DTMHXKP6.js +7 -0
  47. package/dist/analysis-core/chunks/E7TFIHC5.js +1 -0
  48. package/dist/analysis-core/chunks/EH3QQ3T4.js +1 -0
  49. package/dist/analysis-core/chunks/EQTIRIMU.js +37 -0
  50. package/dist/analysis-core/chunks/FSLWD2PE.js +1 -0
  51. package/dist/analysis-core/chunks/G322LST6.js +1 -0
  52. package/dist/analysis-core/chunks/HTLIRVP7.js +1 -0
  53. package/dist/analysis-core/chunks/IBCFGUHI.js +1 -0
  54. package/dist/analysis-core/chunks/IL2KRP3T.js +1 -0
  55. package/dist/analysis-core/chunks/J4AXGE5W.js +1 -0
  56. package/dist/analysis-core/chunks/JF4GYMOZ.js +1 -0
  57. package/dist/analysis-core/chunks/KEKTZDIK.js +1 -0
  58. package/dist/analysis-core/chunks/KU5DINLW.js +1 -0
  59. package/dist/analysis-core/chunks/M7MMAUAK.js +1 -0
  60. package/dist/analysis-core/chunks/OFBJNHC6.js +1 -0
  61. package/dist/analysis-core/chunks/ORCYIIHB.js +60 -0
  62. package/dist/analysis-core/chunks/PVPEY7NF.js +1 -0
  63. package/dist/analysis-core/chunks/PYKAERZZ.js +1 -0
  64. package/dist/analysis-core/chunks/QDYMR6KS.js +1 -0
  65. package/dist/analysis-core/chunks/QPXNXMLC.js +1 -0
  66. package/dist/analysis-core/chunks/SL27GMEN.js +1 -0
  67. package/dist/analysis-core/chunks/STZOC7LN.js +1 -0
  68. package/dist/analysis-core/chunks/T5TREZCQ.js +1 -0
  69. package/dist/analysis-core/chunks/TDNTU4TF.js +1 -0
  70. package/dist/analysis-core/chunks/U5HOEVPE.js +1 -0
  71. package/dist/analysis-core/chunks/UEWL3GMQ.js +1 -0
  72. package/dist/analysis-core/chunks/VER355VN.js +1 -0
  73. package/dist/analysis-core/chunks/VFENG4MR.js +1 -0
  74. package/dist/analysis-core/chunks/VGYWI4X2.js +1 -0
  75. package/dist/analysis-core/chunks/Y5AUKKYG.js +1 -0
  76. package/dist/analysis-core/chunks/Y7JGSUOA.js +1 -0
  77. package/dist/analysis-core/chunks/YVDCJN7D.js +14 -0
  78. package/dist/analysis-core/chunks/ZAA44GPI.js +1 -0
  79. package/dist/analysis-core/chunks/ZMUBMYWN.js +1 -0
  80. package/dist/analysis-core/contracts/requests.js +1 -0
  81. package/dist/analysis-core/conventions/file-admission.js +1 -0
  82. package/dist/analysis-core/data/finding-types.js +1 -0
  83. package/dist/analysis-core/errors.js +1 -0
  84. package/dist/analysis-core/findings/presentation.js +1 -0
  85. package/dist/analysis-core/github/repo-key.js +1 -0
  86. package/dist/analysis-core/import/outside-loader-sources.js +1 -0
  87. package/dist/analysis-core/index.js +6 -0
  88. package/dist/analysis-core/llm/cache-policy.js +1 -0
  89. package/dist/analysis-core/llm/cost.js +1 -0
  90. package/dist/analysis-core/llm/execution.js +1 -0
  91. package/dist/analysis-core/llm/output-limit.js +1 -0
  92. package/dist/analysis-core/llm/slots.js +1 -0
  93. package/dist/analysis-core/llm/stable-json.js +1 -0
  94. package/dist/analysis-core/llm/structured-output-error.js +1 -0
  95. package/dist/analysis-core/map-limit.js +1 -0
  96. package/dist/analysis-core/requests/answer-formats.js +1 -0
  97. package/dist/analysis-core/requests/check-diagnostics.js +1 -0
  98. package/dist/analysis-core/requests/headline-request.js +1 -0
  99. package/dist/analysis-core/requests/instruction-text.js +1 -0
  100. package/dist/analysis-core/requests/local-plan.js +1 -0
  101. package/dist/analysis-core/requests/registry.js +1 -0
  102. package/dist/analysis-core/requests/schema-descriptions.js +1 -0
  103. package/dist/analysis-core/requests/shape-finding-input.js +1 -0
  104. package/dist/analysis-core/requests/template-version.js +1 -0
  105. package/dist/analysis-core/requests/tools.js +1 -0
  106. package/dist/analysis-core/settings.js +1 -0
  107. package/dist/bundle/seal/deserialise.js +75 -0
  108. package/dist/bundle/seal/encryption.js +25 -0
  109. package/dist/bundle/seal/metadata.js +69 -0
  110. package/dist/bundle/seal/open.js +38 -0
  111. package/dist/bundle/seal/planned-calls.js +20 -0
  112. package/dist/bundle/seal/signature.js +51 -0
  113. package/dist/cli/agent-folder.js +17 -0
  114. package/dist/cli/api-types.js +2 -0
  115. package/dist/cli/api.js +1 -0
  116. package/dist/{claude-code-permission.js → cli/claude-code-permission.js} +4 -3
  117. package/dist/cli/commands/analyze.js +73 -0
  118. package/dist/cli/commands/login.js +51 -0
  119. package/dist/cli/completion-recovery.js +18 -0
  120. package/dist/{context.js → cli/context.js} +8 -14
  121. package/dist/cli/email-sign-in.js +56 -0
  122. package/dist/{git.js → cli/git.js} +12 -8
  123. package/dist/cli/intake-payload.js +18 -0
  124. package/dist/{main.js → cli/main.js} +23 -12
  125. package/dist/{mcp.js → cli/mcp.js} +109 -40
  126. package/dist/cli/output.js +10 -0
  127. package/dist/cli/relay-check.js +239 -0
  128. package/dist/cli/relay-preparation.js +31 -0
  129. package/dist/cli/sign-in-input.js +97 -0
  130. package/dist/{sign-in.js → cli/sign-in.js} +2 -1
  131. package/dist/cli/suggested-email.js +10 -0
  132. package/dist/cli/summary.js +30 -0
  133. package/dist/contract/cli-api-messages.js +5 -0
  134. package/dist/contract/cli-refusals.js +22 -0
  135. package/dist/contract/cli-version.js +1 -0
  136. package/dist/contract/console-url.js +8 -0
  137. package/dist/contract/console.js +111 -0
  138. package/dist/contract/email-sign-in-messages.js +18 -0
  139. package/dist/contract/messages.js +252 -0
  140. package/dist/contract/release-key.js +2 -0
  141. package/dist/contract/runtime-environment.js +37 -0
  142. package/dist/core/analysis/finding-quality/config.js +4 -0
  143. package/dist/core/analysis/run-to-end.js +55 -0
  144. package/dist/core/analysis/structural-run.js +16 -0
  145. package/dist/gatekeeper/attestation.js +67 -0
  146. package/dist/gatekeeper/certificate-algorithms.js +18 -0
  147. package/dist/gatekeeper/certificate-path.js +99 -0
  148. package/dist/gatekeeper/cli-api.js +174 -0
  149. package/dist/gatekeeper/client.js +76 -0
  150. package/dist/gatekeeper/connection-exchange.js +60 -0
  151. package/dist/gatekeeper/connection.js +140 -0
  152. package/dist/gatekeeper/console.js +168 -0
  153. package/dist/gatekeeper/loopback.js +5 -0
  154. package/dist/gatekeeper/model.js +70 -0
  155. package/dist/gatekeeper/production-attestation.js +149 -0
  156. package/dist/gatekeeper/trust/hardware.js +21 -0
  157. package/dist/gatekeeper/trust/policy.js +42 -0
  158. package/dist/gatekeeper/trust/production.json +25 -0
  159. package/dist/setup-pings/client.js +31 -0
  160. package/dist/setup-pings/contract.js +2 -0
  161. package/dist/setup-pings/policy.js +3 -0
  162. package/dist/setup-pings/state.js +261 -0
  163. package/dist/setup-pings/worker.js +95 -0
  164. package/dist/transport/base64url.js +9 -0
  165. package/dist/transport/http-response.js +89 -0
  166. package/dist/transport/tls-exporter.js +5 -0
  167. package/package.json +57 -11
  168. package/LICENSE +0 -1
  169. package/dist/api-types.js +0 -2
  170. package/dist/api.js +0 -97
  171. package/dist/commands/check.js +0 -104
  172. package/dist/commands/login.js +0 -9
  173. package/dist/files.js +0 -99
  174. package/dist/gitignore.js +0 -70
  175. package/dist/output.js +0 -23
  176. package/dist/secret-files.js +0 -5
  177. /package/dist/{browser.js → cli/browser.js} +0 -0
  178. /package/dist/{commands → cli/commands}/balance.js +0 -0
  179. /package/dist/{commands → cli/commands}/finding.js +0 -0
  180. /package/dist/{commands → cli/commands}/logout.js +0 -0
  181. /package/dist/{credentials.js → cli/credentials.js} +0 -0
@@ -0,0 +1,73 @@
1
+ import { loadInstalledRelease } from "../../adapters/release.js";
2
+ import { cliRefusals, START_FAILURE_MESSAGE } from "../../contract/cli-refusals.js";
3
+ import { CLI_VERSION } from "../../contract/cli-version.js";
4
+ import { cliApiMessages } from "../../contract/cli-api-messages.js";
5
+ import { ApiError } from "../api.js";
6
+ import { resolveAgentFolder } from "../agent-folder.js";
7
+ import { runRelayCheck } from "../relay-check.js";
8
+ // Only a real pause says "paused"; an unreviewed release or an upload-only server cannot start.
9
+ const unavailable = START_FAILURE_MESSAGE;
10
+ function requireLocal(value) {
11
+ if (!value || typeof value !== "object" || !("mode" in value) || !("minimumLocalCliVersion" in value)
12
+ || typeof value.minimumLocalCliVersion !== "string" || !/^\d+\.\d+\.\d+$/.test(value.minimumLocalCliVersion)) {
13
+ throw new Error(cliApiMessages.invalid_response);
14
+ }
15
+ const current = CLI_VERSION.split(".").map(BigInt), minimum = value.minimumLocalCliVersion.split(".").map(BigInt);
16
+ for (let i = 0; i < current.length; i++) {
17
+ if (minimum[i] > current[i])
18
+ throw new ApiError(426, "cli_update_required", cliRefusals.cli_update_required);
19
+ if (minimum[i] < current[i])
20
+ break;
21
+ }
22
+ if (value.mode === "paused")
23
+ throw new ApiError(503, "local_checks_paused", cliRefusals.local_checks_paused);
24
+ // A local-capable client never falls back to sending source files to the hosted upload API.
25
+ if (value.mode !== "local")
26
+ throw new ApiError(503, "unavailable", unavailable);
27
+ }
28
+ export async function analyze(path, _options, ctx) {
29
+ const startup = ctx.startup;
30
+ requireLocal(await ctx.api.request("checks/mode"));
31
+ const release = ctx.relayRuntime?.release ?? await loadInstalledRelease();
32
+ if (release.projectContext !== "checks")
33
+ throw new Error(unavailable);
34
+ const token = await ctx.token?.();
35
+ if (!token)
36
+ throw new Error("Not signed in. Run counterfact-labs login.");
37
+ const folder = resolveAgentFolder(path, ctx.cwd);
38
+ const config = Object.freeze({ ...startup.transport, CF_RELAY_URL: startup.transport.CF_RELAY_URL ?? release.relayUrl,
39
+ CF_LOCAL_RELAY: "1", CF_CONSOLE_URL: ctx.api.baseUrl, CF_CONSOLE_TOKEN: token });
40
+ const controller = new AbortController();
41
+ const cancel = () => controller.abort();
42
+ process.once("SIGINT", cancel);
43
+ process.once("SIGTERM", cancel);
44
+ let progressShown = false;
45
+ const clearProgress = () => { if (progressShown) {
46
+ ctx.write("\r\u001b[2K");
47
+ progressShown = false;
48
+ } };
49
+ try {
50
+ return await (ctx.relayRuntime?.run ?? runRelayCheck)(folder, {
51
+ settings: startup.settings, config, release, connect: ctx.relayRuntime?.connect,
52
+ signal: ctx.signal ? AbortSignal.any([controller.signal, ctx.signal]) : controller.signal,
53
+ stdout() { }, stderr() { },
54
+ onStart: () => { ctx.onCheckStart?.(); ctx.write("\r\u001b[2KAnalyzing the agent…"); progressShown = true; },
55
+ onError: failure => {
56
+ clearProgress();
57
+ ctx.write(`${failure.message}\n`);
58
+ },
59
+ onSaved: result => {
60
+ clearProgress();
61
+ ctx.onCheckComplete?.(result);
62
+ if (result.findingIds?.length)
63
+ ctx.write(`Finding ids: ${result.findingIds.join(", ")}\n`);
64
+ ctx.write(`Full results: ${result.url}\n`);
65
+ },
66
+ });
67
+ }
68
+ finally {
69
+ clearProgress();
70
+ process.removeListener("SIGINT", cancel);
71
+ process.removeListener("SIGTERM", cancel);
72
+ }
73
+ }
@@ -0,0 +1,51 @@
1
+ import { emailSignInMessages } from "../../contract/email-sign-in-messages.js";
2
+ import { EMAIL_SIGN_IN_TERMS, normalizeSignInEmail, signInWithCode, startEmailSignIn } from "../email-sign-in.js";
3
+ import { startSignIn } from "../sign-in.js";
4
+ export function parseLoginOptions(args) {
5
+ // The router supplies usage for syntax errors without repeating private arguments.
6
+ const options = {};
7
+ for (let index = 0; index < args.length; index += 2) {
8
+ const flag = args[index];
9
+ const value = args[index + 1];
10
+ if ((flag !== "--email" && flag !== "--code") || !value?.trim() || value.startsWith("--"))
11
+ throw new Error();
12
+ const key = flag === "--email" ? "email" : "code";
13
+ if (options[key] !== undefined)
14
+ throw new Error();
15
+ options[key] = value;
16
+ }
17
+ if (options.code !== undefined && options.email === undefined)
18
+ throw new Error();
19
+ return options;
20
+ }
21
+ export async function login(ctx, options = {}) {
22
+ if (options.email !== undefined) {
23
+ if (!ctx.startup.emailSignIn)
24
+ throw new Error(emailSignInMessages.email_unavailable);
25
+ const email = normalizeSignInEmail(options.email);
26
+ if (!email)
27
+ throw new Error(emailSignInMessages.invalid_email);
28
+ ctx.write(EMAIL_SIGN_IN_TERMS + "\n");
29
+ let code = options.code;
30
+ if (code === undefined) {
31
+ await startEmailSignIn(ctx, email);
32
+ ctx.write(`We sent a sign-in code to ${email}.\n`);
33
+ if (!ctx.signInInput.isTerminal) {
34
+ ctx.write(`To finish signing in, run counterfact-labs login --email ${email} --code <code>.\n`);
35
+ return 0;
36
+ }
37
+ code = await ctx.signInInput.readCode("Enter the code from your email: ", ctx.signal);
38
+ if (code === undefined)
39
+ throw new Error(emailSignInMessages.cancelled);
40
+ }
41
+ await signInWithCode(ctx, email, code);
42
+ ctx.write("Signed in.\n");
43
+ return 0;
44
+ }
45
+ const flow = await startSignIn(ctx);
46
+ ctx.write(`1. Open ${flow.verificationUri}. If asked, sign in with the code we email you.\n`
47
+ + `2. Then enter this code on that page: ${flow.userCode}\n`);
48
+ await flow.done;
49
+ ctx.write("Signed in.\n");
50
+ return 0;
51
+ }
@@ -0,0 +1,18 @@
1
+ export const COMPLETION_SETTINGS_MISMATCH_MESSAGE = "This folder has a saved result for a different Counterfact project or address. Run the analysis again with the original settings to save it, or delete .counterfact/completion.json to start a new analysis.";
2
+ export const matchesCheck = (status, identity) => status.checkId === identity.checkId && status.projectId === identity.projectId;
3
+ export function completionRecoveryDecision(input) {
4
+ if (input.localMismatch)
5
+ return { finished: false, message: COMPLETION_SETTINGS_MISMATCH_MESSAGE };
6
+ if (!input.statusSucceeded)
7
+ return { finished: false };
8
+ const { status, completedPayload } = input;
9
+ // A different analysis cannot settle this saved payload, even when submission reports a conflict.
10
+ if (status && completedPayload && !matchesCheck(status, completedPayload))
11
+ return { finished: false };
12
+ // Only trusted terminal status retires unsaved work; outages and closing work remain retryable.
13
+ if (status?.uploadState === "missing" && (status.state === "closed" || status.state === "expired")
14
+ && completedPayload && matchesCheck(status, completedPayload))
15
+ return { finished: true };
16
+ // A refusal never retires paid work by itself; only the console's own terminal status above can.
17
+ return { finished: false };
18
+ }
@@ -1,28 +1,22 @@
1
+ import { readCliSettings } from "../adapters/settings.js";
1
2
  import { homedir } from "node:os";
2
- import { createInterface } from "node:readline/promises";
3
3
  import { setTimeout as delay } from "node:timers/promises";
4
4
  import { ApiClient, DEFAULT_URL } from "./api.js";
5
5
  import { openBrowser } from "./browser.js";
6
6
  import { readCredentials } from "./credentials.js";
7
+ import { userEmail } from "./git.js";
8
+ import { terminalSignInInput } from "./sign-in-input.js";
7
9
  export function commandContext(options = {}) {
10
+ const startup = readCliSettings(options.env, options.emailSignIn);
8
11
  const homeDir = options.homeDir ?? homedir();
9
- const baseUrl = (options.baseUrl ?? process.env.COUNTERFACT_URL ?? DEFAULT_URL).replace(/\/+$/, "");
12
+ const baseUrl = (options.baseUrl ?? startup.baseUrl ?? DEFAULT_URL).replace(/\/+$/, "");
10
13
  const api = new ApiClient(baseUrl, () => readCredentials(homeDir, api.baseUrl), undefined, options.signal);
11
14
  const write = options.write ?? (text => { process.stdout.write(text); });
12
15
  return {
16
+ startup, signal: options.signal, relayRuntime: options.relayRuntime, cwd: options.cwd ?? process.cwd(),
17
+ token: () => readCredentials(homeDir, api.baseUrl), onCheckComplete: options.onCheckComplete, onCheckStart: options.onCheckStart,
13
18
  api, homeDir, write, openBrowser: options.openBrowser ?? openBrowser,
19
+ signInInput: options.signInInput ?? terminalSignInInput(), readEmail: options.readEmail ?? userEmail,
14
20
  sleep: options.sleep ?? (async (ms) => { await delay(ms, undefined, { signal: options.signal }); }), now: options.now ?? Date.now,
15
- ask: options.ask ?? (async (question) => {
16
- if (!process.stdin.isTTY)
17
- throw new Error("Use --yes to confirm a check, or --dry-run to see the files and price.");
18
- write(question);
19
- const input = createInterface({ input: process.stdin });
20
- try {
21
- return /^(?:y|yes)?$/i.test((await input.question("")).trim());
22
- }
23
- finally {
24
- input.close();
25
- }
26
- }),
27
21
  };
28
22
  }
@@ -0,0 +1,56 @@
1
+ import { emailSignInMessages } from "../contract/email-sign-in-messages.js";
2
+ import { saveCredentials } from "./credentials.js";
3
+ export const EMAIL_SIGN_IN_TERMS = "By signing in, you agree to our Terms of Service (https://counterfactlabs.com/terms) and Privacy Policy (https://counterfactlabs.com/privacy).";
4
+ function hasControlCharacters(value) {
5
+ return [...value].some(character => {
6
+ const code = character.charCodeAt(0);
7
+ return code < 32 || code === 127;
8
+ });
9
+ }
10
+ export function normalizeSignInEmail(value) {
11
+ if (typeof value !== "string")
12
+ return undefined;
13
+ const email = value.trim().toLowerCase();
14
+ if (email.length > 320 || !/^[^@\s]+@[^@\s.]+\.[^@\s]+$/.test(email)
15
+ || /[<>()[\],:;"\\]/.test(email) || hasControlCharacters(email))
16
+ return undefined;
17
+ // ASCII domain labels keep the recipient spelling consistent with the console's mailer.
18
+ const domain = email.split("@")[1];
19
+ return /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/.test(domain) ? email : undefined;
20
+ }
21
+ export function validSignInCode(value) {
22
+ return typeof value === "string" && value.trim().length > 0 && !hasControlCharacters(value.trim()) && !/[\u2028\u2029]/.test(value.trim());
23
+ }
24
+ function requireEmail(value) {
25
+ const email = normalizeSignInEmail(value);
26
+ if (!email)
27
+ throw new Error(emailSignInMessages.invalid_email);
28
+ return email;
29
+ }
30
+ export async function startEmailSignIn(ctx, email) {
31
+ const value = await ctx.api.request("email/start", { email: requireEmail(email) }, false);
32
+ if (!value || typeof value !== "object" || Array.isArray(value) || !("expiresIn" in value)
33
+ || typeof value.expiresIn !== "number" || !Number.isInteger(value.expiresIn) || value.expiresIn <= 0) {
34
+ throw new Error(emailSignInMessages.invalid_response);
35
+ }
36
+ return { expiresIn: value.expiresIn };
37
+ }
38
+ export async function signInWithCode(ctx, email, code) {
39
+ const normalizedEmail = requireEmail(email);
40
+ if (!validSignInCode(code))
41
+ throw new Error(emailSignInMessages.invalid_code);
42
+ const value = await ctx.api.request("email/verify", { email: normalizedEmail, code: code.trim(), clientName: "Command line" }, false);
43
+ if (!value || typeof value !== "object" || Array.isArray(value) || !("token" in value)
44
+ || typeof value.token !== "string" || !value.token.trim())
45
+ throw new Error(emailSignInMessages.invalid_response);
46
+ // A request can complete just as the caller cancels; do not replace a saved sign-in then.
47
+ if (ctx.signal?.aborted)
48
+ throw new DOMException(emailSignInMessages.cancelled, "AbortError");
49
+ try {
50
+ await saveCredentials(ctx.homeDir, ctx.api.baseUrl, value.token);
51
+ }
52
+ catch {
53
+ throw new Error(emailSignInMessages.request_failed);
54
+ }
55
+ return "signed_in";
56
+ }
@@ -1,15 +1,19 @@
1
- import { execFile } from "node:child_process";
2
- import { promisify } from "node:util";
3
1
  import { basename, relative, resolve, sep } from "node:path";
4
- export async function repositoryFacts(root, git = async (args) => (await promisify(execFile)("git", ["-C", root, ...args], { timeout: 3000, maxBuffer: 16_384 })).stdout) {
5
- const folderName = basename(resolve(root));
6
- let repoRoot;
2
+ import { gitRepositoryRoot } from "../adapters/repository-root.js";
3
+ import { runGit } from "../adapters/git.js";
4
+ export async function userEmail(folder, git = async (args) => (await runGit(folder, args, { userConfig: true })).toString("utf8")) {
7
5
  try {
8
- repoRoot = (await git(["rev-parse", "--show-toplevel"])).trim();
6
+ return await git(["config", "--get", "user.email"]);
9
7
  }
10
8
  catch {
11
- return { remoteUrl: null, relPath: "", folderName, branch: null, commit: null, dirty: null };
9
+ return undefined;
12
10
  }
11
+ }
12
+ export async function repositoryFacts(root, git = async (args) => (await runGit(root, args)).toString("utf8")) {
13
+ const folderName = basename(resolve(root));
14
+ const repoRoot = await gitRepositoryRoot(git);
15
+ if (repoRoot === null)
16
+ return { remoteUrl: null, relPath: "", folderName, branch: null, commit: null, dirty: null };
13
17
  let remoteUrl;
14
18
  try {
15
19
  remoteUrl = (await git(["config", "--get", "remote.origin.url"])).trim().replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/]*@/i, "$1") || null;
@@ -28,7 +32,7 @@ export async function repositoryFacts(root, git = async (args) => (await promisi
28
32
  const [branch, commit, status] = await Promise.all([
29
33
  read(["rev-parse", "--abbrev-ref", "HEAD"]), read(["rev-parse", "HEAD"]),
30
34
  // Include new files even when Git hides them, and exclude sibling folders.
31
- read(["status", "--porcelain", "--untracked-files=normal", "--", "."]),
35
+ read(["status", "--porcelain=v2", "--no-renames", "--ignore-submodules=all", "--untracked-files=normal", "--", "."]),
32
36
  ]);
33
37
  return { repoRoot, remoteUrl, relPath: relative(repoRoot, root).split(sep).join("/"), folderName,
34
38
  branch: branch === "HEAD" ? null : branch || null, commit: commit || null, dirty: status === null ? null : status.length > 0 };
@@ -0,0 +1,18 @@
1
+ import { localFindingsPayloadSchema } from "../contract/console.js";
2
+ import { RESULTS_TOO_LARGE_MESSAGE } from "../contract/cli-refusals.js";
3
+ export function buildIntakePayload(identity, agent, report) {
4
+ const repo = (key) => key === null || key === report.repoKey ? agent.repository : key;
5
+ const findings = report.findings.map(finding => {
6
+ // The local report may add presentation fields; the intake accepts cleaned evidence only.
7
+ const { kind, severity, title, cause, summary, category, question, site, excerpt, otherSites, fixes, sourceNotes } = finding;
8
+ return { kind, severity, title, cause, summary, category, question, site, excerpt,
9
+ fixes: fixes.map(({ title, approach, kind, targetFiles, expectedOutcome, summary, impact, confidence, objective, status, blockedBy, alsoFixes, delta }) => ({ title, approach, kind, targetFiles, expectedOutcome, summary, impact, confidence, objective, status, blockedBy, alsoFixes, delta })),
10
+ siteRepo: repo(finding.siteRepo), otherSites: otherSites.map(site => ({ ...site, repo: repo(site.repo) })),
11
+ ...(sourceNotes === undefined ? {} : { sourceNotes: sourceNotes && { ...sourceNotes,
12
+ counterEvidence: sourceNotes.counterEvidence.map(site => ({ ...site, repo: repo(site.repo) })) } }) };
13
+ });
14
+ const result = localFindingsPayloadSchema.safeParse({ ...identity, agent, findings });
15
+ if (!result.success)
16
+ throw new Error(RESULTS_TOO_LARGE_MESSAGE);
17
+ return result.data;
18
+ }
@@ -2,39 +2,50 @@
2
2
  import { pathToFileURL } from "node:url";
3
3
  import { realpath } from "node:fs/promises";
4
4
  import { errorCode, errorText } from "./api.js";
5
+ import { readEmailSignIn } from "../adapters/settings.js";
5
6
  import { commandContext } from "./context.js";
6
- import { check } from "./commands/check.js";
7
+ import { analyze } from "./commands/analyze.js";
7
8
  import { finding } from "./commands/finding.js";
8
9
  import { balance } from "./commands/balance.js";
9
- import { login } from "./commands/login.js";
10
+ import { login, parseLoginOptions } from "./commands/login.js";
10
11
  import { logout } from "./commands/logout.js";
11
12
  import { serveMcp } from "./mcp.js";
12
- const usage = "Usage: counterfact-labs check [path] [--yes] [--dry-run]\n counterfact-labs finding <id>\n counterfact-labs balance | login | logout | mcp\n";
13
+ const usage = "Usage: counterfact-labs analyze [path]\n counterfact-labs finding <id>\n counterfact-labs balance | login | logout | mcp\n";
13
14
  export async function run(args, options = {}) {
14
15
  const write = options.write ?? (text => { process.stdout.write(text); });
16
+ const emailSignIn = options.emailSignIn ?? readEmailSignIn(options.env);
17
+ const commandOptions = { ...options, emailSignIn };
15
18
  try {
16
19
  const [command, ...rest] = args;
17
20
  if (["--help", "-h", "help"].includes(command) || !command) {
18
21
  write(usage);
19
22
  return 0;
20
23
  }
21
- if (command === "check") {
22
- const flags = rest.filter(arg => arg.startsWith("--"));
23
- const paths = rest.filter(arg => !arg.startsWith("--"));
24
- if (paths.length > 1 || flags.some(flag => !["--yes", "--dry-run"].includes(flag)))
24
+ if (command === "analyze") {
25
+ if (rest.length > 1 || rest.some(arg => arg.startsWith("-")))
25
26
  throw new Error(usage.trimEnd());
26
- return await check(paths[0] ?? ".", { yes: flags.includes("--yes"), dryRun: flags.includes("--dry-run") }, commandContext(options));
27
+ return await analyze(rest[0] ?? ".", {}, commandContext(commandOptions));
27
28
  }
28
29
  if (command === "finding" && rest.length === 1 && !rest[0].startsWith("-"))
29
- return await finding(rest[0], commandContext(options));
30
+ return await finding(rest[0], commandContext(commandOptions));
31
+ if (command === "login" && emailSignIn) {
32
+ let parsed;
33
+ try {
34
+ parsed = parseLoginOptions(rest);
35
+ }
36
+ catch {
37
+ throw new Error(usage.trimEnd());
38
+ }
39
+ return await login(commandContext(commandOptions), parsed);
40
+ }
30
41
  if (rest.length)
31
42
  throw new Error(usage.trimEnd());
32
43
  if (command === "balance")
33
- return await balance(commandContext(options));
44
+ return await balance(commandContext(commandOptions));
34
45
  if (command === "login")
35
- return await login(commandContext(options));
46
+ return await login(commandContext(commandOptions));
36
47
  if (command === "logout")
37
- return await logout(commandContext(options));
48
+ return await logout(commandContext(commandOptions));
38
49
  if (command === "mcp") {
39
50
  await serveMcp(process.stdin, process.stdout, options);
40
51
  return 0;
@@ -1,26 +1,29 @@
1
1
  import { createInterface } from "node:readline";
2
2
  import { once } from "node:events";
3
- import { readFile } from "node:fs/promises";
4
- import { resolve } from "node:path";
3
+ import { resolveFolderPath } from "../adapters/folder-path.js";
4
+ import { FINISH_FAILURE_MESSAGE, START_FAILURE_MESSAGE } from "../contract/cli-refusals.js";
5
5
  import { errorCode, errorText } from "./api.js";
6
6
  import { commandContext } from "./context.js";
7
- import { confirmPrepared, prepareCheck } from "./commands/check.js";
7
+ import { analyze } from "./commands/analyze.js";
8
+ import { CLI_VERSION } from "../contract/cli-version.js";
8
9
  import { finding } from "./commands/finding.js";
9
10
  import { balance } from "./commands/balance.js";
10
- import { checkToolAllowed } from "./claude-code-permission.js";
11
+ import { ANALYZE_TOOL_NAME, analyzeToolAllowed } from "./claude-code-permission.js";
11
12
  import { signInStatus, startSignIn } from "./sign-in.js";
12
- const tools = [
13
+ import { readEmailSignIn } from "../adapters/settings.js";
14
+ import { emailSignInMessages } from "../contract/email-sign-in-messages.js";
15
+ import { EMAIL_SIGN_IN_TERMS, normalizeSignInEmail, validSignInCode, startEmailSignIn, signInWithCode } from "./email-sign-in.js";
16
+ import { resolveAgentFolder, resolveMcpAgentFolder } from "./agent-folder.js";
17
+ import { suggestedEmail } from "./suggested-email.js";
18
+ const legacyTools = [
13
19
  {
14
- name: "check_agent",
15
- description: "Preview the files and price of an agent check. Show the user the price and get their agreement "
16
- + "before calling with confirm: true. A confirmation uses the most recent preview for that path in this session. "
17
- + "Suggest a check after edits; wait for the user to ask for it. If the tool call is cancelled or times out, "
18
- + "a confirmed check keeps running on the server and its result will be in the console's Checks page.",
19
- inputSchema: { type: "object", properties: { path: { type: "string", description: "The agent folder on this computer." }, confirm: { type: "boolean", default: false } }, required: ["path"], additionalProperties: false },
20
+ name: ANALYZE_TOOL_NAME,
21
+ 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.",
22
+ inputSchema: { type: "object", properties: { path: { type: "string", description: "The agent folder on this computer." } }, required: ["path"], additionalProperties: false },
20
23
  },
21
- { 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 } },
24
+ { 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 } },
22
25
  { name: "get_balance", description: "Get the balance and top-up link.", inputSchema: { type: "object", properties: {}, additionalProperties: false } },
23
- { name: "sign_in_status", description: "Check whether this computer is signed in to Counterfact, and whether the user has allowed the check 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 } },
26
+ { 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 } },
24
27
  { 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 } },
25
28
  ];
26
29
  class ProtocolError extends Error {
@@ -32,9 +35,36 @@ class ProtocolError extends Error {
32
35
  }
33
36
  function object(value) { return value !== null && typeof value === "object" && !Array.isArray(value); }
34
37
  export async function serveMcp(input, output, options = {}) {
35
- const { version } = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
36
- const previews = new Map();
37
38
  const cwd = options.cwd ?? process.cwd();
39
+ const guarded = (options.env ?? process.env).CF_MCP_PATH_GUARD !== "0";
40
+ const launch = guarded ? resolveFolderPath(cwd).catch(() => null) : Promise.resolve(cwd);
41
+ const lastAnalysis = new Map();
42
+ const emailSignIn = options.emailSignIn ?? readEmailSignIn(options.env);
43
+ const tools = emailSignIn ? [...legacyTools.slice(0, 3),
44
+ {
45
+ name: "sign_in_status",
46
+ description: "Check whether this computer is signed in to Counterfact and whether the user has allowed the analysis tool in Claude Code. Use path only to choose the agent folder for the local git email suggestion; otherwise use the launch folder. Analysis permission is always checked in the launch folder. A valid local git email is returned as suggestedEmail. Prefer the signed-in user's email already in your context; otherwise offer suggestedEmail. Ask the user to confirm an address before sending a code. With wait: true, wait only for browser sign-in to finish.",
47
+ inputSchema: { type: "object", properties: {
48
+ wait: { type: "boolean" },
49
+ path: { type: "string", description: "The chosen agent folder for the local git email suggestion. Defaults to the launch folder; does not change the analysis permission check." },
50
+ }, additionalProperties: false },
51
+ },
52
+ {
53
+ name: "sign_in",
54
+ description: `With email, send a Counterfact sign-in code only after the user confirms that address. Before calling, show: ${EMAIL_SIGN_IN_TERMS} Then ask the user for the code from their email and call sign_in_with_code. Without email, open the Counterfact sign-in page in the user's browser, show the returned link and code, then call sign_in_status with wait: true.`,
55
+ inputSchema: { type: "object", properties: {
56
+ email: { type: "string", description: "The email address confirmed by the user." },
57
+ }, additionalProperties: false },
58
+ },
59
+ {
60
+ name: "sign_in_with_code",
61
+ description: "Complete Counterfact email sign-in with the confirmed email address and the code supplied by the user. Save the sign-in on this computer. Never repeat the code in messages, logs, or reports. Do not request another code automatically.",
62
+ inputSchema: { type: "object", properties: {
63
+ email: { type: "string", description: "The email address confirmed by the user." },
64
+ code: { type: "string", description: "The sign-in code supplied by the user from their email." },
65
+ }, required: ["email", "code"], additionalProperties: false },
66
+ },
67
+ ] : legacyTools;
38
68
  let pendingSignIn;
39
69
  const requests = new Map();
40
70
  let queue = Promise.resolve();
@@ -46,21 +76,35 @@ export async function serveMcp(input, output, options = {}) {
46
76
  if (!object(args))
47
77
  throw new ProtocolError(-32602, "Tool arguments must be an object.");
48
78
  const name = params.name;
49
- const allowed = name === "check_agent" ? ["path", "confirm"] : name === "get_finding" ? ["id"] : name === "sign_in_status" ? ["wait"] : [];
79
+ const allowed = name === ANALYZE_TOOL_NAME ? ["path"] : name === "get_finding" ? ["id"]
80
+ : name === "sign_in_status" ? (emailSignIn ? ["wait", "path"] : ["wait"])
81
+ : emailSignIn && name === "sign_in" ? ["email"] : emailSignIn && name === "sign_in_with_code" ? ["email", "code"] : [];
50
82
  if (!tools.some(tool => tool.name === name) || Object.keys(args).some(key => !allowed.includes(key)))
51
83
  throw new ProtocolError(-32602, "Unknown tool or argument.");
52
- if (name === "check_agent" && (typeof args.path !== "string" || !args.path.trim() || (args.confirm !== undefined && typeof args.confirm !== "boolean")))
53
- throw new ProtocolError(-32602, "Provide a path and an optional boolean confirmation.");
84
+ if (name === ANALYZE_TOOL_NAME && (typeof args.path !== "string" || !args.path.trim()))
85
+ throw new ProtocolError(-32602, "Provide a path.");
54
86
  if (name === "get_finding" && (typeof args.id !== "string" || !args.id.trim()))
55
87
  throw new ProtocolError(-32602, "Provide a finding id.");
56
88
  if (name === "sign_in_status" && args.wait !== undefined && typeof args.wait !== "boolean")
57
89
  throw new ProtocolError(-32602, "Provide an optional boolean wait.");
90
+ if (name === "sign_in_status" && "path" in args && (typeof args.path !== "string" || !args.path.trim()))
91
+ throw new ProtocolError(-32602, "Provide a path.");
92
+ const emailFlow = name === "sign_in_with_code" || (name === "sign_in" && "email" in args);
93
+ const email = emailFlow ? normalizeSignInEmail(args.email) : undefined;
94
+ if (emailFlow && !email)
95
+ throw new ProtocolError(-32602, emailSignInMessages.invalid_email);
96
+ if (name === "sign_in_with_code" && !validSignInCode(args.code))
97
+ throw new ProtocolError(-32602, emailSignInMessages.invalid_code);
58
98
  let text = "", exitCode = 0;
59
99
  let findingIds;
60
100
  let url;
61
101
  let signIn;
62
102
  try {
63
- const ctx = commandContext({ ...options, signal, write: chunk => {
103
+ const ctx = commandContext({ ...options, emailSignIn, signal, onCheckComplete: result => {
104
+ findingIds = result.findingIds ?? [];
105
+ url = result.url;
106
+ options.onCheckComplete?.(result);
107
+ }, write: chunk => {
64
108
  // Progress is transient terminal text; only the final lines belong in a tool response.
65
109
  if (!chunk.startsWith("\r"))
66
110
  text += chunk;
@@ -71,7 +115,7 @@ export async function serveMcp(input, output, options = {}) {
71
115
  await balance(ctx);
72
116
  else if (name === "sign_in_status") {
73
117
  const pending = pendingSignIn;
74
- const allowed = await checkToolAllowed(ctx.homeDir, cwd);
118
+ const allowed = await analyzeToolAllowed(ctx.homeDir, cwd);
75
119
  if (args.wait === true && pending) {
76
120
  const cancel = () => pending.controller.abort();
77
121
  signal.addEventListener("abort", cancel, { once: true });
@@ -84,8 +128,27 @@ export async function serveMcp(input, output, options = {}) {
84
128
  signal.removeEventListener("abort", cancel);
85
129
  }
86
130
  }
87
- signIn = { ...await signInStatus(ctx), checkToolAllowed: allowed };
131
+ signIn = { ...await signInStatus(ctx), analyzeToolAllowed: allowed };
88
132
  text = signIn.signedIn ? "Signed in." : "Not signed in.";
133
+ if (emailSignIn) {
134
+ const suggestion = await suggestedEmail(resolveAgentFolder(args.path ?? ".", cwd), ctx.readEmail);
135
+ if (suggestion)
136
+ signIn.suggestedEmail = suggestion;
137
+ }
138
+ }
139
+ else if (emailFlow) {
140
+ if (pendingSignIn)
141
+ throw new Error(emailSignInMessages.browser_sign_in_pending);
142
+ if (name === "sign_in") {
143
+ const start = await startEmailSignIn(ctx, email);
144
+ signIn = { email: email, expiresIn: start.expiresIn };
145
+ 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.`;
146
+ }
147
+ else {
148
+ await signInWithCode(ctx, email, args.code);
149
+ signIn = { signedIn: true };
150
+ text = "Signed in.";
151
+ }
89
152
  }
90
153
  else if (name === "sign_in") {
91
154
  if ((await signInStatus(ctx)).signedIn)
@@ -99,7 +162,7 @@ export async function serveMcp(input, output, options = {}) {
99
162
  signal.addEventListener("abort", cancel, { once: true });
100
163
  try {
101
164
  signal.throwIfAborted();
102
- const flow = await startSignIn(commandContext({ ...options, homeDir: ctx.homeDir, baseUrl: ctx.api.baseUrl, signal: controller.signal }));
165
+ const flow = await startSignIn(commandContext({ ...options, emailSignIn, homeDir: ctx.homeDir, baseUrl: ctx.api.baseUrl, signal: controller.signal }));
103
166
  pending = { flow, controller, opened: false };
104
167
  pendingSignIn = pending;
105
168
  const current = pending;
@@ -119,22 +182,26 @@ export async function serveMcp(input, output, options = {}) {
119
182
  }
120
183
  }
121
184
  else {
122
- const path = resolve(args.path);
123
- if (args.confirm === true) {
124
- const prepared = previews.get(path);
125
- previews.delete(path);
126
- if (!prepared)
127
- throw new Error("Get a preview first. Show the user the price and get their agreement before confirming.");
128
- url = prepared.url;
129
- const result = await confirmPrepared(prepared, ctx);
130
- findingIds = result.findingIds;
131
- url = result.taste.url;
185
+ const launchFolder = await launch;
186
+ if (guarded && !launchFolder)
187
+ throw new Error(START_FAILURE_MESSAGE);
188
+ const folder = guarded ? await resolveMcpAgentFolder(args.path, launchFolder, ctx.homeDir) : resolveAgentFolder(args.path, cwd);
189
+ const last = lastAnalysis.get(folder);
190
+ if (guarded && last !== undefined && ctx.now() - last < 10 * 60 * 1000) {
191
+ throw new Error(FINISH_FAILURE_MESSAGE);
132
192
  }
133
- else {
134
- previews.delete(path);
135
- const prepared = await prepareCheck(args.path, ctx);
136
- previews.set(path, prepared);
137
- url = prepared.url;
193
+ let started = false;
194
+ try {
195
+ exitCode = await analyze(folder, {}, { ...ctx, onCheckStart: () => {
196
+ started = true;
197
+ if (guarded)
198
+ lastAnalysis.set(folder, ctx.now());
199
+ ctx.onCheckStart?.();
200
+ } });
201
+ }
202
+ finally {
203
+ if (guarded && started)
204
+ lastAnalysis.set(folder, ctx.now());
138
205
  }
139
206
  }
140
207
  }
@@ -143,7 +210,7 @@ export async function serveMcp(input, output, options = {}) {
143
210
  exitCode = errorCode(error);
144
211
  }
145
212
  // Some clients (Claude Code among them) give the model only the structured result when one is present, so it
146
- // repeats the text: without it the model sees no files, price or results.
213
+ // repeats the text: without it the model cannot read the result.
147
214
  return { content: [{ type: "text", text }], isError: exitCode !== 0,
148
215
  structuredContent: { text, exitCode, ...(findingIds ? { findingIds } : {}), ...(url ? { url } : {}), ...signIn } };
149
216
  }
@@ -156,7 +223,7 @@ export async function serveMcp(input, output, options = {}) {
156
223
  if (message.method === "initialize") {
157
224
  const requested = object(message.params) ? message.params.protocolVersion : undefined;
158
225
  result = { protocolVersion: ["2024-11-05", "2025-03-26", "2025-06-18"].includes(requested) ? requested : "2025-06-18",
159
- capabilities: { tools: {} }, serverInfo: { name: "counterfact-labs", version } };
226
+ capabilities: { tools: {} }, serverInfo: { name: "counterfact-labs", version: CLI_VERSION } };
160
227
  }
161
228
  else if (message.method === "ping")
162
229
  result = {};
@@ -172,6 +239,8 @@ export async function serveMcp(input, output, options = {}) {
172
239
  reply = errorReply(id, error);
173
240
  }
174
241
  // The caller stopped waiting; a late result must not look like a completed call.
242
+ // The output is an MCP stdio Writable carrying JSON-RPC, not an HTML HTTP response.
243
+ // nosemgrep: javascript.express.security.audit.xss.direct-response-write.direct-response-write
175
244
  if (!signal.aborted && !output.write(JSON.stringify(reply) + "\n"))
176
245
  await once(output, "drain");
177
246
  }
@@ -202,7 +271,7 @@ export async function serveMcp(input, output, options = {}) {
202
271
  id = message.id;
203
272
  const controller = new AbortController();
204
273
  requests.set(id, controller);
205
- // Keep preview and confirm in order while still reading cancellation notifications.
274
+ // Keep tool requests in order while still reading cancellation notifications.
206
275
  const request = message;
207
276
  queue = queue.then(() => respond(request, id, controller.signal)).finally(() => { requests.delete(id); });
208
277
  continue;
@@ -0,0 +1,10 @@
1
+ export function money(micro) {
2
+ const cents = Math.round(micro / 10_000);
3
+ const whole = Math.trunc(cents / 100);
4
+ const fraction = Math.abs(cents % 100);
5
+ return `${cents < 0 ? "-" : ""}$${Math.abs(whole)}${fraction ? `.${String(fraction).padStart(2, "0")}` : ""}`;
6
+ }
7
+ export function tasteText(taste, findingIds) {
8
+ return [taste.headline, ...taste.lines.map(line => ` ${line}`),
9
+ ...(findingIds.length ? [`Finding ids: ${findingIds.join(", ")}`] : []), `Full results: ${taste.url}`, ""].join("\n");
10
+ }