@counterfact-labs/cli 0.0.0-stage → 0.1.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.
- package/LICENSE +1 -0
- package/README.md +59 -2
- package/dist/api-types.js +2 -0
- package/dist/api.js +97 -0
- package/dist/commands/balance.js +6 -0
- package/dist/commands/check.js +102 -0
- package/dist/commands/finding.js +15 -0
- package/dist/commands/login.js +20 -0
- package/dist/commands/logout.js +6 -0
- package/dist/context.js +27 -0
- package/dist/credentials.js +46 -0
- package/dist/files.js +99 -0
- package/dist/git.js +21 -0
- package/dist/gitignore.js +70 -0
- package/dist/main.js +52 -0
- package/dist/mcp.js +157 -0
- package/dist/output.js +23 -0
- package/dist/secret-files.js +2 -0
- package/package.json +14 -4
package/LICENSE
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Copyright (c) 2026 Counterfact Labs, Inc. All rights reserved. This software is provided to access the Counterfact service and may be used only for that purpose. No other rights are granted.
|
package/README.md
CHANGED
|
@@ -1,3 +1,60 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Counterfact command line
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Check an agent's prompt, skills and tool definitions from your terminal or coding agent. Requires Node.js 20 or later.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm install -g @counterfact-labs/cli
|
|
7
|
+
counterfact-labs login
|
|
8
|
+
counterfact-labs check ./support-agent
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The command lists the selected files and shows the server's price before asking to continue. Use `--yes` after agreeing to that price. Use `--dry-run` to see the files and quote without starting analysis or charging your balance. A quote requires uploading the selected files, including in a dry run.
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
counterfact-labs check ./support-agent --dry-run
|
|
15
|
+
counterfact-labs check ./support-agent --yes
|
|
16
|
+
counterfact-labs finding <finding-id>
|
|
17
|
+
counterfact-labs balance
|
|
18
|
+
counterfact-labs logout
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Checks return exit code 0 on success, 1 on error and 2 when the balance is too low. The server chooses the files, sets the price and writes the result summary. The first eligible check is free. Failed checks are not charged.
|
|
22
|
+
|
|
23
|
+
The console is at `https://app.counterfactlabs.com`. Sign in again after moving from the old address; saved sign-in belongs to the server address.
|
|
24
|
+
|
|
25
|
+
Add the local MCP server to Codex:
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
codex mcp add counterfact -- npx -y @counterfact-labs/cli@latest mcp
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Checks can take several minutes. Codex has a [60-second tool timeout by default](https://learn.chatgpt.com/docs/extend/mcp?surface=cli). In `~/.codex/config.toml`, add `tool_timeout_sec = 900` to the existing `[mcp_servers.counterfact]` section. It should look like this; keep any other settings already in that section:
|
|
32
|
+
|
|
33
|
+
```toml
|
|
34
|
+
[mcp_servers.counterfact]
|
|
35
|
+
command = "npx"
|
|
36
|
+
args = ["-y", "@counterfact-labs/cli@latest", "mcp"]
|
|
37
|
+
tool_timeout_sec = 900
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Cancellation stops the local wait. A confirmed check keeps running on the server after cancellation or a timeout, and its result appears on the console's Checks page.
|
|
41
|
+
|
|
42
|
+
Add this to your project's `AGENTS.md`:
|
|
43
|
+
|
|
44
|
+
```markdown
|
|
45
|
+
When I ask to check an agent, use Counterfact's check_agent tool with its folder path.
|
|
46
|
+
Call it without confirm first. Show me the selected files and price, and get my
|
|
47
|
+
agreement before calling again with confirm: true. If the files or price change,
|
|
48
|
+
show me a fresh preview and ask again. Use get_finding for an explanation, quoted
|
|
49
|
+
lines and suggested fixes. Use get_balance for the balance and top-up link.
|
|
50
|
+
After editing prompts, skills or tool definitions, suggest a check and wait for
|
|
51
|
+
me to ask for it.
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The MCP server reads local files and communicates over stdin and stdout. Its tools are `check_agent(path, confirm?)`, `get_finding(id)` and `get_balance()`. Each result's structured content repeats the tool's text, and completed checks add their finding ids. Keep the preview and confirmation in the same MCP session.
|
|
55
|
+
|
|
56
|
+
Privacy: only files requested by the server are uploaded. The tool skips `.git`, `node_modules`, `.gitignore` matches, symlinks and secret file names such as `.env` and private keys. Uploaded files are kept with the check while the workspace exists, and deleted with the check or workspace. Sign-in is saved in `~/.config/counterfact-labs/credentials` with owner-only permissions. `logout` removes that local sign-in; revoke the token from your account to disable it elsewhere.
|
|
57
|
+
|
|
58
|
+
Set `COUNTERFACT_URL` to use another server. It defaults to `https://app.counterfactlabs.com`. Sign-in is tied to the server address; sign in again when changing it.
|
|
59
|
+
|
|
60
|
+
From this repository, `npm run cli:test` copies the API types and secret-file rule from their server owners, compiles the package with TypeScript and runs its offline tests. No additional npm dependencies are needed.
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { CLI_BODY_LIMIT } from "./api-types.js";
|
|
2
|
+
export const DEFAULT_URL = "https://app.counterfactlabs.com";
|
|
3
|
+
export class ApiError extends Error {
|
|
4
|
+
status;
|
|
5
|
+
code;
|
|
6
|
+
retryAfterMs;
|
|
7
|
+
constructor(status, code, message, retryAfterMs = 0) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.code = code;
|
|
11
|
+
this.retryAfterMs = retryAfterMs;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function retryAfterMs(value) {
|
|
15
|
+
if (!value)
|
|
16
|
+
return 0;
|
|
17
|
+
const seconds = Number(value);
|
|
18
|
+
if (Number.isFinite(seconds) && seconds >= 0)
|
|
19
|
+
return seconds * 1000;
|
|
20
|
+
const date = Date.parse(value);
|
|
21
|
+
return Number.isFinite(date) ? Math.max(0, date - Date.now()) : 0;
|
|
22
|
+
}
|
|
23
|
+
export class ApiClient {
|
|
24
|
+
token;
|
|
25
|
+
fetcher;
|
|
26
|
+
signal;
|
|
27
|
+
baseUrl;
|
|
28
|
+
constructor(baseUrl, token, fetcher = fetch, signal) {
|
|
29
|
+
this.token = token;
|
|
30
|
+
this.fetcher = fetcher;
|
|
31
|
+
this.signal = signal;
|
|
32
|
+
const url = new URL(baseUrl);
|
|
33
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash)
|
|
34
|
+
throw new Error("COUNTERFACT_URL must be an HTTP or HTTPS address without credentials or query fields.");
|
|
35
|
+
this.baseUrl = url.href.replace(/\/+$/, "");
|
|
36
|
+
}
|
|
37
|
+
async request(path, body, authenticated = true) {
|
|
38
|
+
this.signal?.throwIfAborted();
|
|
39
|
+
const headers = { accept: "application/json" };
|
|
40
|
+
if (authenticated) {
|
|
41
|
+
const token = await this.token();
|
|
42
|
+
if (!token)
|
|
43
|
+
throw new Error("Not signed in. Run counterfact-labs login.");
|
|
44
|
+
headers.authorization = `Bearer ${token}`;
|
|
45
|
+
}
|
|
46
|
+
if (body !== undefined)
|
|
47
|
+
headers["content-type"] = "application/json";
|
|
48
|
+
const serialized = body === undefined ? undefined : JSON.stringify(body);
|
|
49
|
+
if (serialized !== undefined && Buffer.byteLength(serialized, "utf8") > CLI_BODY_LIMIT) {
|
|
50
|
+
throw new ApiError(413, "body_too_large", "The upload is too large. Send at most 2 MB.");
|
|
51
|
+
}
|
|
52
|
+
const controller = new AbortController();
|
|
53
|
+
const cancel = () => controller.abort(this.signal?.reason);
|
|
54
|
+
const timeout = setTimeout(() => controller.abort(), 120_000);
|
|
55
|
+
timeout.unref();
|
|
56
|
+
this.signal?.addEventListener("abort", cancel, { once: true });
|
|
57
|
+
try {
|
|
58
|
+
this.signal?.throwIfAborted();
|
|
59
|
+
let response;
|
|
60
|
+
try {
|
|
61
|
+
response = await this.fetcher(`${this.baseUrl}/api/cli/${path}`, {
|
|
62
|
+
method: body === undefined ? "GET" : "POST", headers,
|
|
63
|
+
body: serialized,
|
|
64
|
+
redirect: "error", signal: controller.signal,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
this.signal?.throwIfAborted();
|
|
69
|
+
throw new ApiError(0, "unreachable", "Can't reach Counterfact. Try again shortly.");
|
|
70
|
+
}
|
|
71
|
+
let value;
|
|
72
|
+
try {
|
|
73
|
+
value = await response.json();
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
this.signal?.throwIfAborted();
|
|
77
|
+
if (response.ok) {
|
|
78
|
+
if (error instanceof SyntaxError)
|
|
79
|
+
throw new Error("Counterfact returned an invalid response. Try again shortly.");
|
|
80
|
+
throw new ApiError(0, "unreachable", "Can't reach Counterfact. Try again shortly.");
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
this.signal?.throwIfAborted();
|
|
84
|
+
if (!response.ok) {
|
|
85
|
+
const error = value;
|
|
86
|
+
throw new ApiError(response.status, error?.code ?? "request_failed", typeof error?.error === "string" ? error.error : "The request could not be completed. Try again shortly.", retryAfterMs(response.headers.get("retry-after")));
|
|
87
|
+
}
|
|
88
|
+
return value;
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
clearTimeout(timeout);
|
|
92
|
+
this.signal?.removeEventListener("abort", cancel);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export function errorCode(error) { return error instanceof ApiError && error.code === "low_balance" ? 2 : 1; }
|
|
97
|
+
export function errorText(error) { return error instanceof Error ? error.message : "The request could not be completed. Try again shortly."; }
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { realpath } from "node:fs/promises";
|
|
3
|
+
import { listFiles, readUpload } from "../files.js";
|
|
4
|
+
import { repositoryFacts } from "../git.js";
|
|
5
|
+
import { fileText, quoteText, tasteText } from "../output.js";
|
|
6
|
+
import { ApiError } from "../api.js";
|
|
7
|
+
async function awaitQuote(response, ctx) {
|
|
8
|
+
let progressShown = false;
|
|
9
|
+
try {
|
|
10
|
+
while (response.status === "running") {
|
|
11
|
+
ctx.write(`\r\u001b[2K${response.progress}`);
|
|
12
|
+
progressShown = true;
|
|
13
|
+
await ctx.sleep(2000);
|
|
14
|
+
response = await ctx.api.request(`checks/${encodeURIComponent(response.id)}`);
|
|
15
|
+
}
|
|
16
|
+
if (response.status === "failed" || (response.status === "awaiting_confirm" && "error" in response))
|
|
17
|
+
throw new Error(response.error);
|
|
18
|
+
if (response.status !== "awaiting_confirm")
|
|
19
|
+
throw new Error("The check has already finished. Run the check again for a new quote.");
|
|
20
|
+
return response;
|
|
21
|
+
}
|
|
22
|
+
finally {
|
|
23
|
+
if (progressShown)
|
|
24
|
+
ctx.write("\r\u001b[2K");
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export async function prepareCheck(path, ctx) {
|
|
28
|
+
const root = await realpath(resolve(path));
|
|
29
|
+
const { repoRoot, ...facts } = await repositoryFacts(root);
|
|
30
|
+
const files = await listFiles(root, repoRoot);
|
|
31
|
+
const plan = await ctx.api.request("checks/plan", { files });
|
|
32
|
+
const selected = new Set(plan.wanted);
|
|
33
|
+
const wanted = files.filter(file => selected.has(file.path));
|
|
34
|
+
if (!wanted.length)
|
|
35
|
+
throw new Error("No agent files were found in this folder.");
|
|
36
|
+
ctx.write(fileText(path, wanted));
|
|
37
|
+
const upload = await readUpload(root, files, plan.wanted);
|
|
38
|
+
const response = await awaitQuote(await ctx.api.request("checks", { ...facts, files: upload }), ctx);
|
|
39
|
+
ctx.write(quoteText(response.quote));
|
|
40
|
+
return { root, response, files: upload };
|
|
41
|
+
}
|
|
42
|
+
/** A confirmed check can keep running and charge while the connection is down. */
|
|
43
|
+
async function pollConfirmed(id, ctx) {
|
|
44
|
+
const started = ctx.now();
|
|
45
|
+
let waited = 0, backoff = 2000;
|
|
46
|
+
for (;;) {
|
|
47
|
+
try {
|
|
48
|
+
return await ctx.api.request(`checks/${id}`);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (!(error instanceof ApiError) || !(error.status === 0 || error.status === 429 || error.status >= 500 && error.status < 600))
|
|
52
|
+
throw error;
|
|
53
|
+
const wait = Math.max(backoff, error.retryAfterMs);
|
|
54
|
+
// Count both elapsed requests and scheduled waits, including injected clocks.
|
|
55
|
+
if (Math.max(ctx.now() - started, waited) + wait >= 300_000) {
|
|
56
|
+
throw new Error(`The check is still running. Its result will appear on the console's Checks page: ${ctx.api.baseUrl}/checks`);
|
|
57
|
+
}
|
|
58
|
+
await ctx.sleep(wait);
|
|
59
|
+
waited += wait;
|
|
60
|
+
backoff = Math.min(backoff * 2, 30_000);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export async function confirmPrepared(prepared, ctx) {
|
|
65
|
+
// A preview's price belongs to those uploaded files. Edits need a fresh preview and agreement.
|
|
66
|
+
const files = Object.entries(prepared.files).map(([path, text]) => ({ path, size: Buffer.byteLength(text, "utf8") }));
|
|
67
|
+
const current = await readUpload(prepared.root, files, files.map(file => file.path));
|
|
68
|
+
if (files.some(file => current[file.path] !== prepared.files[file.path]))
|
|
69
|
+
throw new Error("The agent files changed. Get a new preview and agree to its price before confirming.");
|
|
70
|
+
const id = encodeURIComponent(prepared.response.id);
|
|
71
|
+
await ctx.api.request(`checks/${id}/confirm`, { quoteId: prepared.response.quote.quoteId });
|
|
72
|
+
ctx.write("\r\u001b[2KChecking the agent…");
|
|
73
|
+
try {
|
|
74
|
+
for (;;) {
|
|
75
|
+
await ctx.sleep(2000);
|
|
76
|
+
const response = await pollConfirmed(id, ctx);
|
|
77
|
+
if (response.status === "done") {
|
|
78
|
+
ctx.write("\r\u001b[2K");
|
|
79
|
+
ctx.write(tasteText(response.taste, response.findingIds));
|
|
80
|
+
return response;
|
|
81
|
+
}
|
|
82
|
+
if (response.status === "failed" || (response.status === "awaiting_confirm" && "error" in response))
|
|
83
|
+
throw new Error(response.error);
|
|
84
|
+
if (response.status === "awaiting_confirm")
|
|
85
|
+
throw new Error("The check needs approval again. Run the check again to see its price.");
|
|
86
|
+
ctx.write(`\r\u001b[2K${response.progress}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
ctx.write("\r\u001b[2K");
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
export async function check(path, options, ctx) {
|
|
95
|
+
const prepared = await prepareCheck(path, ctx);
|
|
96
|
+
if (options.dryRun)
|
|
97
|
+
return 0;
|
|
98
|
+
if (!options.yes && !await ctx.ask("Continue? [Y/n] "))
|
|
99
|
+
return 0;
|
|
100
|
+
await confirmPrepared(prepared, ctx);
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export async function finding(id, ctx) {
|
|
2
|
+
const value = await ctx.api.request(`findings/${encodeURIComponent(id)}`);
|
|
3
|
+
ctx.write(`${value.title}\n${value.explanation}\n`);
|
|
4
|
+
for (const quote of value.quotes) {
|
|
5
|
+
ctx.write(`\n${quote.path}${quote.line === null ? "" : `:${quote.line}`}\n`);
|
|
6
|
+
if (quote.text !== null)
|
|
7
|
+
ctx.write(quote.text.split("\n").map(line => `> ${line}`).join("\n") + "\n");
|
|
8
|
+
}
|
|
9
|
+
for (const fix of value.fixes) {
|
|
10
|
+
ctx.write(`\n${fix.title}\n${fix.approach}\n`);
|
|
11
|
+
if (fix.targetFiles.length)
|
|
12
|
+
ctx.write(`Files: ${fix.targetFiles.map(file => `${file.path}${file.create ? " (new file)" : ""}`).join(", ")}\n`);
|
|
13
|
+
}
|
|
14
|
+
return 0;
|
|
15
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { saveCredentials } from "../credentials.js";
|
|
2
|
+
export async function login(ctx) {
|
|
3
|
+
const device = await ctx.api.request("device", {}, false);
|
|
4
|
+
ctx.write(`To sign in, open ${device.verificationUri} and enter ${device.userCode}\n`);
|
|
5
|
+
const until = ctx.now() + device.expiresIn * 1000;
|
|
6
|
+
while (ctx.now() < until) {
|
|
7
|
+
await ctx.sleep(device.interval * 1000);
|
|
8
|
+
const poll = await ctx.api.request("device/token", { deviceCode: device.deviceCode }, false);
|
|
9
|
+
if (poll.status === "ok") {
|
|
10
|
+
await saveCredentials(ctx.homeDir, ctx.api.baseUrl, poll.token);
|
|
11
|
+
ctx.write("Signed in.\n");
|
|
12
|
+
return 0;
|
|
13
|
+
}
|
|
14
|
+
if (poll.status === "expired")
|
|
15
|
+
break;
|
|
16
|
+
if (poll.status === "denied")
|
|
17
|
+
throw new Error("Sign-in was cancelled. Run counterfact-labs login to try again.");
|
|
18
|
+
}
|
|
19
|
+
throw new Error("The sign-in code expired. Run counterfact-labs login to try again.");
|
|
20
|
+
}
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { createInterface } from "node:readline/promises";
|
|
3
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
4
|
+
import { ApiClient, DEFAULT_URL } from "./api.js";
|
|
5
|
+
import { readCredentials } from "./credentials.js";
|
|
6
|
+
export function commandContext(options = {}) {
|
|
7
|
+
const homeDir = options.homeDir ?? homedir();
|
|
8
|
+
const baseUrl = (options.baseUrl ?? process.env.COUNTERFACT_URL ?? DEFAULT_URL).replace(/\/+$/, "");
|
|
9
|
+
const api = new ApiClient(baseUrl, () => readCredentials(homeDir, api.baseUrl), undefined, options.signal);
|
|
10
|
+
const write = options.write ?? (text => { process.stdout.write(text); });
|
|
11
|
+
return {
|
|
12
|
+
api, homeDir, write,
|
|
13
|
+
sleep: options.sleep ?? (async (ms) => { await delay(ms, undefined, { signal: options.signal }); }), now: options.now ?? Date.now,
|
|
14
|
+
ask: options.ask ?? (async (question) => {
|
|
15
|
+
if (!process.stdin.isTTY)
|
|
16
|
+
throw new Error("Use --yes to confirm a check, or --dry-run to see the files and price.");
|
|
17
|
+
write(question);
|
|
18
|
+
const input = createInterface({ input: process.stdin });
|
|
19
|
+
try {
|
|
20
|
+
return /^(?:y|yes)?$/i.test((await input.question("")).trim());
|
|
21
|
+
}
|
|
22
|
+
finally {
|
|
23
|
+
input.close();
|
|
24
|
+
}
|
|
25
|
+
}),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { mkdir, open, rename, rm } from "node:fs/promises";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
function credentialsPath(homeDir) { return join(homeDir, ".config/counterfact-labs/credentials"); }
|
|
6
|
+
function missing(error) { return error.code === "ENOENT"; }
|
|
7
|
+
export async function readCredentials(homeDir, baseUrl) {
|
|
8
|
+
try {
|
|
9
|
+
const file = await open(credentialsPath(homeDir), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
10
|
+
try {
|
|
11
|
+
const value = JSON.parse(await file.readFile("utf8"));
|
|
12
|
+
if (typeof value?.token !== "string" || !value.token || typeof value.baseUrl !== "string")
|
|
13
|
+
throw new Error("Invalid credentials");
|
|
14
|
+
return value.baseUrl === baseUrl ? value.token : null;
|
|
15
|
+
}
|
|
16
|
+
finally {
|
|
17
|
+
await file.close();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
if (missing(error))
|
|
22
|
+
return null;
|
|
23
|
+
throw new Error("Saved sign-in is unreadable. Run counterfact-labs login.");
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export async function saveCredentials(homeDir, baseUrl, token) {
|
|
27
|
+
const directory = join(homeDir, ".config/counterfact-labs");
|
|
28
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
29
|
+
const temporary = join(directory, `.credentials-${randomUUID()}`);
|
|
30
|
+
try {
|
|
31
|
+
const file = await open(temporary, "wx", 0o600);
|
|
32
|
+
try {
|
|
33
|
+
await file.writeFile(JSON.stringify({ baseUrl, token }) + "\n", "utf8");
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
await file.close();
|
|
37
|
+
}
|
|
38
|
+
await rename(temporary, credentialsPath(homeDir));
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
await rm(temporary, { force: true });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export async function deleteCredentials(homeDir) {
|
|
45
|
+
await rm(credentialsPath(homeDir), { force: true });
|
|
46
|
+
}
|
package/dist/files.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { lstat, open, readdir, realpath } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { ignored, parseIgnore } from "./gitignore.js";
|
|
5
|
+
import { SECRET_FILE_RE } from "./secret-files.js";
|
|
6
|
+
const slash = (path) => path.split(sep).join("/");
|
|
7
|
+
function safe(path) {
|
|
8
|
+
return !!path && !path.includes("\\") && !path.includes("\0") && !path.startsWith("/") && !SECRET_FILE_RE.test(path)
|
|
9
|
+
&& path.split("/").every(part => !["", ".", "..", ".git", "node_modules"].includes(part));
|
|
10
|
+
}
|
|
11
|
+
async function ignoreFile(directory, base) {
|
|
12
|
+
try {
|
|
13
|
+
const file = await open(join(directory, ".gitignore"), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
14
|
+
try {
|
|
15
|
+
return parseIgnore(await file.readFile("utf8"), base);
|
|
16
|
+
}
|
|
17
|
+
finally {
|
|
18
|
+
await file.close();
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (["ENOENT", "ELOOP"].includes(error.code ?? ""))
|
|
23
|
+
return [];
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export async function listFiles(root, repoRoot) {
|
|
28
|
+
root = await realpath(root);
|
|
29
|
+
const base = repoRoot ? await realpath(repoRoot) : root;
|
|
30
|
+
const prefix = slash(relative(base, root));
|
|
31
|
+
if (prefix.startsWith("../") || prefix === "..")
|
|
32
|
+
throw new Error("The agent folder must be inside its repository.");
|
|
33
|
+
let parentRules = [];
|
|
34
|
+
if (prefix) {
|
|
35
|
+
let directory = base;
|
|
36
|
+
parentRules = await ignoreFile(directory, "");
|
|
37
|
+
for (const part of prefix.split("/")) {
|
|
38
|
+
directory = join(directory, part);
|
|
39
|
+
if (ignored(slash(relative(base, directory)), true, parentRules))
|
|
40
|
+
return [];
|
|
41
|
+
if (directory !== root)
|
|
42
|
+
parentRules.push(...await ignoreFile(directory, slash(relative(base, directory))));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const files = [];
|
|
46
|
+
async function walk(directory, inherited) {
|
|
47
|
+
const scope = slash(relative(base, directory));
|
|
48
|
+
const rules = [...inherited, ...await ignoreFile(directory, scope)];
|
|
49
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
50
|
+
for (const entry of entries) {
|
|
51
|
+
const absolute = join(directory, entry.name);
|
|
52
|
+
const path = slash(relative(root, absolute));
|
|
53
|
+
if (!safe(path) || entry.isSymbolicLink() || ignored(slash(relative(base, absolute)), entry.isDirectory(), rules))
|
|
54
|
+
continue;
|
|
55
|
+
if (entry.isDirectory())
|
|
56
|
+
await walk(absolute, rules);
|
|
57
|
+
else if (entry.isFile()) {
|
|
58
|
+
const stat = await lstat(absolute);
|
|
59
|
+
if (stat.isFile())
|
|
60
|
+
files.push({ path, size: stat.size });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
await walk(root, parentRules);
|
|
65
|
+
return files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
66
|
+
}
|
|
67
|
+
export async function readUpload(root, files, wanted) {
|
|
68
|
+
root = await realpath(root);
|
|
69
|
+
const sizes = new Map(files.map(file => [file.path, file.size]));
|
|
70
|
+
const uploaded = Object.create(null);
|
|
71
|
+
for (const path of wanted) {
|
|
72
|
+
if (!safe(path) || !sizes.has(path))
|
|
73
|
+
throw new Error("Counterfact requested a file outside the local file list.");
|
|
74
|
+
const absolute = resolve(root, path);
|
|
75
|
+
// A directory replaced by a symlink after listing must not expose files outside the folder.
|
|
76
|
+
if (await realpath(dirname(absolute)) !== dirname(absolute))
|
|
77
|
+
throw new Error("A file changed since listing. Run the check again.");
|
|
78
|
+
const file = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
79
|
+
try {
|
|
80
|
+
if (!(await file.stat()).isFile())
|
|
81
|
+
throw new Error("Only regular files can be uploaded.");
|
|
82
|
+
const bytes = await file.readFile();
|
|
83
|
+
let text;
|
|
84
|
+
try {
|
|
85
|
+
text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
throw new Error(`${path} is not UTF-8 text and cannot be uploaded.`);
|
|
89
|
+
}
|
|
90
|
+
if (Buffer.byteLength(text, "utf8") !== sizes.get(path))
|
|
91
|
+
throw new Error("A file changed since listing. Run the check again.");
|
|
92
|
+
uploaded[path] = text;
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
await file.close();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return { ...uploaded };
|
|
99
|
+
}
|
package/dist/git.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
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;
|
|
7
|
+
try {
|
|
8
|
+
repoRoot = (await git(["rev-parse", "--show-toplevel"])).trim();
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return { remoteUrl: null, relPath: "", folderName };
|
|
12
|
+
}
|
|
13
|
+
let remoteUrl;
|
|
14
|
+
try {
|
|
15
|
+
remoteUrl = (await git(["config", "--get", "remote.origin.url"])).trim().replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/]*@/i, "$1") || null;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
remoteUrl = null;
|
|
19
|
+
}
|
|
20
|
+
return { repoRoot, remoteUrl, relPath: relative(repoRoot, root).split(sep).join("/"), folderName };
|
|
21
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2
|
+
function glob(pattern) {
|
|
3
|
+
let result = "";
|
|
4
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
5
|
+
const ch = pattern[i];
|
|
6
|
+
if (ch === "\\" && i + 1 < pattern.length)
|
|
7
|
+
result += escape(pattern[++i]);
|
|
8
|
+
else if (ch === "*" && pattern[i + 1] === "*") {
|
|
9
|
+
i++;
|
|
10
|
+
if (pattern[i + 1] === "/") {
|
|
11
|
+
result += "(?:.*/)?";
|
|
12
|
+
i++;
|
|
13
|
+
}
|
|
14
|
+
else
|
|
15
|
+
result += ".*";
|
|
16
|
+
}
|
|
17
|
+
else if (ch === "*")
|
|
18
|
+
result += "[^/]*";
|
|
19
|
+
else if (ch === "?")
|
|
20
|
+
result += "[^/]";
|
|
21
|
+
else if (ch === "[") {
|
|
22
|
+
const end = pattern.indexOf("]", i + 1);
|
|
23
|
+
if (end < 0)
|
|
24
|
+
result += "\\[";
|
|
25
|
+
else {
|
|
26
|
+
const chars = pattern.slice(i + 1, end).replace(/^!/, "^");
|
|
27
|
+
result += `[${chars}]`;
|
|
28
|
+
i = end;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
else
|
|
32
|
+
result += escape(ch);
|
|
33
|
+
}
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
export function parseIgnore(text, base) {
|
|
37
|
+
const rules = [];
|
|
38
|
+
for (let line of text.split(/\r?\n/)) {
|
|
39
|
+
line = line.replace(/(?<!\\) +$/, "");
|
|
40
|
+
if (!line || line.startsWith("#"))
|
|
41
|
+
continue;
|
|
42
|
+
const negate = line.startsWith("!");
|
|
43
|
+
if (negate)
|
|
44
|
+
line = line.slice(1);
|
|
45
|
+
const directory = line.endsWith("/");
|
|
46
|
+
if (directory)
|
|
47
|
+
line = line.slice(0, -1);
|
|
48
|
+
const anchored = line.startsWith("/") || line.includes("/");
|
|
49
|
+
if (line.startsWith("/"))
|
|
50
|
+
line = line.slice(1);
|
|
51
|
+
if (!line)
|
|
52
|
+
continue;
|
|
53
|
+
try {
|
|
54
|
+
rules.push({ base, directory, negate, regex: new RegExp(`${anchored ? "^" : "(?:^|/)"}${glob(line)}$`) });
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
throw new Error("A .gitignore pattern could not be read.");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return rules;
|
|
61
|
+
}
|
|
62
|
+
export function ignored(path, directory, rules) {
|
|
63
|
+
let excluded = false;
|
|
64
|
+
for (const rule of rules) {
|
|
65
|
+
const local = rule.base ? path.startsWith(`${rule.base}/`) ? path.slice(rule.base.length + 1) : null : path;
|
|
66
|
+
if (local !== null && (!rule.directory || directory) && rule.regex.test(local))
|
|
67
|
+
excluded = !rule.negate;
|
|
68
|
+
}
|
|
69
|
+
return excluded;
|
|
70
|
+
}
|
package/dist/main.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import { realpath } from "node:fs/promises";
|
|
4
|
+
import { errorCode, errorText } from "./api.js";
|
|
5
|
+
import { commandContext } from "./context.js";
|
|
6
|
+
import { check } from "./commands/check.js";
|
|
7
|
+
import { finding } from "./commands/finding.js";
|
|
8
|
+
import { balance } from "./commands/balance.js";
|
|
9
|
+
import { login } from "./commands/login.js";
|
|
10
|
+
import { logout } from "./commands/logout.js";
|
|
11
|
+
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
|
+
export async function run(args, options = {}) {
|
|
14
|
+
const write = options.write ?? (text => { process.stdout.write(text); });
|
|
15
|
+
try {
|
|
16
|
+
const [command, ...rest] = args;
|
|
17
|
+
if (["--help", "-h", "help"].includes(command) || !command) {
|
|
18
|
+
write(usage);
|
|
19
|
+
return 0;
|
|
20
|
+
}
|
|
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)))
|
|
25
|
+
throw new Error(usage.trimEnd());
|
|
26
|
+
return await check(paths[0] ?? ".", { yes: flags.includes("--yes"), dryRun: flags.includes("--dry-run") }, commandContext(options));
|
|
27
|
+
}
|
|
28
|
+
if (command === "finding" && rest.length === 1 && !rest[0].startsWith("-"))
|
|
29
|
+
return await finding(rest[0], commandContext(options));
|
|
30
|
+
if (rest.length)
|
|
31
|
+
throw new Error(usage.trimEnd());
|
|
32
|
+
if (command === "balance")
|
|
33
|
+
return await balance(commandContext(options));
|
|
34
|
+
if (command === "login")
|
|
35
|
+
return await login(commandContext(options));
|
|
36
|
+
if (command === "logout")
|
|
37
|
+
return await logout(commandContext(options));
|
|
38
|
+
if (command === "mcp") {
|
|
39
|
+
await serveMcp(process.stdin, process.stdout, options);
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
throw new Error(usage.trimEnd());
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
write(errorText(error) + "\n");
|
|
46
|
+
return errorCode(error);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const invokedPath = process.argv[1] ? await realpath(process.argv[1]).catch(() => "") : "";
|
|
50
|
+
if (invokedPath && import.meta.url === pathToFileURL(invokedPath).href) {
|
|
51
|
+
process.exitCode = await run(process.argv.slice(2));
|
|
52
|
+
}
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { createInterface } from "node:readline";
|
|
2
|
+
import { once } from "node:events";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { errorCode, errorText } from "./api.js";
|
|
5
|
+
import { commandContext } from "./context.js";
|
|
6
|
+
import { confirmPrepared, prepareCheck } from "./commands/check.js";
|
|
7
|
+
import { finding } from "./commands/finding.js";
|
|
8
|
+
import { balance } from "./commands/balance.js";
|
|
9
|
+
const tools = [
|
|
10
|
+
{
|
|
11
|
+
name: "check_agent",
|
|
12
|
+
description: "Preview the files and price of an agent check. Show the user the price and get their agreement "
|
|
13
|
+
+ "before calling with confirm: true. A confirmation uses the most recent preview for that path in this session. "
|
|
14
|
+
+ "Suggest a check after edits; wait for the user to ask for it. If the tool call is cancelled or times out, "
|
|
15
|
+
+ "a confirmed check keeps running on the server and its result will be in the console's Checks page.",
|
|
16
|
+
inputSchema: { type: "object", properties: { path: { type: "string", description: "The agent folder on this computer." }, confirm: { type: "boolean", default: false } }, required: ["path"], additionalProperties: false },
|
|
17
|
+
},
|
|
18
|
+
{ 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 } },
|
|
19
|
+
{ name: "get_balance", description: "Get the balance and top-up link.", inputSchema: { type: "object", properties: {}, additionalProperties: false } },
|
|
20
|
+
];
|
|
21
|
+
class ProtocolError extends Error {
|
|
22
|
+
code;
|
|
23
|
+
constructor(code, message) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.code = code;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function object(value) { return value !== null && typeof value === "object" && !Array.isArray(value); }
|
|
29
|
+
export async function serveMcp(input, output, options = {}) {
|
|
30
|
+
const previews = new Map();
|
|
31
|
+
const requests = new Map();
|
|
32
|
+
let queue = Promise.resolve();
|
|
33
|
+
const lines = createInterface({ input, crlfDelay: Infinity });
|
|
34
|
+
async function toolCall(params, signal) {
|
|
35
|
+
if (!object(params) || typeof params.name !== "string")
|
|
36
|
+
throw new ProtocolError(-32602, "Choose a tool and its arguments.");
|
|
37
|
+
const args = params.arguments ?? {};
|
|
38
|
+
if (!object(args))
|
|
39
|
+
throw new ProtocolError(-32602, "Tool arguments must be an object.");
|
|
40
|
+
const name = params.name;
|
|
41
|
+
const allowed = name === "check_agent" ? ["path", "confirm"] : name === "get_finding" ? ["id"] : [];
|
|
42
|
+
if (!tools.some(tool => tool.name === name) || Object.keys(args).some(key => !allowed.includes(key)))
|
|
43
|
+
throw new ProtocolError(-32602, "Unknown tool or argument.");
|
|
44
|
+
if (name === "check_agent" && (typeof args.path !== "string" || !args.path.trim() || (args.confirm !== undefined && typeof args.confirm !== "boolean")))
|
|
45
|
+
throw new ProtocolError(-32602, "Provide a path and an optional boolean confirmation.");
|
|
46
|
+
if (name === "get_finding" && (typeof args.id !== "string" || !args.id.trim()))
|
|
47
|
+
throw new ProtocolError(-32602, "Provide a finding id.");
|
|
48
|
+
let text = "", exitCode = 0;
|
|
49
|
+
let findingIds;
|
|
50
|
+
try {
|
|
51
|
+
const ctx = commandContext({ ...options, signal, write: chunk => {
|
|
52
|
+
// Progress is transient terminal text; only the final lines belong in a tool response.
|
|
53
|
+
if (!chunk.startsWith("\r"))
|
|
54
|
+
text += chunk;
|
|
55
|
+
} });
|
|
56
|
+
if (name === "get_finding")
|
|
57
|
+
await finding(args.id, ctx);
|
|
58
|
+
else if (name === "get_balance")
|
|
59
|
+
await balance(ctx);
|
|
60
|
+
else {
|
|
61
|
+
const path = resolve(args.path);
|
|
62
|
+
if (args.confirm === true) {
|
|
63
|
+
const prepared = previews.get(path);
|
|
64
|
+
previews.delete(path);
|
|
65
|
+
if (!prepared)
|
|
66
|
+
throw new Error("Get a preview first. Show the user the price and get their agreement before confirming.");
|
|
67
|
+
findingIds = (await confirmPrepared(prepared, ctx)).findingIds;
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
previews.delete(path);
|
|
71
|
+
previews.set(path, await prepareCheck(args.path, ctx));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
text += errorText(error) + "\n";
|
|
77
|
+
exitCode = errorCode(error);
|
|
78
|
+
}
|
|
79
|
+
// Some clients (Claude Code among them) give the model only the structured result when one is present, so it
|
|
80
|
+
// repeats the text: without it the model sees no files, price or results.
|
|
81
|
+
return { content: [{ type: "text", text }], isError: exitCode !== 0,
|
|
82
|
+
structuredContent: { text, exitCode, ...(findingIds ? { findingIds } : {}) } };
|
|
83
|
+
}
|
|
84
|
+
async function respond(message, id, signal) {
|
|
85
|
+
if (signal.aborted)
|
|
86
|
+
return;
|
|
87
|
+
let reply;
|
|
88
|
+
try {
|
|
89
|
+
let result;
|
|
90
|
+
if (message.method === "initialize") {
|
|
91
|
+
const requested = object(message.params) ? message.params.protocolVersion : undefined;
|
|
92
|
+
result = { protocolVersion: ["2024-11-05", "2025-03-26", "2025-06-18"].includes(requested) ? requested : "2025-06-18",
|
|
93
|
+
capabilities: { tools: {} }, serverInfo: { name: "counterfact-labs", version: "0.1.1" } };
|
|
94
|
+
}
|
|
95
|
+
else if (message.method === "ping")
|
|
96
|
+
result = {};
|
|
97
|
+
else if (message.method === "tools/list")
|
|
98
|
+
result = { tools };
|
|
99
|
+
else if (message.method === "tools/call")
|
|
100
|
+
result = await toolCall(message.params, signal);
|
|
101
|
+
else
|
|
102
|
+
throw new ProtocolError(-32601, "Method not found.");
|
|
103
|
+
reply = { jsonrpc: "2.0", id, result };
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
reply = errorReply(id, error);
|
|
107
|
+
}
|
|
108
|
+
// The caller stopped waiting; a late result must not look like a completed call.
|
|
109
|
+
if (!signal.aborted && !output.write(JSON.stringify(reply) + "\n"))
|
|
110
|
+
await once(output, "drain");
|
|
111
|
+
}
|
|
112
|
+
for await (const line of lines) {
|
|
113
|
+
if (!line.trim())
|
|
114
|
+
continue;
|
|
115
|
+
let id = null;
|
|
116
|
+
let reply;
|
|
117
|
+
try {
|
|
118
|
+
let message;
|
|
119
|
+
try {
|
|
120
|
+
message = JSON.parse(line);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
throw new ProtocolError(-32700, "Invalid JSON.");
|
|
124
|
+
}
|
|
125
|
+
if (!object(message) || message.jsonrpc !== "2.0" || typeof message.method !== "string"
|
|
126
|
+
|| (message.id !== undefined && typeof message.id !== "string" && typeof message.id !== "number" && message.id !== null))
|
|
127
|
+
throw new ProtocolError(-32600, "Invalid request.");
|
|
128
|
+
if (message.id === undefined) {
|
|
129
|
+
if (message.method === "notifications/cancelled" && object(message.params)) {
|
|
130
|
+
const requestId = message.params.requestId;
|
|
131
|
+
if (typeof requestId === "string" || typeof requestId === "number")
|
|
132
|
+
requests.get(requestId)?.abort();
|
|
133
|
+
}
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
id = message.id;
|
|
137
|
+
const controller = new AbortController();
|
|
138
|
+
requests.set(id, controller);
|
|
139
|
+
// Keep preview and confirm in order while still reading cancellation notifications.
|
|
140
|
+
const request = message;
|
|
141
|
+
queue = queue.then(() => respond(request, id, controller.signal)).finally(() => { requests.delete(id); });
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
reply = errorReply(id, error);
|
|
146
|
+
}
|
|
147
|
+
if (!output.write(JSON.stringify(reply) + "\n"))
|
|
148
|
+
await once(output, "drain");
|
|
149
|
+
}
|
|
150
|
+
await queue;
|
|
151
|
+
}
|
|
152
|
+
function errorReply(id, error) {
|
|
153
|
+
return { jsonrpc: "2.0", id, error: {
|
|
154
|
+
code: error instanceof ProtocolError ? error.code : -32603,
|
|
155
|
+
message: error instanceof ProtocolError ? error.message : "The request could not be completed.",
|
|
156
|
+
} };
|
|
157
|
+
}
|
package/dist/output.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
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 fileText(path, files) {
|
|
8
|
+
const bytes = files.reduce((sum, file) => sum + file.size, 0);
|
|
9
|
+
const names = files.slice(0, 3).map(file => file.path).join(", ");
|
|
10
|
+
const more = files.length > 3 ? `, ... ${files.length - 3} more` : "";
|
|
11
|
+
return `Checking the agent in ${path} (${files.length} files, ${Math.ceil(bytes / 1000)} KB):\n ${names}${more}\n`;
|
|
12
|
+
}
|
|
13
|
+
export function quoteText(quote) {
|
|
14
|
+
if (quote.band === null)
|
|
15
|
+
return quote.message + "\n";
|
|
16
|
+
if (quote.free)
|
|
17
|
+
return "This check is free (your first one).\n";
|
|
18
|
+
return `${quote.message ? quote.message + "\n" : ""}This check costs ${money(quote.priceMicro)} (${quote.band} agent).${quote.balanceMicro === null ? "" : ` Balance: ${money(quote.balanceMicro)}.`}\n`;
|
|
19
|
+
}
|
|
20
|
+
export function tasteText(taste, findingIds) {
|
|
21
|
+
return [taste.headline, ...taste.lines.map(line => ` ${line}`),
|
|
22
|
+
...(findingIds.length ? [`Finding ids: ${findingIds.join(", ")}`] : []), `Full results: ${taste.url}`, ""].join("\n");
|
|
23
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
// Copied from src/lib/conventions/file-patterns.ts by the build.
|
|
2
|
+
export const SECRET_FILE_RE = /(^|\/)(\.env(?:\.[^/]*)?|\.(?:npmrc|pypirc|netrc)|(?:credentials|secrets)\.(?:json|ya?ml)|secrets\.toml|[^/]+-credentials\.json|service-account[^/]*\.json|id_(?:rsa|dsa|ecdsa|ed25519)|[^/]+\.(?:pem|key|p12|pfx))$/i;
|
package/package.json
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@counterfact-labs/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"license": "UNLICENSED",
|
|
5
|
+
"description": "Check an agent from your terminal or coding agent",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": { "counterfact-labs": "dist/main.js" },
|
|
8
|
+
"engines": { "node": ">=20" },
|
|
9
|
+
"files": ["dist", "!dist/**/*.test.js", "!dist/test-helpers.js"],
|
|
10
|
+
"scripts": {
|
|
11
|
+
"prebuild": "node scripts/copy-api-types.mjs",
|
|
12
|
+
"build": "node scripts/build.mjs",
|
|
13
|
+
"test": "npm run build && node --test dist/*.test.js dist/commands/*.test.js",
|
|
14
|
+
"prepack": "npm run build"
|
|
15
|
+
}
|
|
16
|
+
}
|