activate-agentmd 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +158 -0
  2. package/bin/agentmd.js +186 -0
  3. package/package.json +55 -0
  4. package/src/commands/agents.js +44 -0
  5. package/src/commands/analytics.js +128 -0
  6. package/src/commands/auth.js +144 -0
  7. package/src/commands/ci.js +296 -0
  8. package/src/commands/extract.js +254 -0
  9. package/src/commands/info.js +105 -0
  10. package/src/commands/init.js +166 -0
  11. package/src/commands/install.js +216 -0
  12. package/src/commands/link.js +206 -0
  13. package/src/commands/list.js +99 -0
  14. package/src/commands/outdated.js +92 -0
  15. package/src/commands/remove.js +72 -0
  16. package/src/commands/review.js +293 -0
  17. package/src/commands/search.js +110 -0
  18. package/src/commands/sync.js +245 -0
  19. package/src/commands/telemetry.js +82 -0
  20. package/src/commands/test.js +226 -0
  21. package/src/commands/update.js +115 -0
  22. package/src/commands/validate.js +125 -0
  23. package/src/config/license-public-keys.json +17 -0
  24. package/src/detect.json +452 -0
  25. package/src/lib/agents.js +301 -0
  26. package/src/lib/analytics.js +137 -0
  27. package/src/lib/coordinates.js +103 -0
  28. package/src/lib/credentials.js +115 -0
  29. package/src/lib/detect.js +352 -0
  30. package/src/lib/diff.js +142 -0
  31. package/src/lib/enterprise.js +140 -0
  32. package/src/lib/fetcher.js +174 -0
  33. package/src/lib/invocation.js +47 -0
  34. package/src/lib/license.js +234 -0
  35. package/src/lib/manifest.js +191 -0
  36. package/src/lib/patterns.js +135 -0
  37. package/src/lib/pro.js +149 -0
  38. package/src/lib/ranking.js +287 -0
  39. package/src/lib/registry.js +207 -0
  40. package/src/lib/star.js +105 -0
  41. package/src/lib/status.js +75 -0
  42. package/src/lib/telemetry.js +169 -0
  43. package/src/lib/versions.js +190 -0
  44. package/src/registry.json +33907 -0
