cortad 0.2.2 → 0.3.0-rc.2
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/README.md +11 -2
- package/lib/cli.mjs +85 -38
- package/lib/home.mjs +9 -2
- package/lib/mcp.mjs +29 -12
- package/lib/register.mjs +64 -30
- package/lib/spec.mjs +20 -0
- package/lib/stick.mjs +99 -0
- package/lib/text.mjs +279 -0
- package/lib/verbs.mjs +135 -100
- package/local.mjs +27 -14
- package/package.json +4 -1
- package/skill/SKILL.md +71 -31
- package/skill/references/results.md +152 -33
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ Runs Cortad's test conversations against the AI app on your machine, and gives y
|
|
|
6
6
|
npx cortad <code>
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
Run it in your app's folder with the code from cortad.com. It starts your app, connects it to Cortad, and adds Cortad to Claude Code, Codex and
|
|
9
|
+
Run it in your app's folder with the code from cortad.com. It starts your app, connects it to Cortad, and adds Cortad to Claude Code, Codex, Cursor and Copilot on this machine. The first run starts on its own. Ctrl-C disconnects.
|
|
10
10
|
|
|
11
11
|
## Your coding agent
|
|
12
12
|
|
|
@@ -21,6 +21,15 @@ npx cortad verify <findingId>
|
|
|
21
21
|
|
|
22
22
|
A run needs your app up. If nothing on this machine is holding it, the command starts it with the key in `~/.cortad` and stops it ten minutes after the last run.
|
|
23
23
|
|
|
24
|
+
To make checking with Cortad part of the repository:
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
npx cortad stick one line in AGENTS.md, CLAUDE.md, .cursor/rules and .github/copilot-instructions.md, and an after-edit hook for Claude Code and Codex
|
|
28
|
+
npx cortad unstick takes them out
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Both print every file they changed. Neither touches git.
|
|
32
|
+
|
|
24
33
|
## Flags
|
|
25
34
|
|
|
26
35
|
```
|
|
@@ -32,7 +41,7 @@ A run needs your app up. If nothing on this machine is holding it, the command s
|
|
|
32
41
|
|
|
33
42
|
## Files it creates
|
|
34
43
|
|
|
35
|
-
- `~/.cortad/<project>/` the key, the last tree digest, which process holds your app up
|
|
44
|
+
- `~/.cortad/<project>/` the key, the last tree digest, which process holds your app up, the run it asked for last
|
|
36
45
|
- `~/.cortad/identity.key` the seed for the session's test accounts
|
|
37
46
|
- `$TMPDIR/cortad-<pid>/` removed on exit
|
|
38
47
|
|
package/lib/cli.mjs
CHANGED
|
@@ -1,49 +1,77 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { closeSync, openSync } from "node:fs";
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
-
import { homeOf, projectOf, readRunner, readToken, writeRunner } from "./home.mjs";
|
|
5
|
+
import { homeOf, projectOf, readPending, readRunner, readToken, writePending, writeRunner } from "./home.mjs";
|
|
6
6
|
import { serveMcp } from "./mcp.mjs";
|
|
7
|
-
import {
|
|
7
|
+
import { cliSpec, VERSION } from "./spec.mjs";
|
|
8
|
+
import { hookOutput, stick, unstick } from "./stick.mjs";
|
|
9
|
+
import { makeVerbs, READY_MS } from "./verbs.mjs";
|
|
8
10
|
|
|
9
|
-
// `npx cortad mcp
|
|
10
|
-
// connect.
|
|
11
|
-
// nothing on this machine is holding it, the runner (local.mjs with the stored key) is started
|
|
11
|
+
// `npx cortad mcp`, `npx cortad <verb>` and `npx cortad stick`: what a coding agent uses after the
|
|
12
|
+
// first connect. None of them uploads or edits anything by itself. When a run needs the app up and
|
|
13
|
+
// nothing on this machine is holding it, the runner (local.mjs with the stored key) is started, and
|
|
14
|
+
// a second detached process posts the run once the app answers, so `run` returns at once from
|
|
15
|
+
// either face and the shell face exits after printing.
|
|
12
16
|
const LOCAL = join(dirname(fileURLToPath(import.meta.url)), "..", "local.mjs");
|
|
13
|
-
const VERSION = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")).version;
|
|
14
17
|
const OURS = ["cortad.com", "brainsless.com", "brainsless-frontend.pages.dev"];
|
|
18
|
+
const WAITER = "run-when-up";
|
|
15
19
|
export const VERBS = ["status", "run", "run_status", "findings", "verify", "dispute", "field_connect", "field"];
|
|
16
|
-
export const COMMANDS = new Set(["mcp", ...VERBS, "run-status", "field-connect"]);
|
|
20
|
+
export const COMMANDS = new Set(["mcp", ...VERBS, "run-status", "field-connect", "stick", "unstick", WAITER]);
|
|
17
21
|
|
|
18
22
|
export async function main(argv, { root = process.cwd(), env = process.env, stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
23
|
+
const [command, ...rest] = argv;
|
|
24
|
+
if (command === "stick" || command === "unstick") {
|
|
25
|
+
stdout.write(`${(command === "stick" ? stick : unstick)(root, { spec: cliSpec({ env }) }).join("\n")}\n`);
|
|
26
|
+
return 0;
|
|
27
|
+
}
|
|
19
28
|
const origin = new URL(env.CORTAD_ORIGIN || "https://cortad.com");
|
|
20
29
|
const trusted = (origin.protocol === "https:" && OURS.some((host) => origin.hostname === host || origin.hostname.endsWith(`.${host}`)))
|
|
21
30
|
|| origin.hostname === "localhost" || origin.hostname === "127.0.0.1";
|
|
22
31
|
if (!trusted) { stderr.write(`cortad refusing: ${origin.host} is not Cortad.\n`); return 1; }
|
|
23
|
-
const api = `${origin.origin}/api`;
|
|
24
32
|
const project = projectOf(root);
|
|
25
|
-
const [command, ...rest] = argv;
|
|
26
33
|
const mcp = command === "mcp";
|
|
27
34
|
const children = [];
|
|
35
|
+
const pending = { read: () => readPending(project), write: (p) => writePending(project, p) };
|
|
28
36
|
// The key is read at every call: the MCP process outlives a connect that writes it after the process started.
|
|
29
|
-
const verbs = makeVerbs({
|
|
37
|
+
const verbs = makeVerbs({
|
|
38
|
+
api: `${origin.origin}/api`,
|
|
39
|
+
root,
|
|
40
|
+
token: () => readToken(project),
|
|
41
|
+
pending,
|
|
42
|
+
startApp: (args, kind) => startApp({ root, project, env, keep: mcp, children, args, kind }),
|
|
43
|
+
});
|
|
30
44
|
|
|
45
|
+
if (command === WAITER) {
|
|
46
|
+
await runWhenUp({ verbs, project, env, pending, request: requestOf(rest[0]) });
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
31
49
|
if (mcp) {
|
|
32
50
|
const code = await serveMcp({ verbs, version: VERSION, log: (line) => stderr.write(`${line}\n`) });
|
|
33
51
|
for (const child of children) try { child.kill("SIGTERM"); } catch { /* gone */ }
|
|
34
52
|
return code;
|
|
35
53
|
}
|
|
36
|
-
|
|
37
|
-
|
|
54
|
+
// The hook face: one line or nothing, never an error on every edit in a folder that is not connected.
|
|
55
|
+
if (command === "status" && rest.includes("--changed")) {
|
|
56
|
+
const { text } = await verbs.changed();
|
|
57
|
+
if (text) stdout.write(`${rest.includes("--hook") ? hookOutput(text) : text}\n`);
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
const out = await verbs[command.replace(/-/g, "_")](argsOf(command, rest));
|
|
38
61
|
stdout.write(`${out.text}\n`);
|
|
39
62
|
return out.isError ? 1 : 0;
|
|
40
63
|
}
|
|
41
64
|
|
|
42
|
-
// Positional arguments for the shell face: `verify f1`, `run_status <jobId>`, `
|
|
65
|
+
// Positional arguments for the shell face: `verify f1`, `run_status <jobId>`, `findings 2`,
|
|
66
|
+
// `dispute f1 "why"`. In findings a bare number is a page and anything else a run id.
|
|
43
67
|
function argsOf(command, rest) {
|
|
44
68
|
switch (command.replace(/-/g, "_")) {
|
|
45
|
-
case "run_status": return { jobId: rest[0] };
|
|
46
|
-
case "findings":
|
|
69
|
+
case "run_status": return rest[0] ? { jobId: rest[0] } : {};
|
|
70
|
+
case "findings": {
|
|
71
|
+
const page = rest.find((w) => /^\d+$/.test(w));
|
|
72
|
+
const jobId = rest.find((w) => w !== "--page" && !/^\d+$/.test(w));
|
|
73
|
+
return { ...(jobId ? { jobId } : {}), ...(page ? { page: Number(page) } : {}) };
|
|
74
|
+
}
|
|
47
75
|
case "verify": return { findingId: rest[0], ...(rest[1] ? { jobId: rest[1] } : {}) };
|
|
48
76
|
case "dispute": return { findingId: rest[0], why: rest.slice(1).join(" ") };
|
|
49
77
|
case "field": return rest[0] ? { days: rest[0] } : {};
|
|
@@ -51,37 +79,56 @@ function argsOf(command, rest) {
|
|
|
51
79
|
}
|
|
52
80
|
}
|
|
53
81
|
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
|
|
82
|
+
// What the waiter was asked for, from its own command line: a run, or a verify of one finding.
|
|
83
|
+
function requestOf(raw) {
|
|
84
|
+
let value = {};
|
|
85
|
+
try { value = JSON.parse(raw ?? "{}"); } catch { /* a run */ }
|
|
86
|
+
const pick = (v) => (typeof v === "string" && v.length <= 200 ? v : undefined);
|
|
87
|
+
const findingId = pick(value.args?.findingId);
|
|
88
|
+
const jobId = pick(value.args?.jobId);
|
|
89
|
+
return findingId ? { kind: "verify", args: { findingId, ...(jobId ? { jobId } : {}) } } : { kind: "run", args: {} };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Returns at once. The runner holds the app up: from the MCP it lives as long as the MCP does;
|
|
93
|
+
// from a shell command it stays until the runs stop for a while (--until-idle). The waiter is
|
|
94
|
+
// detached either way and ends once the run is posted or the app did not come up.
|
|
95
|
+
export function startApp({ root, project, env, keep, children, args, kind, spawnImpl = spawn }) {
|
|
96
|
+
const log = openSync(join(homeOf(project), "runner.log"), "a");
|
|
61
97
|
if (!readRunner(project)) {
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
cwd: root, env, stdio: ["ignore", log, log], detached: !keep,
|
|
65
|
-
});
|
|
66
|
-
if (!keep) child.unref(); else children.push(child);
|
|
98
|
+
const child = spawnImpl(process.execPath, [LOCAL, "--token", ...(keep ? [] : ["--until-idle"])], { cwd: root, env, stdio: ["ignore", log, log], detached: !keep });
|
|
99
|
+
if (keep) children.push(child); else child.unref();
|
|
67
100
|
writeRunner(project, { pid: child.pid, startedAt: new Date().toISOString(), by: keep ? "mcp" : "cli" });
|
|
68
|
-
say("starting your app for the run");
|
|
69
101
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
102
|
+
spawnImpl(process.execPath, [LOCAL, WAITER, JSON.stringify({ kind, args })], { cwd: root, env, stdio: ["ignore", log, log], detached: true }).unref();
|
|
103
|
+
closeSync(log);
|
|
104
|
+
return { ok: true };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// The waiter: posts the run once the app answers and leaves the outcome in pending.json.
|
|
108
|
+
export async function runWhenUp({ verbs, project, env, pending, request, poll = appOf, now = Date.now, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), readyMs = READY_MS }) {
|
|
109
|
+
const log = join(homeOf(project), "runner.log");
|
|
110
|
+
const fail = (text) => pending.write({ ...pending.read(), error: text });
|
|
111
|
+
const until = now() + readyMs;
|
|
112
|
+
let said = "";
|
|
113
|
+
while (now() < until) {
|
|
114
|
+
await sleep(3000);
|
|
115
|
+
const app = await poll(env, project);
|
|
116
|
+
if (app?.state === "ready-to-test") {
|
|
117
|
+
const out = await verbs.post(request.args, request.kind);
|
|
118
|
+
if (!out.data?.jobId) fail(out.text);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (app?.said) said = ` ${app.said}`;
|
|
122
|
+
if (!readRunner(project)) return fail(`The process holding your app up ended before the app answered.${said} Its output is in ${log}.\nNothing ran.`);
|
|
76
123
|
}
|
|
77
|
-
|
|
124
|
+
fail(`Your app did not answer within ${Math.round(readyMs / 60_000)} minutes.${said} Its output is in ${log}.\nNothing ran.`);
|
|
78
125
|
}
|
|
79
126
|
|
|
80
|
-
async function
|
|
127
|
+
async function appOf(env, project) {
|
|
81
128
|
const token = readToken(project);
|
|
82
129
|
if (!token) return null;
|
|
83
130
|
try {
|
|
84
131
|
const res = await fetch(`${new URL(env.CORTAD_ORIGIN || "https://cortad.com").origin}/api/mcp/status`, { headers: { authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(15_000) });
|
|
85
|
-
return res.ok ? (await res.json()).app
|
|
132
|
+
return res.ok ? (await res.json()).app ?? null : null;
|
|
86
133
|
} catch { return null; }
|
|
87
134
|
}
|
package/lib/home.mjs
CHANGED
|
@@ -4,8 +4,9 @@ import { homedir } from "node:os";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
|
|
6
6
|
// What this machine keeps between sessions, per project, under ~/.cortad/<project>/: the key the
|
|
7
|
-
// first connect left behind, the digest of the tree last uploaded,
|
|
8
|
-
//
|
|
7
|
+
// first connect left behind, the digest of the tree last uploaded, which process is holding the app
|
|
8
|
+
// up, and the run this machine asked for last. The folder's path never leaves the machine; the
|
|
9
|
+
// project is a hash of it.
|
|
9
10
|
export const projectOf = (root) => createHash("sha256").update(realpathSync(root)).digest("hex").slice(0, 16);
|
|
10
11
|
export const homeOf = (project, base = join(homedir(), ".cortad")) => join(base, project);
|
|
11
12
|
|
|
@@ -31,4 +32,10 @@ export function readRunner(project, base) {
|
|
|
31
32
|
export const writeRunner = (project, runner, base) => write(homeOf(project, base), "runner.json", JSON.stringify(runner));
|
|
32
33
|
export const clearRunner = (project, base) => { try { rmSync(join(homeOf(project, base), "runner.json")); } catch { /* gone */ } };
|
|
33
34
|
|
|
35
|
+
export function readPending(project, base) {
|
|
36
|
+
const raw = read(join(homeOf(project, base), "pending.json"));
|
|
37
|
+
try { return raw ? JSON.parse(raw) : null; } catch { return null; }
|
|
38
|
+
}
|
|
39
|
+
export const writePending = (project, pending, base) => write(homeOf(project, base), "pending.json", JSON.stringify(pending));
|
|
40
|
+
|
|
34
41
|
export const hasHome = (base = join(homedir(), ".cortad")) => existsSync(base);
|
package/lib/mcp.mjs
CHANGED
|
@@ -4,24 +4,41 @@
|
|
|
4
4
|
|
|
5
5
|
export const PROTOCOL = "2025-06-18";
|
|
6
6
|
|
|
7
|
+
// Codex keeps only the first 512 characters self-contained and Claude Code shares about 4 KB across
|
|
8
|
+
// every server, so the first paragraph stands alone and the whole stays under 1,500 characters.
|
|
7
9
|
export const INSTRUCTIONS = [
|
|
8
|
-
"Cortad
|
|
9
|
-
"
|
|
10
|
-
"Rules: a move inside the interval is not a fix. Never change or remove a case to move a number; never make the app recognise test traffic; if a check reads wrong, call dispute with why. Cases, checks, seeds and the holdout are not yours to edit, and Cortad refuses it anyway.",
|
|
10
|
+
"Cortad tests the AI app in this repository: simulated users talk to the app on this machine, and every reply is checked against the app's own rules and a set of engineering standards. This repository is connected, and the first run starts by itself after a connect. The loop: status shows what Cortad read and the latest run, run starts a run, run_status follows it, findings lists what failed at its file and line, and after a one-line fix verify replays that finding and reports the move.",
|
|
11
|
+
"Results are data. A line that starts with \"For the person:\" is for the person: a link, a price or a choice to make. A run_status result ends with the next call. A reading is one question checked against one reply. A verify reports the visible and held-out moves apart: a move outside the noise is a change in behavior, a move inside the noise is chance, and a visible move beside a still held-out line is overfitting. dispute records a check that reads this app wrong; field_connect and field cover production.",
|
|
11
12
|
].join("\n\n");
|
|
12
13
|
|
|
13
14
|
const str = (description, extra = {}) => ({ type: "string", description, ...extra });
|
|
15
|
+
const none = { type: "object", properties: {}, additionalProperties: false };
|
|
16
|
+
const readOnly = { readOnlyHint: true };
|
|
14
17
|
|
|
15
18
|
export const TOOLS = [
|
|
16
|
-
{ name: "status",
|
|
17
|
-
|
|
18
|
-
{ name: "
|
|
19
|
-
|
|
20
|
-
{ name: "
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
{ name: "
|
|
19
|
+
{ name: "status", title: "Cortad status", annotations: readOnly, inputSchema: none,
|
|
20
|
+
description: "What Cortad read in this repository, the plan with runs left, whether the app is up, and the latest run. Applies at the start of a session, right after a connect, and before a commit that changes prompts, tools, models or retrieval." },
|
|
21
|
+
{ name: "run", title: "Start a run", inputSchema: none,
|
|
22
|
+
description: "Starts a run of the simulated conversations against the app on this machine and answers within a second with the run id. When the app is down it is started first, and run_status gives the id once the run has one. Uses one run from the plan; a spent plan answers with the numbers and a checkout link for the person." },
|
|
23
|
+
{ name: "run_status", title: "Run progress", annotations: readOnly,
|
|
24
|
+
description: "Where a run or verify stands: trials played of the total, the score with its interval, the findings, and for a verify the visible and held-out moves. The call holds up to 45 seconds until the count moves, and ends with the next call. With no jobId it follows the run this machine started last, or the latest run.",
|
|
25
|
+
inputSchema: { type: "object", properties: { jobId: str("A run or verify id, or the word pending. Omit for the run this machine started last.") }, additionalProperties: false } },
|
|
26
|
+
{ name: "findings", title: "Findings", annotations: readOnly,
|
|
27
|
+
description: "The failures of a run, worst first and grouped by file and line: the question, the replies it held in with the interval, quotes, and the trials a verify replays. Applies once a run has finished. A long list comes in pages.",
|
|
28
|
+
inputSchema: { type: "object", properties: { jobId: str("A run id. Omit for the latest."), page: { type: "integer", minimum: 1, description: "The page to read. Default 1." } }, additionalProperties: false } },
|
|
29
|
+
{ name: "verify", title: "Verify a fix",
|
|
30
|
+
description: "Replays one finding's trials with the same seeds after a change and reports the move, visible and held-out apart. Answers within a second; run_status follows it. Uses verify trials from the plan, not a run.",
|
|
31
|
+
inputSchema: { type: "object", properties: { findingId: str("The finding id from findings."), jobId: str("The run the finding came from. Omit for the latest.") }, required: ["findingId"], additionalProperties: false } },
|
|
32
|
+
{ name: "dispute", title: "Dispute a check",
|
|
33
|
+
description: "Records that a finding's check reads this app wrong, with the reason and an optional better wording. The finding and its rate stay as they are; the note goes to the owner.",
|
|
34
|
+
inputSchema: { type: "object", properties: { findingId: str("The finding id."), why: str("One or two sentences: what the check gets wrong about this app.", { maxLength: 500 }), question: str("The wording that fits this app.", { maxLength: 300 }) }, required: ["findingId", "why"], additionalProperties: false } },
|
|
35
|
+
{ name: "field_connect", title: "Connect production", inputSchema: none,
|
|
36
|
+
description: "How production replies reach Cortad, so real conversations are read with the same checks. The ingest key is created by the owner in the browser." },
|
|
37
|
+
{ name: "field", title: "Production numbers", annotations: readOnly,
|
|
38
|
+
description: "Production in numbers: conversations read, checks held, resolved, frustrated, asks for a human, and the rules broken most. Message text stays out of the answer.",
|
|
39
|
+
inputSchema: { type: "object", properties: { days: { type: "integer", minimum: 1, maximum: 180, description: "Window in days. Default 30." } }, additionalProperties: false } },
|
|
24
40
|
];
|
|
41
|
+
const NAMES = new Set(TOOLS.map((t) => t.name));
|
|
25
42
|
|
|
26
43
|
const error = (id, code, message) => ({ jsonrpc: "2.0", id, error: { code, message } });
|
|
27
44
|
const result = (id, value) => ({ jsonrpc: "2.0", id, result: value });
|
|
@@ -63,7 +80,7 @@ export function serveMcp({ verbs, version, input = process.stdin, output = proce
|
|
|
63
80
|
return send(result(id, { tools: TOOLS }));
|
|
64
81
|
case "tools/call": {
|
|
65
82
|
const name = params?.name;
|
|
66
|
-
const verb = Object.hasOwn(verbs, name) ? verbs[name] : null;
|
|
83
|
+
const verb = NAMES.has(name) && Object.hasOwn(verbs, name) ? verbs[name] : null;
|
|
67
84
|
if (!verb) return send(result(id, { content: [{ type: "text", text: `No tool ${String(name)}.` }], isError: true }));
|
|
68
85
|
const out = await verb(params?.arguments ?? {});
|
|
69
86
|
return send(result(id, { content: [{ type: "text", text: out.text }], ...(out.isError ? { isError: true } : {}) }));
|
package/lib/register.mjs
CHANGED
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
import {
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { delimiter, dirname, join } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { promisify } from "node:util";
|
|
7
|
+
import { cliSpec, npxArgs } from "./spec.mjs";
|
|
7
8
|
|
|
8
9
|
// Making Cortad known to the coding agents on this machine: an MCP entry in each client that is
|
|
9
10
|
// here, and the skill that teaches the loop. Nothing is written into the repository; everything
|
|
10
11
|
// lands in the client's own home folders, the way the client's own `add` command would put it.
|
|
11
|
-
// Idempotent: run twice, it adds nothing twice.
|
|
12
|
+
// Idempotent: run twice, it adds nothing twice. An entry naming another spec (lib/spec.mjs) is
|
|
13
|
+
// replaced, so a connect with a newer package moves every client to it.
|
|
12
14
|
const exec = promisify(execFile);
|
|
13
|
-
const SERVER = { command: "npx", args: ["-y", "cortad@latest", "mcp"] };
|
|
14
15
|
const SKILL_SRC = join(dirname(fileURLToPath(import.meta.url)), "..", "skill");
|
|
16
|
+
// The skill's shell examples name the spec in place of this.
|
|
17
|
+
const PLACEHOLDER = "{{cortad}}";
|
|
18
|
+
|
|
19
|
+
export const serverFor = (spec) => ({ command: "npx", args: [...npxArgs(spec), "mcp"] });
|
|
20
|
+
const same = (entry, server) => entry?.command === server.command && JSON.stringify(entry?.args) === JSON.stringify(server.args);
|
|
15
21
|
|
|
16
22
|
export const onPath = (bin, env = process.env) =>
|
|
17
23
|
(env.PATH ?? "").split(delimiter).some((dir) => dir && existsSync(join(dir, bin)));
|
|
@@ -21,68 +27,96 @@ export function detectClients({ home = homedir(), env = process.env } = {}) {
|
|
|
21
27
|
claude: onPath("claude", env) || existsSync(join(home, ".claude")),
|
|
22
28
|
codex: onPath("codex", env) || existsSync(join(home, ".codex")),
|
|
23
29
|
cursor: existsSync(join(home, ".cursor")),
|
|
30
|
+
copilot: onPath("copilot", env) || existsSync(join(home, ".copilot")),
|
|
24
31
|
};
|
|
25
32
|
}
|
|
26
33
|
|
|
27
34
|
// Returns the names of the clients that now know Cortad, for the one line the command prints.
|
|
28
|
-
export async function registerAll({ home = homedir(), env = process.env, run = exec, skillSrc = SKILL_SRC } = {}) {
|
|
35
|
+
export async function registerAll({ home = homedir(), env = process.env, run = exec, skillSrc = SKILL_SRC, spec = cliSpec({ env }) } = {}) {
|
|
29
36
|
const found = detectClients({ home, env });
|
|
37
|
+
const server = serverFor(spec);
|
|
38
|
+
const skill = (dir) => installSkill(dir, { src: skillSrc, spec });
|
|
30
39
|
const added = [];
|
|
31
40
|
if (found.claude) {
|
|
32
|
-
await registerClaude({ home, env, run });
|
|
33
|
-
|
|
41
|
+
await registerClaude({ home, env, run, server });
|
|
42
|
+
skill(join(home, ".claude", "skills", "cortad"));
|
|
34
43
|
added.push("Claude Code");
|
|
35
44
|
}
|
|
36
45
|
if (found.codex) {
|
|
37
|
-
await registerCodex({ home, env, run });
|
|
38
|
-
installSkill(join(home, ".agents", "skills", "cortad"), skillSrc);
|
|
46
|
+
await registerCodex({ home, env, run, server });
|
|
39
47
|
added.push("Codex");
|
|
40
48
|
}
|
|
41
49
|
if (found.cursor) {
|
|
42
|
-
|
|
50
|
+
registerJson(join(home, ".cursor", "mcp.json"), { type: "stdio", ...server });
|
|
43
51
|
added.push("Cursor");
|
|
44
52
|
}
|
|
53
|
+
if (found.copilot) {
|
|
54
|
+
// Copilot CLI keeps its servers in its own home, in its own shape: a "local" server with every tool.
|
|
55
|
+
registerJson(join(home, ".copilot", "mcp-config.json"), { type: "local", ...server, tools: ["*"] });
|
|
56
|
+
skill(join(home, ".copilot", "skills", "cortad"));
|
|
57
|
+
added.push("Copilot");
|
|
58
|
+
}
|
|
59
|
+
// The one skills folder Codex, Cursor and Copilot all read.
|
|
60
|
+
if (found.codex || found.cursor || found.copilot) skill(join(home, ".agents", "skills", "cortad"));
|
|
45
61
|
return added;
|
|
46
62
|
}
|
|
47
63
|
|
|
48
64
|
// The client's own command when it is here, so its config is written the way it writes it; the file
|
|
49
|
-
// itself only when the folder exists without the binary on this PATH.
|
|
50
|
-
async function registerClaude({ home, env, run }) {
|
|
65
|
+
// itself only when the folder exists without the binary on this PATH, or the command failed.
|
|
66
|
+
async function registerClaude({ home, env, run, server }) {
|
|
67
|
+
const file = join(home, ".claude.json");
|
|
51
68
|
if (onPath("claude", env)) {
|
|
52
|
-
|
|
53
|
-
|
|
69
|
+
const add = () => run("claude", ["mcp", "add", "--scope", "user", "cortad", "--", server.command, ...server.args], { env });
|
|
70
|
+
try { await add(); return; } catch (err) {
|
|
71
|
+
if (/already exists/i.test(String(err?.stderr ?? err?.message))) {
|
|
72
|
+
if (same(readJson(file)?.mcpServers?.cortad, server)) return;
|
|
73
|
+
try { await run("claude", ["mcp", "remove", "--scope", "user", "cortad"], { env }); await add(); return; } catch { /* the file below */ }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
54
76
|
}
|
|
55
|
-
const file = join(home, ".claude.json");
|
|
56
77
|
const config = readJson(file) ?? {};
|
|
57
|
-
config.mcpServers = { ...(config.mcpServers ?? {}), cortad: { type: "stdio", ...
|
|
78
|
+
config.mcpServers = { ...(config.mcpServers ?? {}), cortad: { type: "stdio", ...server } };
|
|
58
79
|
writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`);
|
|
59
80
|
}
|
|
60
81
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
catch (err) { if (/already exists/i.test(String(err?.stderr ?? err?.message))) return; }
|
|
65
|
-
}
|
|
82
|
+
const CODEX_BLOCK = /^\[mcp_servers\.cortad\]\n(?:(?!\[).*(?:\n|$))*/m;
|
|
83
|
+
|
|
84
|
+
async function registerCodex({ home, env, run, server }) {
|
|
66
85
|
const file = join(home, ".codex", "config.toml");
|
|
67
86
|
const text = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
68
|
-
|
|
87
|
+
const block = text.match(CODEX_BLOCK)?.[0] ?? "";
|
|
88
|
+
const args = block.match(/^args\s*=\s*(\[.*\])\s*$/m)?.[1];
|
|
89
|
+
const current = (() => { try { return args ? { command: block.match(/^command\s*=\s*"(.*)"\s*$/m)?.[1], args: JSON.parse(args) } : null; } catch { return null; } })();
|
|
90
|
+
if (same(current, server)) return;
|
|
91
|
+
if (onPath("codex", env)) {
|
|
92
|
+
try {
|
|
93
|
+
if (block) await run("codex", ["mcp", "remove", "cortad"], { env });
|
|
94
|
+
await run("codex", ["mcp", "add", "cortad", "--", server.command, ...server.args], { env });
|
|
95
|
+
return;
|
|
96
|
+
} catch { /* the file below */ }
|
|
97
|
+
}
|
|
98
|
+
const entry = `[mcp_servers.cortad]\ncommand = "${server.command}"\nargs = ${JSON.stringify(server.args)}\n`;
|
|
69
99
|
mkdirSync(dirname(file), { recursive: true });
|
|
70
|
-
|
|
100
|
+
// The blank lines after the old block stay, so the rest of the file keeps its layout.
|
|
101
|
+
const replaced = () => text.replace(CODEX_BLOCK, (old) => `${entry}${(old.match(/\n+$/)?.[0] ?? "\n").slice(1)}`);
|
|
102
|
+
writeFileSync(file, block ? replaced() : `${text}${text && !text.endsWith("\n") ? "\n" : ""}\n${entry}`);
|
|
71
103
|
}
|
|
72
104
|
|
|
73
|
-
function
|
|
74
|
-
const file = join(home, ".cursor", "mcp.json");
|
|
105
|
+
function registerJson(file, entry) {
|
|
75
106
|
const config = readJson(file) ?? {};
|
|
76
|
-
if (config.mcpServers?.cortad) return;
|
|
77
|
-
config.mcpServers = { ...(config.mcpServers ?? {}), cortad:
|
|
107
|
+
if (same(config.mcpServers?.cortad, entry)) return;
|
|
108
|
+
config.mcpServers = { ...(config.mcpServers ?? {}), cortad: entry };
|
|
109
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
78
110
|
writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`);
|
|
79
111
|
}
|
|
80
112
|
|
|
81
|
-
// SKILL.md and its references, copied whole so a newer package refreshes the words
|
|
82
|
-
|
|
113
|
+
// SKILL.md and its references, copied whole so a newer package refreshes the words, with the
|
|
114
|
+
// shell examples naming the spec this machine runs.
|
|
115
|
+
export function installSkill(dir, { src = SKILL_SRC, spec = cliSpec() } = {}) {
|
|
83
116
|
mkdirSync(join(dir, "references"), { recursive: true });
|
|
84
|
-
|
|
85
|
-
|
|
117
|
+
for (const rel of ["SKILL.md", join("references", "results.md")]) {
|
|
118
|
+
writeFileSync(join(dir, rel), readFileSync(join(src, rel), "utf8").replaceAll(PLACEHOLDER, spec));
|
|
119
|
+
}
|
|
86
120
|
}
|
|
87
121
|
|
|
88
122
|
function readJson(file) {
|
package/lib/spec.mjs
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { isAbsolute } from "node:path";
|
|
3
|
+
|
|
4
|
+
// Which cortad the coding agents on this machine start, and the skill and stick name: the
|
|
5
|
+
// CORTAD_CLI_SPEC the connect ran with (cortad@next, or an absolute folder for an unpublished
|
|
6
|
+
// build), else cortad@next while this package is a prerelease, else cortad@latest. The spec ends up
|
|
7
|
+
// in a shell line in the repository (lib/stick.mjs), so anything else in the variable is ignored.
|
|
8
|
+
export const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
9
|
+
const SAFE = /^(?:cortad(?:@[A-Za-z0-9._-]+)?|\/[A-Za-z0-9._/-]+)$/;
|
|
10
|
+
|
|
11
|
+
export function cliSpec({ env = process.env, version = VERSION } = {}) {
|
|
12
|
+
if (env.CORTAD_CLI_SPEC && SAFE.test(env.CORTAD_CLI_SPEC)) return env.CORTAD_CLI_SPEC;
|
|
13
|
+
return version.includes("-") ? "cortad@next" : "cortad@latest";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// npx needs -y to fetch a package without asking; a local folder is not fetched.
|
|
17
|
+
export const npxArgs = (spec) => (isAbsolute(spec) ? [spec] : ["-y", spec]);
|
|
18
|
+
|
|
19
|
+
// How a line a person reads names the command: plain `cortad` when that is what npx resolves anyway.
|
|
20
|
+
export const npxName = (spec) => (spec === "cortad@latest" ? "cortad" : spec);
|
package/lib/stick.mjs
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmdirSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
3
|
+
import { cliSpec, npxName } from "./spec.mjs";
|
|
4
|
+
|
|
5
|
+
// `npx cortad stick`: one line in each coding agent's own instructions file in this repository, and
|
|
6
|
+
// a hook in the two clients that run one after an edit. `unstick` takes out exactly what stick put
|
|
7
|
+
// in. Both are idempotent, print what they changed, and never touch git.
|
|
8
|
+
//
|
|
9
|
+
// The hook command carries --hook: Claude Code and Codex both drop plain stdout from a PostToolUse
|
|
10
|
+
// hook, and read only hookSpecificOutput.additionalContext. The command follows lib/spec.mjs, and
|
|
11
|
+
// unstick knows the line and the hook under any spec.
|
|
12
|
+
export const lineFor = (name) => `After changing prompts, tools, models or retrieval, check with Cortad before committing: npx ${name} status`;
|
|
13
|
+
export const hookFor = (name) => `npx ${name} status --changed --hook`;
|
|
14
|
+
const IS_LINE = /^After changing prompts, tools, models or retrieval, check with Cortad before committing: npx \S+ status$/;
|
|
15
|
+
const IS_HOOK = /^npx \S+ status --changed --hook$/;
|
|
16
|
+
const hasLine = (text) => text.split("\n").some((l) => IS_LINE.test(l.trim()));
|
|
17
|
+
const ours = (entry) => entry?.hooks?.some((h) => IS_HOOK.test(h.command ?? ""));
|
|
18
|
+
const LINE_FILES = ["AGENTS.md", "CLAUDE.md", ".github/copilot-instructions.md"];
|
|
19
|
+
const HOOK_FILES = [".claude/settings.json", ".codex/hooks.json"];
|
|
20
|
+
const RULE_FILE = ".cursor/rules/cortad.mdc";
|
|
21
|
+
|
|
22
|
+
export const hookOutput = (text) => JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: text } });
|
|
23
|
+
|
|
24
|
+
export function stick(root, { spec = cliSpec() } = {}) {
|
|
25
|
+
const line = lineFor(npxName(spec));
|
|
26
|
+
const hook = hookFor(npxName(spec));
|
|
27
|
+
const rule = `---\ndescription: Checking AI behavior with Cortad\nalwaysApply: true\n---\n${line}\n`;
|
|
28
|
+
const said = LINE_FILES.map((rel, i) => write(root, rel, (text) => {
|
|
29
|
+
if (hasLine(text)) return null;
|
|
30
|
+
return `${text}${text && !text.endsWith("\n") ? "\n" : ""}${text ? "\n" : ""}${line}\n`;
|
|
31
|
+
}, i === 0 ? `added "${line}"` : "added the same line"));
|
|
32
|
+
said.push(write(root, RULE_FILE, (text) => (hasLine(text) ? null : rule), "written, with the same line, applied always"));
|
|
33
|
+
said.push(...HOOK_FILES.map((rel, i) => write(root, rel, (text) => {
|
|
34
|
+
const config = parse(text);
|
|
35
|
+
if (config === undefined) return undefined;
|
|
36
|
+
const list = config.hooks?.PostToolUse ?? [];
|
|
37
|
+
if (list.some(ours)) return null;
|
|
38
|
+
config.hooks = { ...config.hooks, PostToolUse: [...list, { matcher: "Edit|Write", hooks: [{ type: "command", command: hook }] }] };
|
|
39
|
+
return `${JSON.stringify(config, null, 2)}\n`;
|
|
40
|
+
}, i === 0 ? `added a PostToolUse hook on Edit|Write that runs ${hook}` : "added the same hook")));
|
|
41
|
+
return said;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function unstick(root) {
|
|
45
|
+
const said = LINE_FILES.map((rel) => write(root, rel, (text) => {
|
|
46
|
+
if (!hasLine(text)) return null;
|
|
47
|
+
return text.split("\n").filter((l) => !IS_LINE.test(l.trim())).join("\n").replace(/\n+$/, "\n");
|
|
48
|
+
}, "removed the line"));
|
|
49
|
+
said.push(write(root, RULE_FILE, (text) => (text ? "" : null), "removed"));
|
|
50
|
+
said.push(...HOOK_FILES.map((rel) => write(root, rel, (text) => {
|
|
51
|
+
const config = parse(text);
|
|
52
|
+
if (config === undefined) return undefined;
|
|
53
|
+
const list = config.hooks?.PostToolUse;
|
|
54
|
+
if (!list?.some(ours)) return null;
|
|
55
|
+
const kept = list.map((e) => ({ ...e, hooks: (e.hooks ?? []).filter((h) => !IS_HOOK.test(h.command ?? "")) })).filter((e) => e.hooks.length);
|
|
56
|
+
if (kept.length) config.hooks.PostToolUse = kept;
|
|
57
|
+
else delete config.hooks.PostToolUse;
|
|
58
|
+
if (!Object.keys(config.hooks).length) delete config.hooks;
|
|
59
|
+
return Object.keys(config).length ? `${JSON.stringify(config, null, 2)}\n` : "";
|
|
60
|
+
}, "removed the hook")));
|
|
61
|
+
return said;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// One file: `change` gets its text ("" when absent) and returns the new text, null for nothing to
|
|
65
|
+
// do, or undefined when it cannot be read. An empty result removes the file, and the folders it
|
|
66
|
+
// leaves empty. A path that leaves the repository, through a symbolic link or otherwise, is left alone.
|
|
67
|
+
function write(root, rel, change, done) {
|
|
68
|
+
const file = resolve(root, rel);
|
|
69
|
+
if (!inside(root, file)) return `${rel}: left alone, it points outside this repository`;
|
|
70
|
+
const text = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
71
|
+
const next = change(text);
|
|
72
|
+
if (next === undefined) return `${rel}: left alone, it is not valid JSON`;
|
|
73
|
+
if (next === null) return `${rel}: nothing to change`;
|
|
74
|
+
if (next.trim() === "") {
|
|
75
|
+
rmSync(file);
|
|
76
|
+
for (let dir = dirname(file); dir.startsWith(`${resolve(root)}${sep}`) && !readdirSync(dir).length; dir = dirname(dir)) rmdirSync(dir);
|
|
77
|
+
return `${rel}: removed, it held only what stick wrote`;
|
|
78
|
+
}
|
|
79
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
80
|
+
writeFileSync(file, next);
|
|
81
|
+
return `${rel}: ${done}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function inside(root, file) {
|
|
85
|
+
const base = realpathSync(root);
|
|
86
|
+
let dir = dirname(file);
|
|
87
|
+
while (!existsSync(dir)) dir = dirname(dir);
|
|
88
|
+
const real = realpathSync(dir);
|
|
89
|
+
if (real !== base && !real.startsWith(base + sep)) return false;
|
|
90
|
+
try { return !lstatSync(file).isSymbolicLink(); } catch { return true; }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function parse(text) {
|
|
94
|
+
if (!text.trim()) return {};
|
|
95
|
+
try {
|
|
96
|
+
const value = JSON.parse(text);
|
|
97
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
|
|
98
|
+
} catch { return undefined; }
|
|
99
|
+
}
|