cortad 0.2.2 → 0.3.0-rc.10
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 +90 -38
- package/lib/home.mjs +9 -2
- package/lib/mcp.mjs +32 -12
- package/lib/pyhook/sitecustomize.py +396 -11
- package/lib/read-text.mjs +91 -0
- package/lib/register.mjs +64 -30
- package/lib/replay.mjs +43 -5
- package/lib/spec.mjs +20 -0
- package/lib/start.mjs +10 -0
- package/lib/stick.mjs +99 -0
- package/lib/text.mjs +268 -0
- package/lib/trace.cjs +303 -9
- package/lib/verbs.mjs +148 -103
- package/lib/words.mjs +37 -0
- package/local.mjs +58 -17
- package/package.json +6 -1
- package/skill/SKILL.md +75 -31
- package/skill/references/results.md +175 -34
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/replay.mjs
CHANGED
|
@@ -11,7 +11,7 @@ const HOOK = join(HERE, "trace.cjs");
|
|
|
11
11
|
const PYHOOK = join(HERE, "pyhook");
|
|
12
12
|
export const CAPTURED = "captured";
|
|
13
13
|
// Never replayed: they describe one connection, not the caller.
|
|
14
|
-
const HOP = /^(?:host|content-length|connection|keep-alive|transfer-encoding|upgrade|expect|te|trailer|accept-encoding|x-cortad-as)$/i;
|
|
14
|
+
const HOP = /^(?:host|content-length|connection|keep-alive|transfer-encoding|upgrade|expect|te|trailer|accept-encoding|x-cortad-as|x-cortad-turn)$/i;
|
|
15
15
|
|
|
16
16
|
export function makeCapture({ work, keepSecret, onDoor }) {
|
|
17
17
|
const file = join(work, "trace.jsonl");
|
|
@@ -21,6 +21,9 @@ export function makeCapture({ work, keepSecret, onDoor }) {
|
|
|
21
21
|
// The ports a hooked process listens on. Loading is not listening: turbo's own Node launcher loads
|
|
22
22
|
// the hook, and one app was told we would see its messages while its API on Bun carried none.
|
|
23
23
|
const ports = new Set();
|
|
24
|
+
// The route table each listening process read off its own app, newest per port.
|
|
25
|
+
const tables = new Map();
|
|
26
|
+
let newestTable = null;
|
|
24
27
|
// Bun reads BUN_OPTIONS and splits it on spaces, quotes included, and only the `--preload=` form
|
|
25
28
|
// leaves `bun run <script>` working. A hook path with a space in it is copied to one without.
|
|
26
29
|
const bunHook = /\s/.test(HOOK) ? (() => { const at = join(mkdtempSync(join(tmpdir(), "cortad-")), "trace.cjs"); copyFileSync(HOOK, at); return at; })() : HOOK;
|
|
@@ -29,6 +32,9 @@ export function makeCapture({ work, keepSecret, onDoor }) {
|
|
|
29
32
|
// ponytail: Node, Bun and Python. Go, Ruby, Java and PHP apps are asked for their route on the screen.
|
|
30
33
|
const env = (base) => ({
|
|
31
34
|
CORTAD_TRACE_FILE: file,
|
|
35
|
+
// The run writes the customer's rule sentences here (the engine's /tmp/rules.json lands in this
|
|
36
|
+
// folder), and the hook reads which of them each model call's prompt carried.
|
|
37
|
+
CORTAD_RULES_FILE: join(work, "rules.json"),
|
|
32
38
|
NODE_OPTIONS: `${base.NODE_OPTIONS ?? ""} --require ${JSON.stringify(HOOK)}`.trim(),
|
|
33
39
|
BUN_OPTIONS: `${base.BUN_OPTIONS ?? ""} --preload=${bunHook}`.trim(),
|
|
34
40
|
PYTHONPATH: [PYHOOK, base.PYTHONPATH].filter(Boolean).join(":"),
|
|
@@ -45,6 +51,7 @@ export function makeCapture({ work, keepSecret, onDoor }) {
|
|
|
45
51
|
let row; try { row = JSON.parse(line); } catch { continue; }
|
|
46
52
|
if (row.hello) { alive = true; continue; }
|
|
47
53
|
if (Number.isInteger(row.listen)) { ports.add(row.listen); continue; }
|
|
54
|
+
if (row.routes) { const t = routeTable(row.routes); if (t) { tables.set(t.port, t); newestTable = t; } continue; }
|
|
48
55
|
if (row.call) { meter.add(row.call); continue; }
|
|
49
56
|
if (row.dep) { meter.dep(row.dep); continue; }
|
|
50
57
|
let body; try { body = JSON.parse(row.body); } catch { continue; }
|
|
@@ -67,11 +74,41 @@ export function makeCapture({ work, keepSecret, onDoor }) {
|
|
|
67
74
|
// What your app spent on its model providers since it started, as the hook saw each call. Null
|
|
68
75
|
// when no hook is in your app (an app this command did not start): absent, never zero.
|
|
69
76
|
usage: () => { poll(); return alive ? meter.report() : null; },
|
|
77
|
+
// The app's routes as its framework holds them: the table of the process on `port`, else the
|
|
78
|
+
// newest any hooked process wrote. Empty when no hook read one.
|
|
79
|
+
registry: (port) => { poll(); return tables.get(port) ?? newestTable ?? {}; },
|
|
70
80
|
};
|
|
71
81
|
}
|
|
72
82
|
|
|
83
|
+
const FRAMEWORKS = new Set(["fastapi", "starlette", "flask", "django", "quart", "litestar", "aiohttp", "express", "fastify", "hono", "koa", "elysia", "nest", "unknown"]);
|
|
84
|
+
const OPENAPI_MAX = 8 * 1024 * 1024;
|
|
85
|
+
const text = (v, max) => (typeof v === "string" ? v.slice(0, max) : "");
|
|
86
|
+
// The row comes from inside a process this command does not control: every field is checked.
|
|
87
|
+
const routeTable = (r) => {
|
|
88
|
+
if (!r || typeof r !== "object" || !Array.isArray(r.routes)) return null;
|
|
89
|
+
const openapi = r.openapi && typeof r.openapi === "object" && !Array.isArray(r.openapi) && JSON.stringify(r.openapi).length <= OPENAPI_MAX ? r.openapi : null;
|
|
90
|
+
return {
|
|
91
|
+
framework: FRAMEWORKS.has(r.framework) ? r.framework : "unknown",
|
|
92
|
+
port: Number.isInteger(r.port) && r.port > 0 && r.port < 65536 ? r.port : null,
|
|
93
|
+
routes: r.routes
|
|
94
|
+
.filter((x) => x && /^[A-Za-z]{1,10}$/.test(x.method) && typeof x.path === "string" && x.path.startsWith("/"))
|
|
95
|
+
.slice(0, 400)
|
|
96
|
+
.map((x) => ({ method: x.method.toUpperCase(), path: x.path.slice(0, 1024), file: text(x.file, 512), handler: text(x.handler, 200) })),
|
|
97
|
+
openapi,
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
|
|
73
101
|
// Every model call the hook wrote down, kept here: totals per host and model, and the newest rows.
|
|
74
102
|
const ROWS = 5000;
|
|
103
|
+
// The turn a row was pinned to by the run's own tag, and the rule ids the call's prompt carried.
|
|
104
|
+
const TURN = /^[A-Za-z0-9:_.-]{1,80}$/;
|
|
105
|
+
const RULE_ID = /^[\w:-]{1,64}$/;
|
|
106
|
+
const turnOf = (v) => (typeof v === "string" && TURN.test(v) ? { turn: v } : {});
|
|
107
|
+
const rulesOf = (v) => (Array.isArray(v) ? { rules: v.filter((id) => typeof id === "string" && RULE_ID.test(id)).slice(0, 300) } : {});
|
|
108
|
+
// What the app's tools answered behind a call, as the hook read them off the next prompt.
|
|
109
|
+
const toolsOf = (v) => (Array.isArray(v) && v.length
|
|
110
|
+
? { tools: v.filter((t) => t && typeof t.text === "string" && t.text.trim()).slice(0, 12).map((t) => ({ name: String(t.name ?? "").slice(0, 80), text: t.text.slice(0, 3000) })) }
|
|
111
|
+
: {});
|
|
75
112
|
const meter = (() => {
|
|
76
113
|
const rows = [];
|
|
77
114
|
const deps = [];
|
|
@@ -82,14 +119,15 @@ const meter = (() => {
|
|
|
82
119
|
rows.push({
|
|
83
120
|
at: count(call.at), host: call.host.slice(0, 253), model: String(call.model ?? "").slice(0, 160), status: count(call.status),
|
|
84
121
|
promptTokens: count(call.promptTokens), cachedTokens: count(call.cachedTokens), completionTokens: count(call.completionTokens),
|
|
85
|
-
usage: call.usage === true,
|
|
122
|
+
usage: call.usage === true, ...turnOf(call.turn), ...rulesOf(call.rules), ...toolsOf(call.tools),
|
|
86
123
|
});
|
|
87
124
|
if (rows.length > ROWS) rows.splice(0, rows.length - ROWS);
|
|
88
125
|
},
|
|
89
126
|
// A service their settings name: only its setting, host and status travel, never a byte of it.
|
|
90
127
|
dep(d) {
|
|
91
|
-
if (!d || typeof d.
|
|
92
|
-
|
|
128
|
+
if (!d || typeof d.host !== "string" || !d.host) return;
|
|
129
|
+
const env = typeof d.env === "string" && /^[A-Z_][A-Z0-9_]*$/.test(d.env) ? d.env : undefined;
|
|
130
|
+
deps.push({ at: count(d.at), ...(env ? { env } : {}), host: d.host.slice(0, 253), status: count(d.status), ...(typeof d.code === "string" ? { code: d.code.slice(0, 40) } : {}), ...turnOf(d.turn) });
|
|
93
131
|
if (deps.length > ROWS) deps.splice(0, deps.length - ROWS);
|
|
94
132
|
},
|
|
95
133
|
report() {
|
|
@@ -107,7 +145,7 @@ const meter = (() => {
|
|
|
107
145
|
}
|
|
108
146
|
return {
|
|
109
147
|
totals: [...totals.values()].sort((a, b) => b.calls - a.calls),
|
|
110
|
-
rows: rows.slice(-
|
|
148
|
+
rows: rows.slice(-400).reverse().map(({ at, host, model, status, turn, rules, tools }) => ({ at, host, model, status, ...(turn ? { turn } : {}), ...(rules ? { rules } : {}), ...(tools ? { tools } : {}) })),
|
|
111
149
|
deps: deps.slice(-400).reverse(),
|
|
112
150
|
};
|
|
113
151
|
},
|
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/start.mjs
CHANGED
|
@@ -55,6 +55,7 @@ function pythonStart(dir, onPath) {
|
|
|
55
55
|
const py = venv ? JSON.stringify(venv) : existsSync(join(dir, "uv.lock")) && onPath("uv") ? "uv run python" : existsSync(join(dir, "poetry.lock")) && onPath("poetry") ? "poetry run python" : "python3";
|
|
56
56
|
if (existsSync(join(dir, "manage.py"))) return { cmd: `${py} manage.py runserver`, serves: true };
|
|
57
57
|
const named = [...new Set([...ENTRIES, ...entriesIn(dir)])].map((entry) => [entry, read(join(dir, entry))]).filter(([, text]) => text);
|
|
58
|
+
const manifest = `${read(join(dir, "requirements.txt"))}\n${read(join(dir, "pyproject.toml"))}\n${read(join(dir, "Pipfile"))}`;
|
|
58
59
|
// The file that serves before the file that merely runs: src/run_agent.py runs a conversation in
|
|
59
60
|
// the terminal and src/run_service.py is the app, and they sit in one folder.
|
|
60
61
|
for (const [entry, text] of [...named.filter(([, text]) => SERVES.test(text)), ...named]) {
|
|
@@ -65,10 +66,19 @@ function pythonStart(dir, onPath) {
|
|
|
65
66
|
if (fast) return { cmd: `${py} -m uvicorn ${module}:${fast[1]} --host 127.0.0.1 --port 8000`, serves: true };
|
|
66
67
|
const flask = /^(\w+)\s*=\s*Flask\(/m.exec(text);
|
|
67
68
|
if (flask) return { cmd: `${py} -m flask --app ${module} run --port 5000`, serves: true };
|
|
69
|
+
// The app object built elsewhere and only bound or re-exported here: resumeforge's
|
|
70
|
+
// backend/app/main.py is `from .application import (app, ...)`, and the server the manifest
|
|
71
|
+
// names says what serves it.
|
|
72
|
+
const bound = BOUND_APP.exec(text)?.[1] ?? IMPORTED_APP.exec(text)?.slice(1).find(Boolean);
|
|
73
|
+
if (bound && /\b(?:fastapi|starlette|litestar|quart)\b/i.test(manifest)) return { cmd: `${py} -m uvicorn ${module}:${bound} --host 127.0.0.1 --port 8000`, serves: true };
|
|
74
|
+
if (bound && /\bflask\b/i.test(manifest)) return { cmd: `${py} -m flask --app ${module}:${bound} run --port 5000`, serves: true };
|
|
68
75
|
}
|
|
69
76
|
return null;
|
|
70
77
|
}
|
|
71
78
|
|
|
79
|
+
// `app = create_app()` at the top of the file, or `from .application import app`.
|
|
80
|
+
const BOUND_APP = /^(app|application)\s*=\s*[\w.]+\(/m;
|
|
81
|
+
const IMPORTED_APP = /^from\s+[\w.]+\s+import\s+(?:\(\s*[^)]*?\b(app|application)\b[^)]*\)|[^\n(]*\b(app|application)\b)/m;
|
|
72
82
|
const KNOWN = ["package.json", "pyproject.toml", "requirements.txt", "manage.py", "Pipfile", "uv.lock"];
|
|
73
83
|
|
|
74
84
|
const startOf = (dir, onPath) => nodeStart(dir, onPath) ?? pythonStart(dir, onPath);
|
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
|
+
}
|