@@ -0,0 +1,174 @@
1
+ /**
2
+ * fetcher.js
3
+ *
4
+ * Fetches preset markdown from the registry over HTTPS.
5
+ * Uses Node's built-in https module — zero extra dependencies.
6
+ *
7
+ * This is a supply-chain-sensitive path: the bytes it returns end up in a
8
+ * file that a developer's coding agent will read as instructions. It is
9
+ * deliberately strict about what it will talk to and how much it will accept.
10
+ */
11
+
12
+ "use strict";
13
+
14
+ const https = require("https");
15
+
16
+ const TIMEOUT_MS = Number(process.env.AGENTMD_TIMEOUT_MS) || 30000;
17
+ const MAX_REDIRECTS = 5;
18
+ const MAX_BYTES = 2 * 1024 * 1024; // presets are markdown; the largest today is ~40 KB
19
+ const RETRIES = 2;
20
+ /**
21
+ * A ceiling on the whole request, headers and body together.
22
+ * `req.setTimeout` only fires on socket inactivity, so a server that dribbles
23
+ * one byte at a time keeps resetting it and the CLI hangs indefinitely.
24
+ */
25
+ const DEADLINE_MS = TIMEOUT_MS * 2;
26
+
27
+ /**
28
+ * Shared keep-alive agent.
29
+ *
30
+ * `agentmd init` opens ~30 connections in a row. Without pooling, each one
31
+ * pays a fresh DNS + TLS handshake, and a burst of simultaneous cold
32
+ * handshakes was reliably timing out the first batch of `outdated`. Reusing a
33
+ * small pool of sockets fixes that and is kinder to the registry host.
34
+ */
35
+ const agent = new https.Agent({ keepAlive: true, maxSockets: 6 });
36
+
37
+ /**
38
+ * Fetch text over HTTPS.
39
+ *
40
+ * Refuses plaintext HTTP outright (including via redirect), so a hijacked
41
+ * redirect cannot downgrade the transport and inject preset content.
42
+ * Bounded by a timeout, a redirect limit and a response size cap so a hung or
43
+ * hostile server cannot wedge the CLI or exhaust memory.
44
+ */
45
+ function fetchOnce(url, redirectsLeft = MAX_REDIRECTS) {
46
+ return new Promise((resolve, reject) => {
47
+ let parsed;
48
+ try {
49
+ parsed = new URL(url);
50
+ } catch {
51
+ return reject(new Error(`Invalid registry URL: ${url}`));
52
+ }
53
+ if (parsed.protocol !== "https:") {
54
+ return reject(
55
+ new Error(`Refusing to fetch preset content over ${parsed.protocol}// — HTTPS is required (${url})`)
56
+ );
57
+ }
58
+
59
+ const req = https.get(parsed, { agent, headers: { "user-agent": "activate-agentmd" } }, (res) => {
60
+ const { statusCode, headers } = res;
61
+
62
+ if (statusCode >= 300 && statusCode < 400 && headers.location) {
63
+ res.resume(); // drain so the socket can be reused
64
+ if (redirectsLeft <= 0) {
65
+ return reject(new Error(`Too many redirects fetching ${url}`));
66
+ }
67
+ const next = new URL(headers.location, parsed).toString();
68
+ return fetchOnce(next, redirectsLeft - 1).then(resolve, reject);
69
+ }
70
+
71
+ if (statusCode !== 200) {
72
+ res.resume();
73
+ return reject(new Error(`HTTP ${statusCode} fetching ${url}`));
74
+ }
75
+
76
+ const chunks = [];
77
+ let bytes = 0;
78
+ res.setTimeout(TIMEOUT_MS, () => {
79
+ req.destroy(new Error(`Stalled after ${TIMEOUT_MS}ms while reading ${url}`));
80
+ });
81
+ res.on("data", (chunk) => {
82
+ bytes += chunk.length;
83
+ if (bytes > MAX_BYTES) {
84
+ req.destroy();
85
+ return reject(new Error(`Preset at ${url} exceeds the ${MAX_BYTES / 1024 / 1024} MB limit`));
86
+ }
87
+ chunks.push(chunk);
88
+ });
89
+ res.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
90
+ res.on("error", reject);
91
+ });
92
+
93
+ req.setTimeout(TIMEOUT_MS, () => {
94
+ req.destroy(new Error(`Timed out after ${TIMEOUT_MS}ms fetching ${url}`));
95
+ });
96
+ const deadline = setTimeout(() => {
97
+ req.destroy(new Error(`Timed out after ${DEADLINE_MS}ms fetching ${url} (response never completed)`));
98
+ }, DEADLINE_MS);
99
+ if (typeof deadline.unref === "function") deadline.unref();
100
+ const done = () => clearTimeout(deadline);
101
+ req.on("close", done);
102
+ req.on("error", (err) => { done(); reject(err); });
103
+ });
104
+ }
105
+
106
+ /**
107
+ * Fetch with retries.
108
+ *
109
+ * Retries only transient failures — a timeout or a dropped connection. An
110
+ * HTTP 404 means the package genuinely isn't there, and retrying it three
111
+ * times just makes the user wait longer for the same error.
112
+ */
113
+ async function fetchText(url) {
114
+ try {
115
+ return await fetchWithRetries(url);
116
+ } catch (err) {
117
+ // registry.js is required lazily: it imports the package index, which the
118
+ // fetcher's unit tests never need.
119
+ const { RAW_BASE, RAW_FALLBACK } = require("./registry");
120
+ if (RAW_FALLBACK && /HTTP 404/.test(err.message) && url.startsWith(RAW_BASE)) {
121
+ if (!warnedFallback) {
122
+ warnedFallback = true;
123
+ process.stderr.write(` ! ${RAW_BASE} is missing a file; falling back to main. Update the CLI when the next release lands.\n`);
124
+ }
125
+ return fetchWithRetries(RAW_FALLBACK + url.slice(RAW_BASE.length));
126
+ }
127
+ throw err;
128
+ }
129
+ }
130
+
131
+ let warnedFallback = false;
132
+
133
+ async function fetchWithRetries(url) {
134
+ let lastError;
135
+ for (let attempt = 0; attempt <= RETRIES; attempt++) {
136
+ try {
137
+ return await fetchOnce(url);
138
+ } catch (err) {
139
+ lastError = err;
140
+ const transient = /timed out|stalled|ECONNRESET|ETIMEDOUT|EAI_AGAIN|socket hang up|ENOTFOUND|ECONNREFUSED|EHOSTUNREACH|ENETUNREACH/i.test(err.message);
141
+ if (!transient || attempt === RETRIES) throw explain(err, url);
142
+ await new Promise((r) => setTimeout(r, 400 * (attempt + 1)));
143
+ }
144
+ }
145
+ throw explain(lastError, url);
146
+ }
147
+
148
+ /**
149
+ * Turn a Node network error into something a developer can act on. Raw
150
+ * `getaddrinfo EAI_AGAIN` tells a user nothing about which knob to turn, and
151
+ * the two knobs that exist (a proxy, and AGENTMD_TIMEOUT_MS) are not
152
+ * discoverable from the stack trace.
153
+ */
154
+ function explain(err, url) {
155
+ const host = (() => { try { return new URL(url).host; } catch { return "the registry"; } })();
156
+ const proxy = process.env.HTTPS_PROXY || process.env.https_proxy;
157
+ const hint = (text) => Object.assign(new Error(text), { cause: err, url });
158
+
159
+ if (/ENOTFOUND|EAI_AGAIN/i.test(err.message)) {
160
+ return hint(`Cannot resolve ${host}. Check your network or DNS${proxy ? "" : "; behind a proxy, set HTTPS_PROXY"}.`);
161
+ }
162
+ if (/ECONNREFUSED|EHOSTUNREACH|ENETUNREACH/i.test(err.message)) {
163
+ return hint(`Cannot reach ${host}. Check your network${proxy ? ` and HTTPS_PROXY (${proxy})` : " or firewall"}.`);
164
+ }
165
+ if (/timed out|stalled/i.test(err.message)) {
166
+ return hint(`${err.message.replace(/ fetching .*/, "")} fetching from ${host}. Raise AGENTMD_TIMEOUT_MS (currently ${TIMEOUT_MS}) on a slow link.`);
167
+ }
168
+ if (/certificate|self.signed|SELF_SIGNED|UNABLE_TO_VERIFY/i.test(err.message)) {
169
+ return hint(`TLS verification failed for ${host}: ${err.message}. A corporate proxy needs NODE_EXTRA_CA_CERTS pointed at its root certificate.`);
170
+ }
171
+ return err;
172
+ }
173
+
174
+ module.exports = { fetchText, fetchOnce, explain, TIMEOUT_MS, DEADLINE_MS, MAX_BYTES, MAX_REDIRECTS, RETRIES };
@@ -0,0 +1,47 @@
1
+ /**
2
+ * invocation.js
3
+ *
4
+ * Works out how the user invoked this CLI, so the commands it suggests are
5
+ * ones they can actually paste back.
6
+ *
7
+ * This matters more than it sounds. `npx activate-agentmd` does not put `agentmd`
8
+ * on PATH — npm caches the package under ~/.npm/_npx/<hash>/ and runs it from
9
+ * there. So a first-time user who ran `npx activate-agentmd init`, then followed
10
+ * the printed "Next: agentmd link", got `command not found` immediately after
11
+ * their first success.
12
+ *
13
+ * Every hint in every command goes through here.
14
+ */
15
+
16
+ "use strict";
17
+
18
+ /**
19
+ * npm caches npx packages under a `_npx` directory; nothing else uses it.
20
+ *
21
+ * Both separators are normalized regardless of the host platform. Keying off
22
+ * path.sep would mean a Windows-style path is only recognised when running on
23
+ * Windows, which makes the behaviour untestable anywhere else and silently
24
+ * wrong for any mixed-separator path.
25
+ */
26
+ function isNpx(scriptPath = process.argv[1] || "") {
27
+ const normalized = String(scriptPath).replace(/\\/g, "/");
28
+ return /\/_npx\//.test(normalized) || process.env.npm_command === "exec";
29
+ }
30
+
31
+ /**
32
+ * The prefix to put in front of a subcommand.
33
+ * "npx activate-agentmd" when run through npx, otherwise the plain binary name
34
+ * (a global install, a devDependency run via npm scripts, or ./node_modules/.bin).
35
+ */
36
+ function invocation() {
37
+ if (process.env.AGENTMD_INVOCATION) return process.env.AGENTMD_INVOCATION;
38
+ return isNpx() ? "npx activate-agentmd" : "agentmd";
39
+ }
40
+
41
+ /** Format a runnable command string, e.g. cmd("link") -> "npx activate-agentmd link". */
42
+ function cmd(subcommand = "") {
43
+ const prefix = invocation();
44
+ return subcommand ? `${prefix} ${subcommand}` : prefix;
45
+ }
46
+
47
+ module.exports = { invocation, cmd, isNpx };
@@ -0,0 +1,234 @@
1
+ /**
2
+ * license.js
3
+ *
4
+ * Offline verification of Agent.md Pro license keys.
5
+ *
6
+ * A key is a signed claim, not a lookup token:
7
+ *
8
+ * agmd_<base64url(payload JSON)>.<base64url(Ed25519 signature)>
9
+ *
10
+ * The CLI ships a **keyset** of trusted public keys and verifies the signature
11
+ * locally, so `agentmd extract` works on a plane, in a CI runner with no
12
+ * egress, and behind a proxy that blocks everything but npm. No call home on
13
+ * the hot path, nothing to rate-limit, and no way to forge a key without a
14
+ * private half.
15
+ *
16
+ * Why a keyset rather than one key: a single embedded key cannot be rotated
17
+ * without breaking every license already issued, and cannot be revoked at all
18
+ * — a compromise would mean shipping a new CLI and asking every customer to
19
+ * upgrade before anything improved. With key ids:
20
+ *
21
+ * - each license names the key that signed it (`kid`)
22
+ * - adding a key invalidates nothing
23
+ * - retiring a key stops it signing while it keeps verifying (rotation grace)
24
+ * - revoking a key kills every license under it at the next CLI update
25
+ *
26
+ * Licenses issued before `kid` existed carry none, and verify against the
27
+ * keyset's `legacyKid`. They keep working.
28
+ *
29
+ * The trade offline verification makes is revocation latency: a revoked key
30
+ * stops being trusted when the user updates the CLI, not the instant we say
31
+ * so. Keys are therefore dated a year at most. An online revocation check is
32
+ * the obvious next step and is deliberately not here yet, rather than being
33
+ * half-built and unreliable.
34
+ *
35
+ * Ed25519 is in Node's crypto core, so this costs no dependency.
36
+ */
37
+
38
+ "use strict";
39
+
40
+ const crypto = require("crypto");
41
+ const fs = require("fs");
42
+
43
+ /**
44
+ * The trusted keyset, generated from config/license-public-keys.json by
45
+ * scripts/build-keyset.js. Public halves only.
46
+ */
47
+ const BUNDLED = require("../config/license-public-keys.json");
48
+
49
+ const PREFIX = "agmd_";
50
+ const b64url = {
51
+ encode: (buf) => Buffer.from(buf).toString("base64url"),
52
+ decode: (str) => Buffer.from(str, "base64url"),
53
+ };
54
+
55
+ /** Plans that unlock the gated commands, cheapest first. */
56
+ const PAID_PLANS = ["pro", "team", "enterprise"];
57
+
58
+ /** Statuses that can still verify. `revoked` is the whole point of the field. */
59
+ const VERIFIES = new Set(["active", "retired"]);
60
+
61
+ /**
62
+ * The keyset in force.
63
+ *
64
+ * Two overrides, both for self-hosting rather than convenience — an enterprise
65
+ * running its own registry signs its own licenses:
66
+ *
67
+ * AGENTMD_LICENSE_KEYSET path to a keyset JSON file, or inline JSON
68
+ * AGENTMD_LICENSE_PUBLIC_KEY one bare public key (kept from before keysets)
69
+ *
70
+ * A broken override falls back to the bundled keyset rather than failing open
71
+ * or closed silently: a typo in an env var must not hand someone Pro, and must
72
+ * not lock a paying customer out either.
73
+ */
74
+ function keyset() {
75
+ const inline = process.env.AGENTMD_LICENSE_KEYSET;
76
+ if (inline) {
77
+ try {
78
+ const parsed = inline.trim().startsWith("{") ? JSON.parse(inline) : JSON.parse(fs.readFileSync(inline, "utf-8"));
79
+ if (parsed && Array.isArray(parsed.keys) && parsed.keys.length) return parsed;
80
+ } catch {
81
+ // unreadable or malformed — fall through to the bundled keyset
82
+ }
83
+ }
84
+
85
+ const single = process.env.AGENTMD_LICENSE_PUBLIC_KEY;
86
+ if (single) {
87
+ const kid = process.env.AGENTMD_LICENSE_KID || "env";
88
+ return { schema: 1, defaultKid: kid, legacyKid: kid, keys: [{ kid, alg: "ed25519", status: "active", publicKey: single }] };
89
+ }
90
+
91
+ return BUNDLED;
92
+ }
93
+
94
+ /** The key record for a kid, or null. */
95
+ function findKey(kid, set = keyset()) {
96
+ return set.keys.find((k) => k.kid === kid) || null;
97
+ }
98
+
99
+ /**
100
+ * May this kid be used to *sign* a new license?
101
+ *
102
+ * Verifying and signing are deliberately different questions. A `retired` key
103
+ * still verifies — licenses issued under it are in customers' hands — but
104
+ * signing anything new with it would extend the life of a key we have already
105
+ * decided to stop using, and the customer would have no way to tell. Only an
106
+ * `active` key signs.
107
+ *
108
+ * Returns `{ ok: true }` or `{ ok: false, reason }`, phrased for an operator
109
+ * at a terminal rather than for a user.
110
+ */
111
+ function canSign(kid, set = keyset()) {
112
+ const record = findKey(kid, set);
113
+ if (!record) {
114
+ return { ok: false, reason: `\`${kid}\` is not in config/license-public-keys.json. Add it, run node scripts/build-keyset.js, and ship that CLI before issuing under it.` };
115
+ }
116
+ if (record.status === "revoked") {
117
+ return { ok: false, reason: `\`${kid}\` is revoked. Anything signed with it is void on sight — issuing under it would produce a dead license.` };
118
+ }
119
+ if (record.status !== "active") {
120
+ return { ok: false, reason: `\`${kid}\` is \`${record.status}\`, not \`active\`. A retired key still verifies the licenses already issued under it, but must not sign new ones.` };
121
+ }
122
+ return { ok: true, record };
123
+ }
124
+
125
+ function publicKeyOf(record) {
126
+ return crypto.createPublicKey({ key: Buffer.from(record.publicKey, "base64"), format: "der", type: "spki" });
127
+ }
128
+
129
+ /**
130
+ * Verify a key and return its claims.
131
+ *
132
+ * Returns `{ valid: true, claims, kid }` or `{ valid: false, reason }`. Never
133
+ * throws on malformed input — a user pasting half a key should get a sentence,
134
+ * not a stack trace. Reasons read as a predicate, because they are printed
135
+ * after "That key …" and "The key from <source> …".
136
+ */
137
+ function verify(key, set = keyset()) {
138
+ if (typeof key !== "string" || !key.startsWith(PREFIX)) {
139
+ return { valid: false, reason: "is not an Agent.md license key — one starts with `agmd_`" };
140
+ }
141
+
142
+ const [payloadPart, signaturePart, ...rest] = key.slice(PREFIX.length).split(".");
143
+ if (!payloadPart || !signaturePart || rest.length) {
144
+ return { valid: false, reason: "is malformed — expected `agmd_<payload>.<signature>`" };
145
+ }
146
+
147
+ let signature;
148
+ let claims;
149
+ try {
150
+ signature = b64url.decode(signaturePart);
151
+ claims = JSON.parse(b64url.decode(payloadPart).toString("utf-8"));
152
+ } catch {
153
+ return { valid: false, reason: "is malformed — the payload or signature is not valid base64url" };
154
+ }
155
+ if (!claims || typeof claims !== "object") {
156
+ return { valid: false, reason: "is malformed — the payload is not an object" };
157
+ }
158
+
159
+ // Which key signed this. A license from before kid support carries none and
160
+ // verifies against whichever key the keyset nominates as the legacy one.
161
+ const kid = typeof claims.kid === "string" && claims.kid ? claims.kid : set.legacyKid;
162
+ const record = findKey(kid, set);
163
+
164
+ if (!record) {
165
+ return {
166
+ valid: false,
167
+ kid,
168
+ reason: `names signing key \`${kid}\`, which this version of the CLI does not trust — update the CLI, or check the key was not mistyped`,
169
+ };
170
+ }
171
+ if (record.status === "revoked") {
172
+ return {
173
+ valid: false,
174
+ kid,
175
+ reason: `was signed with \`${kid}\`, a key that has been revoked — every license under it is void. Contact support for a replacement`,
176
+ };
177
+ }
178
+ if (!VERIFIES.has(record.status)) {
179
+ return { valid: false, kid, reason: `was signed with \`${kid}\`, which is in an unknown state (\`${record.status}\`)` };
180
+ }
181
+
182
+ let ok = false;
183
+ try {
184
+ ok = crypto.verify(null, Buffer.from(payloadPart, "utf-8"), publicKeyOf(record), signature);
185
+ } catch {
186
+ ok = false;
187
+ }
188
+ if (!ok) return { valid: false, kid, reason: "was not issued by Agent.md — the signature does not match" };
189
+
190
+ if (!PAID_PLANS.includes(claims.plan)) {
191
+ return { valid: false, kid, claims, reason: `is for the \`${claims.plan || "none"}\` plan, which does not include Pro features` };
192
+ }
193
+ if (typeof claims.exp === "number" && claims.exp * 1000 < Date.now()) {
194
+ return { valid: false, kid, claims, reason: `expired on ${new Date(claims.exp * 1000).toISOString().slice(0, 10)}` };
195
+ }
196
+
197
+ return { valid: true, claims, kid };
198
+ }
199
+
200
+ /**
201
+ * Sign a payload. Only ever runs where a private key lives — the issuing
202
+ * script and its tests, never in a user's CLI.
203
+ *
204
+ * `kid` is stamped into the claims so verification knows which key to use.
205
+ * Omitting it produces a pre-kid license, which is only useful for proving
206
+ * that licenses issued before keysets still verify.
207
+ */
208
+ function sign(claims, privateKeyB64, kid) {
209
+ const key = crypto.createPrivateKey({
210
+ key: Buffer.from(privateKeyB64, "base64"),
211
+ format: "der",
212
+ type: "pkcs8",
213
+ });
214
+ const body = kid ? { ...claims, kid } : { ...claims };
215
+ const payload = b64url.encode(JSON.stringify(body));
216
+ const signature = b64url.encode(crypto.sign(null, Buffer.from(payload, "utf-8"), key));
217
+ return `${PREFIX}${payload}.${signature}`;
218
+ }
219
+
220
+ /** Last 6 characters, for printing a key without printing the key. */
221
+ function fingerprint(key) {
222
+ return typeof key === "string" && key.length > 6 ? `…${key.slice(-6)}` : "…";
223
+ }
224
+
225
+ /** Days until expiry, or null when the key never expires. */
226
+ function daysLeft(claims) {
227
+ if (!claims || typeof claims.exp !== "number") return null;
228
+ return Math.ceil((claims.exp * 1000 - Date.now()) / 86400000);
229
+ }
230
+
231
+ module.exports = {
232
+ verify, sign, fingerprint, daysLeft, keyset, findKey, canSign,
233
+ PAID_PLANS, PREFIX, VERIFIES,
234
+ };
@@ -0,0 +1,191 @@
1
+ /**
2
+ * manifest.js
3
+ *
4
+ * Reads and writes .agentmd/manifest.json in the user's project root.
5
+ * Tracks which presets are installed, their versions, checksums and paths.
6
+ */
7
+
8
+ "use strict";
9
+
10
+ const fs = require("fs");
11
+ const path = require("path");
12
+ const crypto = require("crypto");
13
+
14
+ const MANIFEST_DIR = ".agentmd";
15
+ const MANIFEST_FILE = "manifest.json";
16
+ const PRESETS_DIR = ".agentmd/presets";
17
+
18
+ function getManifestPath(cwd) {
19
+ return path.join(cwd, MANIFEST_DIR, MANIFEST_FILE);
20
+ }
21
+
22
+ function getPresetsDir(cwd) {
23
+ return path.join(cwd, PRESETS_DIR);
24
+ }
25
+
26
+ function checksum(content) {
27
+ return "sha256-" + crypto.createHash("sha256").update(content, "utf-8").digest("hex");
28
+ }
29
+
30
+ /**
31
+ * Resolve a preset filename to an absolute path inside .agentmd/presets/,
32
+ * refusing anything that would escape it.
33
+ *
34
+ * Filenames are derived from registry coordinates, so today they are already
35
+ * constrained — but this is the one place the CLI turns remote-influenced data
36
+ * into a filesystem write, and a registry index is exactly the kind of thing
37
+ * an attacker would target to get `../../.bashrc` written. Check it here so
38
+ * every caller is covered rather than trusting each one to remember.
39
+ */
40
+ function resolvePresetPath(cwd, filename) {
41
+ if (typeof filename !== "string" || filename.length === 0) {
42
+ throw new Error("Invalid preset filename");
43
+ }
44
+ const dir = path.resolve(getPresetsDir(cwd));
45
+ const target = path.resolve(dir, filename);
46
+ if (target !== path.join(dir, path.basename(target)) || !target.startsWith(dir + path.sep)) {
47
+ throw new Error(`Refusing to write outside ${PRESETS_DIR}/: ${filename}`);
48
+ }
49
+ return target;
50
+ }
51
+
52
+ /**
53
+ * Resolve a path the CLI is about to write inside the project, refusing
54
+ * anything that escapes it.
55
+ *
56
+ * `link` writes outside `.agentmd/` by design — CLAUDE.md, .cursor/rules/,
57
+ * .github/instructions/, and one file per detected workspace. Those paths are
58
+ * built from category and preset names and from a directory walk, so they are
59
+ * constrained today; this makes that a property of the code rather than of
60
+ * every caller remembering. Symlinks are resolved so a linked directory cannot
61
+ * be used to step outside the project either.
62
+ */
63
+ function resolveInProject(cwd, relative) {
64
+ if (typeof relative !== "string" || relative.length === 0) {
65
+ throw new Error("Invalid path");
66
+ }
67
+ if (path.isAbsolute(relative)) {
68
+ throw new Error(`Refusing to write to an absolute path: ${relative}`);
69
+ }
70
+ const root = realpath(path.resolve(cwd));
71
+ const target = path.resolve(root, relative);
72
+ // The file itself may not exist yet; its nearest existing ancestor must
73
+ // still live inside the project once symlinks are followed.
74
+ let existing = target;
75
+ while (!fs.existsSync(existing) && path.dirname(existing) !== existing) existing = path.dirname(existing);
76
+ const real = realpath(existing);
77
+ if (real !== root && !real.startsWith(root + path.sep)) {
78
+ throw new Error(`Refusing to write outside the project: ${relative}`);
79
+ }
80
+ return target;
81
+ }
82
+
83
+ function realpath(p) {
84
+ try { return fs.realpathSync(p); } catch { return p; }
85
+ }
86
+
87
+ /**
88
+ * Turn registry coordinates into a flat, filesystem-safe preset filename.
89
+ *
90
+ * The model is part of the name. Without it, claude/Security/owasp and
91
+ * grok/Security/owasp both resolved to Security-owasp.md — two manifest
92
+ * entries pointing at one file, so whichever installed last silently replaced
93
+ * the other while the manifest still claimed both were present.
94
+ *
95
+ * Runs of dots collapse to one, and leading dots are stripped, so no input can
96
+ * produce a `..` segment or a hidden file. Package names like `linear.app` and
97
+ * `x.ai` keep their single dot.
98
+ */
99
+ function presetFilename(model, category, preset) {
100
+ const slug = (s) =>
101
+ String(s)
102
+ .replace(/[^a-zA-Z0-9._-]+/g, "-")
103
+ .replace(/\.{2,}/g, ".")
104
+ .replace(/^[.\-]+|[-]+$/g, "");
105
+ return `${slug(model)}-${slug(category)}-${slug(preset)}.md`;
106
+ }
107
+
108
+ /** Read the existing manifest, or return a fresh one. */
109
+ function readManifest(cwd) {
110
+ const manifestPath = getManifestPath(cwd);
111
+ if (fs.existsSync(manifestPath)) {
112
+ try {
113
+ const parsed = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
114
+ if (!Array.isArray(parsed.presets)) parsed.presets = [];
115
+ return parsed;
116
+ } catch {
117
+ // Corrupt manifest — start fresh rather than crashing the install.
118
+ }
119
+ }
120
+ return { version: "1", installedAt: new Date().toISOString(), presets: [] };
121
+ }
122
+
123
+ /** Write the manifest, creating .agentmd/ if needed. */
124
+ function writeManifest(cwd, manifest) {
125
+ fs.mkdirSync(path.join(cwd, MANIFEST_DIR), { recursive: true });
126
+ manifest.presets.sort((a, b) => a.id.localeCompare(b.id));
127
+ fs.writeFileSync(getManifestPath(cwd), JSON.stringify(manifest, null, 2) + "\n", "utf-8");
128
+ }
129
+
130
+ function ensurePresetsDir(cwd) {
131
+ const dir = getPresetsDir(cwd);
132
+ fs.mkdirSync(dir, { recursive: true });
133
+ return dir;
134
+ }
135
+
136
+ /** Add or replace a preset entry. */
137
+ function upsertPreset(manifest, entry) {
138
+ const record = {
139
+ id: entry.id,
140
+ model: entry.model,
141
+ category: entry.category,
142
+ preset: entry.preset,
143
+ version: entry.version,
144
+ checksum: entry.checksum,
145
+ source: entry.url,
146
+ installedAt: new Date().toISOString(),
147
+ file: entry.file,
148
+ };
149
+ const index = manifest.presets.findIndex((p) => p.id === record.id);
150
+ if (index >= 0) {
151
+ record.installedAt = manifest.presets[index].installedAt;
152
+ record.updatedAt = new Date().toISOString();
153
+ manifest.presets[index] = record;
154
+ } else {
155
+ manifest.presets.push(record);
156
+ }
157
+ return record;
158
+ }
159
+
160
+ function findPreset(manifest, id) {
161
+ return manifest.presets.find((p) => p.id.toLowerCase() === String(id).toLowerCase()) || null;
162
+ }
163
+
164
+ function isInstalled(manifest, model, category, preset) {
165
+ return findPreset(manifest, `${model}/${category}/${preset}`) !== null;
166
+ }
167
+
168
+ /** Drop an entry from the manifest. Returns the removed record, or null. */
169
+ function removePreset(manifest, id) {
170
+ const index = manifest.presets.findIndex((p) => p.id.toLowerCase() === String(id).toLowerCase());
171
+ if (index < 0) return null;
172
+ return manifest.presets.splice(index, 1)[0];
173
+ }
174
+
175
+ module.exports = {
176
+ readManifest,
177
+ writeManifest,
178
+ ensurePresetsDir,
179
+ upsertPreset,
180
+ findPreset,
181
+ removePreset,
182
+ isInstalled,
183
+ getManifestPath,
184
+ getPresetsDir,
185
+ resolvePresetPath,
186
+ resolveInProject,
187
+ presetFilename,
188
+ checksum,
189
+ MANIFEST_DIR,
190
+ PRESETS_DIR,
191
+ };