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.
- package/README.md +158 -0
- package/bin/agentmd.js +186 -0
- package/package.json +55 -0
- package/src/commands/agents.js +44 -0
- package/src/commands/analytics.js +128 -0
- package/src/commands/auth.js +144 -0
- package/src/commands/ci.js +296 -0
- package/src/commands/extract.js +254 -0
- package/src/commands/info.js +105 -0
- package/src/commands/init.js +166 -0
- package/src/commands/install.js +216 -0
- package/src/commands/link.js +206 -0
- package/src/commands/list.js +99 -0
- package/src/commands/outdated.js +92 -0
- package/src/commands/remove.js +72 -0
- package/src/commands/review.js +293 -0
- package/src/commands/search.js +110 -0
- package/src/commands/sync.js +245 -0
- package/src/commands/telemetry.js +82 -0
- package/src/commands/test.js +226 -0
- package/src/commands/update.js +115 -0
- package/src/commands/validate.js +125 -0
- package/src/config/license-public-keys.json +17 -0
- package/src/detect.json +452 -0
- package/src/lib/agents.js +301 -0
- package/src/lib/analytics.js +137 -0
- package/src/lib/coordinates.js +103 -0
- package/src/lib/credentials.js +115 -0
- package/src/lib/detect.js +352 -0
- package/src/lib/diff.js +142 -0
- package/src/lib/enterprise.js +140 -0
- package/src/lib/fetcher.js +174 -0
- package/src/lib/invocation.js +47 -0
- package/src/lib/license.js +234 -0
- package/src/lib/manifest.js +191 -0
- package/src/lib/patterns.js +135 -0
- package/src/lib/pro.js +149 -0
- package/src/lib/ranking.js +287 -0
- package/src/lib/registry.js +207 -0
- package/src/lib/star.js +105 -0
- package/src/lib/status.js +75 -0
- package/src/lib/telemetry.js +169 -0
- package/src/lib/versions.js +190 -0
- package/src/registry.json +33907 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agents.js
|
|
3
|
+
*
|
|
4
|
+
* The adapter layer: one installed package, many agent-specific config files.
|
|
5
|
+
*
|
|
6
|
+
* Every target below is a documented convention that the named tool actually
|
|
7
|
+
* reads. Nothing here is aspirational — if a tool's convention isn't
|
|
8
|
+
* implemented, it isn't listed, and `agentmd agents` will not claim it.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
"use strict";
|
|
12
|
+
|
|
13
|
+
const path = require("path");
|
|
14
|
+
|
|
15
|
+
const START = "<!-- agentmd:start -->";
|
|
16
|
+
const END = "<!-- agentmd:end -->";
|
|
17
|
+
|
|
18
|
+
const TARGETS = {
|
|
19
|
+
claude: {
|
|
20
|
+
label: "Claude Code",
|
|
21
|
+
file: "CLAUDE.md",
|
|
22
|
+
format: "markdown",
|
|
23
|
+
},
|
|
24
|
+
agents: {
|
|
25
|
+
label: "AGENTS.md (Codex, Amp, Jules, and others)",
|
|
26
|
+
file: "AGENTS.md",
|
|
27
|
+
format: "markdown",
|
|
28
|
+
},
|
|
29
|
+
cursor: {
|
|
30
|
+
label: "Cursor",
|
|
31
|
+
file: path.join(".cursor", "rules", "agentmd.mdc"),
|
|
32
|
+
format: "mdc",
|
|
33
|
+
},
|
|
34
|
+
gemini: {
|
|
35
|
+
label: "Gemini CLI",
|
|
36
|
+
file: "GEMINI.md",
|
|
37
|
+
format: "markdown",
|
|
38
|
+
},
|
|
39
|
+
copilot: {
|
|
40
|
+
label: "GitHub Copilot",
|
|
41
|
+
file: path.join(".github", "copilot-instructions.md"),
|
|
42
|
+
format: "markdown",
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Rough tokens: four characters each, the cut every tokenizer lands near. */
|
|
47
|
+
const tokens = (text) => Math.ceil(String(text || "").length / 4);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The always-on budget. Adherence collapses as instructions stack (about 96%
|
|
51
|
+
* with one instruction, as low as 20% with twenty-four, in the 2026
|
|
52
|
+
* benchmarks), so the block every prompt carries is capped. Everything past
|
|
53
|
+
* the cap is still installed and linked, but loads on demand.
|
|
54
|
+
*/
|
|
55
|
+
const ALWAYS_ON_BUDGET = 1500;
|
|
56
|
+
|
|
57
|
+
/** Packages that earn a place in the core first when the budget is tight. */
|
|
58
|
+
const CORE_PRIORITY = ["Security/owasp", "Review/code-review", "AI/agent-rules"];
|
|
59
|
+
const CATEGORY_RANK = ["Security", "AI", "Review", "Backend", "Frontend", "Database", "API", "Testing", "Performance", "DevOps"];
|
|
60
|
+
|
|
61
|
+
const idOf = (p) => `${p.category}/${p.preset}`;
|
|
62
|
+
const posix = (file) => String(file).split(path.sep).join("/");
|
|
63
|
+
|
|
64
|
+
function corePriority(p) {
|
|
65
|
+
const i = CORE_PRIORITY.indexOf(idOf(p));
|
|
66
|
+
if (i !== -1) return i;
|
|
67
|
+
const c = CATEGORY_RANK.indexOf(p.category);
|
|
68
|
+
return 100 + (c === -1 ? CATEGORY_RANK.length : c) * 10;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A package's core: its hard rules and its checklist — the lines an agent
|
|
73
|
+
* must hold on every prompt. Derived from the standard itself rather than
|
|
74
|
+
* hand-written, so every package has one today; a canonical file can tighten
|
|
75
|
+
* it by improving its own **Never** rules and checklist.
|
|
76
|
+
*/
|
|
77
|
+
function coreOf(content, limit = 6) {
|
|
78
|
+
const body = String(content).replace(/^---[\s\S]*?---\s*/, "");
|
|
79
|
+
const lines = [];
|
|
80
|
+
for (const para of body.split(/\n\s*\n/)) {
|
|
81
|
+
const t = para.trim();
|
|
82
|
+
if (/^\*\*Never\*\*|^- \*\*Never/.test(t)) {
|
|
83
|
+
lines.push("- " + t.replace(/^[-*\s]*/, "").replace(/\*\*/g, "").replace(/\s*\n\s*/g, " "));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
for (const m of body.match(/^- \[ \] .+$/gm) || []) {
|
|
87
|
+
if (lines.length >= limit) break;
|
|
88
|
+
lines.push("- " + m.replace(/^- \[ \] (Verify: )?/, ""));
|
|
89
|
+
}
|
|
90
|
+
if (lines.length) return lines.slice(0, limit);
|
|
91
|
+
// Older packages carry neither **Never** rules nor a checklist: fall back to
|
|
92
|
+
// their first substantive bullets so they still earn a place in the core.
|
|
93
|
+
return (body.match(/^- .{40,}$/gm) || []).slice(0, Math.min(4, limit)).map((l) => l.replace(/\*\*/g, ""));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Decide what fits in the always-on block. Pins go first (they are the
|
|
98
|
+
* highest-value lines in the file), then package cores in priority order
|
|
99
|
+
* until the budget is spent. Without a `readPreset` there is nothing to
|
|
100
|
+
* extract, and every package is listed as a link — the pre-budget shape.
|
|
101
|
+
*/
|
|
102
|
+
function planAlwaysOn(presets, pins = [], readPreset = null, budget = ALWAYS_ON_BUDGET) {
|
|
103
|
+
const { renderPins } = require("./versions");
|
|
104
|
+
const core = [];
|
|
105
|
+
const demoted = [];
|
|
106
|
+
let used = tokens(renderPins(pins)) + 150; // header, section titles and footer
|
|
107
|
+
if (!readPreset) return { core, demoted: presets.slice(), used, budget };
|
|
108
|
+
|
|
109
|
+
for (const p of presets.slice().sort((a, b) => corePriority(a) - corePriority(b))) {
|
|
110
|
+
const content = readPreset(p);
|
|
111
|
+
const lines = content ? coreOf(content) : [];
|
|
112
|
+
if (!lines.length) { demoted.push(p); continue; }
|
|
113
|
+
const cost = tokens(lines.join("\n")) + tokens(idOf(p)) + 8;
|
|
114
|
+
if (used + cost > budget) { demoted.push(p); continue; }
|
|
115
|
+
used += cost;
|
|
116
|
+
core.push({ id: idOf(p), file: posix(p.file), lines });
|
|
117
|
+
}
|
|
118
|
+
return { core, demoted, used, budget };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The managed block, rendered for an agent to read.
|
|
123
|
+
*
|
|
124
|
+
* opts.readPreset (preset) => text | null — enables the core/reference split
|
|
125
|
+
* opts.scope workspace path — marks a nested file that overrides the root
|
|
126
|
+
*/
|
|
127
|
+
function renderBody(presets, pins = [], opts = {}) {
|
|
128
|
+
if (presets.length === 0 && pins.length === 0) {
|
|
129
|
+
return "No agentmd packages are installed. Run `agentmd init` to add some.";
|
|
130
|
+
}
|
|
131
|
+
const { renderPins } = require("./versions");
|
|
132
|
+
const plan = planAlwaysOn(presets, pins, opts.readPreset);
|
|
133
|
+
|
|
134
|
+
const lines = [
|
|
135
|
+
"## Engineering standards (managed by agentmd)",
|
|
136
|
+
"",
|
|
137
|
+
"Read and follow the standards in these files. They are installed from the",
|
|
138
|
+
"agentmd registry — edit them through `agentmd`, not by hand, or the next",
|
|
139
|
+
"`agentmd update` will overwrite your changes.",
|
|
140
|
+
"",
|
|
141
|
+
];
|
|
142
|
+
if (opts.scope) {
|
|
143
|
+
lines.push(`_Workspace \`${opts.scope}\`: the nearest file wins. These rules override the root file for everything under this directory._`, "");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const pinBlock = renderPins(pins);
|
|
147
|
+
if (pinBlock) lines.push(pinBlock);
|
|
148
|
+
|
|
149
|
+
if (plan.core.length) {
|
|
150
|
+
lines.push("### Core rules (always on)", "");
|
|
151
|
+
for (const c of plan.core) {
|
|
152
|
+
lines.push(`**${c.id}** — [full standard](${c.file})`, ...c.lines, "");
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const rest = presets.filter((p) => !plan.core.some((c) => c.id === idOf(p)));
|
|
157
|
+
if (rest.length) {
|
|
158
|
+
if (plan.core.length) lines.push("### On demand", "", "Read the standard before working in its area; the always-on budget is spent.", "");
|
|
159
|
+
const byCategory = new Map();
|
|
160
|
+
for (const p of rest) {
|
|
161
|
+
if (!byCategory.has(p.category)) byCategory.set(p.category, []);
|
|
162
|
+
byCategory.get(p.category).push(p);
|
|
163
|
+
}
|
|
164
|
+
for (const [category, items] of [...byCategory].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
165
|
+
lines.push(`### ${category}`);
|
|
166
|
+
for (const item of items.sort((a, b) => a.preset.localeCompare(b.preset))) {
|
|
167
|
+
lines.push(`- [\`${item.preset}\`](${posix(item.file)})`);
|
|
168
|
+
}
|
|
169
|
+
lines.push("");
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const budgetNote = plan.core.length ? ` · ${plan.core.length} in core (~${plan.used} of ${plan.budget} tokens always on)` : "";
|
|
174
|
+
lines.push(`_${presets.length} package${presets.length === 1 ? "" : "s"}${budgetNote}${pins.length ? ` · ${pins.length} version pin${pins.length === 1 ? "" : "s"}` : ""} · regenerate with \`agentmd link\`_`);
|
|
175
|
+
return lines.join("\n");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Produce the new contents of an agent config file.
|
|
180
|
+
*
|
|
181
|
+
* Markdown targets get a delimited block spliced in so hand-written content
|
|
182
|
+
* around it survives. The Cursor .mdc file is generated wholesale — agentmd
|
|
183
|
+
* owns that path, and MDC needs frontmatter at the very top.
|
|
184
|
+
*/
|
|
185
|
+
function applyToFile(target, existing, presets, pins = [], opts = {}) {
|
|
186
|
+
const body = renderBody(presets, pins, opts);
|
|
187
|
+
|
|
188
|
+
if (target.format === "mdc") {
|
|
189
|
+
return [
|
|
190
|
+
"---",
|
|
191
|
+
"description: Engineering standards installed by agentmd",
|
|
192
|
+
"alwaysApply: true",
|
|
193
|
+
"---",
|
|
194
|
+
"",
|
|
195
|
+
body,
|
|
196
|
+
"",
|
|
197
|
+
].join("\n");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const block = `${START}\n${body}\n${END}`;
|
|
201
|
+
|
|
202
|
+
if (!existing) return block + "\n";
|
|
203
|
+
|
|
204
|
+
const startAt = existing.indexOf(START);
|
|
205
|
+
const endAt = existing.indexOf(END);
|
|
206
|
+
|
|
207
|
+
if (startAt !== -1 && endAt > startAt) {
|
|
208
|
+
return existing.slice(0, startAt) + block + existing.slice(endAt + END.length);
|
|
209
|
+
}
|
|
210
|
+
// No managed block yet — append, keeping everything the user wrote.
|
|
211
|
+
return existing.replace(/\s*$/, "") + "\n\n" + block + "\n";
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* File globs per category, for Cursor's scoped ("Auto Attached") rules.
|
|
216
|
+
*
|
|
217
|
+
* With the index .mdc alone, Cursor only ever saw a list of links. A scoped
|
|
218
|
+
* rule carries the standard's text and is attached only while a matching
|
|
219
|
+
* file is open — so the Database standard costs nothing while you edit CSS.
|
|
220
|
+
* Categories with no natural file shape get no globs; Cursor then treats the
|
|
221
|
+
* rule as "Agent Requested" and loads it when the description fits the task.
|
|
222
|
+
*/
|
|
223
|
+
const CATEGORY_GLOBS = {
|
|
224
|
+
Database: "**/*.sql,**/*.prisma,**/prisma/**,**/migrations/**,**/db/**,**/models/**,**/*repository*",
|
|
225
|
+
Frontend: "**/*.tsx,**/*.jsx,**/*.vue,**/*.svelte,**/*.css,**/app/**,**/components/**,**/pages/**",
|
|
226
|
+
Design: "**/*.tsx,**/*.jsx,**/*.vue,**/*.svelte,**/*.css,**/styles/**",
|
|
227
|
+
Backend: "**/api/**,**/server/**,**/routes/**,**/controllers/**,**/services/**,**/handlers/**,**/middleware/**,**/*.py,**/*.go",
|
|
228
|
+
API: "**/api/**,**/routes/**,**/openapi*,**/*.graphql,**/schema.*,**/handlers/**",
|
|
229
|
+
Security: "**/auth/**,**/*auth*,**/middleware/**,**/security/**,**/api/**,**/.env*",
|
|
230
|
+
Testing: "**/*.test.*,**/*.spec.*,**/__tests__/**,**/tests/**,**/test/**,**/conftest.py,**/*_test.go",
|
|
231
|
+
DevOps: "**/Dockerfile*,**/docker-compose*,**/.github/workflows/**,**/k8s/**,**/*.tf,**/*.yml,**/*.yaml",
|
|
232
|
+
Performance: "**/*.ts,**/*.tsx,**/*.py,**/*.go,**/*.sql",
|
|
233
|
+
Documentation: "**/*.md,**/docs/**",
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
/** First `description:` line of a preset's frontmatter, or a plain fallback. */
|
|
237
|
+
function presetDescription(content, preset) {
|
|
238
|
+
const m = /^---[\s\S]*?\ndescription:\s*(.+?)\s*\n[\s\S]*?---/m.exec(content);
|
|
239
|
+
return (m ? m[1] : `${preset.category}/${preset.preset} engineering standard`).replace(/\s+/g, " ");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Where each target keeps its on-demand reference files, and the pattern
|
|
244
|
+
* `link` may clean up there. Only conventions the tool actually reads:
|
|
245
|
+
* cursor — Auto Attached .mdc rules by glob
|
|
246
|
+
* claude — .claude/rules/*.md with `paths:` frontmatter (loads when a
|
|
247
|
+
* matching file is read, not at launch)
|
|
248
|
+
* copilot — .github/instructions/*.instructions.md with `applyTo`
|
|
249
|
+
* AGENTS.md and GEMINI.md have no on-demand mechanism; their block links to
|
|
250
|
+
* the installed presets instead.
|
|
251
|
+
*/
|
|
252
|
+
const REFERENCE_DIRS = {
|
|
253
|
+
cursor: { dir: path.join(".cursor", "rules"), pattern: /^agentmd-.+\.mdc$/ },
|
|
254
|
+
claude: { dir: path.join(".claude", "rules"), pattern: /^agentmd-.+\.md$/ },
|
|
255
|
+
copilot: { dir: path.join(".github", "instructions"), pattern: /^agentmd-.+\.instructions\.md$/ },
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const slugOf = (p) => `${p.category}-${p.preset}`.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* One on-demand reference file per installed preset, in the target's own
|
|
262
|
+
* convention. Categories without a file shape (System Design, AI, …) get no
|
|
263
|
+
* reference for Claude or Copilot — an unscoped rule there would load every
|
|
264
|
+
* session and defeat the budget — and stay reachable through the block's links.
|
|
265
|
+
*/
|
|
266
|
+
function referenceRules(presets, readPreset, target = "cursor") {
|
|
267
|
+
const rules = [];
|
|
268
|
+
for (const p of presets) {
|
|
269
|
+
const content = readPreset(p);
|
|
270
|
+
if (!content) continue;
|
|
271
|
+
const globs = CATEGORY_GLOBS[p.category];
|
|
272
|
+
const body = content.replace(/^---[\s\S]*?---\s*/, "");
|
|
273
|
+
const description = presetDescription(content, p);
|
|
274
|
+
const slug = slugOf(p);
|
|
275
|
+
|
|
276
|
+
if (target === "cursor") {
|
|
277
|
+
rules.push({
|
|
278
|
+
file: path.join(REFERENCE_DIRS.cursor.dir, `agentmd-${slug}.mdc`),
|
|
279
|
+
content: ["---", `description: ${description}`, ...(globs ? [`globs: ${globs}`] : []), "alwaysApply: false", "---", "", body, ""].join("\n"),
|
|
280
|
+
});
|
|
281
|
+
} else if (target === "claude" && globs) {
|
|
282
|
+
rules.push({
|
|
283
|
+
file: path.join(REFERENCE_DIRS.claude.dir, `agentmd-${slug}.md`),
|
|
284
|
+
content: ["---", "paths:", ...globs.split(",").map((g) => ` - "${g}"`), "---", "", `# ${idOf(p)}`, "", description, "", body, ""].join("\n"),
|
|
285
|
+
});
|
|
286
|
+
} else if (target === "copilot" && globs) {
|
|
287
|
+
rules.push({
|
|
288
|
+
file: path.join(REFERENCE_DIRS.copilot.dir, `agentmd-${slug}.instructions.md`),
|
|
289
|
+
content: ["---", `applyTo: "${globs}"`, "---", "", `# ${idOf(p)}`, "", body, ""].join("\n"),
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return rules;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Kept for callers that predate referenceRules(); the Cursor shape. */
|
|
297
|
+
function scopedRules(presets, readPreset) {
|
|
298
|
+
return referenceRules(presets, readPreset, "cursor");
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
module.exports = { TARGETS, CATEGORY_GLOBS, REFERENCE_DIRS, ALWAYS_ON_BUDGET, applyToFile, renderBody, planAlwaysOn, coreOf, referenceRules, scopedRules, tokens, START, END };
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* analytics.js
|
|
3
|
+
*
|
|
4
|
+
* A local ledger of what the installed standards actually caught.
|
|
5
|
+
*
|
|
6
|
+
* Every `review` appends one run: when it happened, which standards were
|
|
7
|
+
* loaded, and one row per finding. Nothing leaves the machine — this file is
|
|
8
|
+
* written next to the manifest, inside the project, and is as private as the
|
|
9
|
+
* diff it describes. (Telemetry is a separate, opt-in thing; see lib/telemetry.js.)
|
|
10
|
+
*
|
|
11
|
+
* It exists because "did these rules earn their tokens?" is the question every
|
|
12
|
+
* team asks in week three, and until now the only answer was a shrug. A
|
|
13
|
+
* finding is a moment the agent was about to ship something a standard forbids
|
|
14
|
+
* — an interpolated query, a deprecated call from a training set two years
|
|
15
|
+
* stale — and that is the number worth counting.
|
|
16
|
+
*
|
|
17
|
+
* Recording is free for everyone. `agentmd analytics` reads it back.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
"use strict";
|
|
21
|
+
|
|
22
|
+
const fs = require("fs");
|
|
23
|
+
const path = require("path");
|
|
24
|
+
|
|
25
|
+
const FILE = path.join(".agentmd", "analytics.json");
|
|
26
|
+
/** Runs kept on disk. A year of daily CI on one repo is well under this. */
|
|
27
|
+
const MAX_RUNS = 500;
|
|
28
|
+
|
|
29
|
+
const filePath = (cwd) => path.join(cwd, FILE);
|
|
30
|
+
|
|
31
|
+
/** The ledger, or an empty one. A corrupt file is replaced, never thrown over. */
|
|
32
|
+
function read(cwd = process.cwd()) {
|
|
33
|
+
try {
|
|
34
|
+
const parsed = JSON.parse(fs.readFileSync(filePath(cwd), "utf-8"));
|
|
35
|
+
if (parsed && Array.isArray(parsed.runs)) return parsed;
|
|
36
|
+
} catch {
|
|
37
|
+
// unreadable or malformed — start fresh rather than break `review`
|
|
38
|
+
}
|
|
39
|
+
return { version: 1, runs: [] };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Append one review. Called from `review` after it reports; a failure here
|
|
44
|
+
* must never turn a successful review into a crash, so everything is guarded.
|
|
45
|
+
*/
|
|
46
|
+
function record(cwd, { findings = [], standards = [], mode = "patterns", base = null } = {}) {
|
|
47
|
+
try {
|
|
48
|
+
const ledger = read(cwd);
|
|
49
|
+
ledger.runs.push({
|
|
50
|
+
at: new Date().toISOString(),
|
|
51
|
+
mode,
|
|
52
|
+
base,
|
|
53
|
+
standards: standards.length,
|
|
54
|
+
findings: findings.map((f) => ({
|
|
55
|
+
severity: String(f.severity || "low"),
|
|
56
|
+
standard: String(f.standard || "unknown"),
|
|
57
|
+
rule: String(f.rule || ""),
|
|
58
|
+
file: String(f.file || ""),
|
|
59
|
+
pattern: !!f.pattern,
|
|
60
|
+
})),
|
|
61
|
+
});
|
|
62
|
+
if (ledger.runs.length > MAX_RUNS) ledger.runs = ledger.runs.slice(-MAX_RUNS);
|
|
63
|
+
|
|
64
|
+
fs.mkdirSync(path.dirname(filePath(cwd)), { recursive: true });
|
|
65
|
+
fs.writeFileSync(filePath(cwd), JSON.stringify(ledger, null, 2) + "\n", "utf-8");
|
|
66
|
+
return true;
|
|
67
|
+
} catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Roll the ledger up for reporting.
|
|
74
|
+
*
|
|
75
|
+
* `since` is a Date; runs before it are ignored so `--since 30d` is a filter
|
|
76
|
+
* on the same data rather than a second storage format.
|
|
77
|
+
*/
|
|
78
|
+
function summarise(cwd = process.cwd(), since = null) {
|
|
79
|
+
const ledger = read(cwd);
|
|
80
|
+
const runs = ledger.runs.filter((r) => !since || new Date(r.at) >= since);
|
|
81
|
+
|
|
82
|
+
const byStandard = new Map();
|
|
83
|
+
const bySeverity = { high: 0, medium: 0, low: 0 };
|
|
84
|
+
const byRule = new Map();
|
|
85
|
+
const byWeek = new Map();
|
|
86
|
+
let total = 0;
|
|
87
|
+
let patterns = 0;
|
|
88
|
+
|
|
89
|
+
for (const run of runs) {
|
|
90
|
+
const week = weekOf(new Date(run.at));
|
|
91
|
+
byWeek.set(week, (byWeek.get(week) || 0) + run.findings.length);
|
|
92
|
+
for (const f of run.findings) {
|
|
93
|
+
total++;
|
|
94
|
+
if (f.pattern) patterns++;
|
|
95
|
+
bySeverity[f.severity] = (bySeverity[f.severity] || 0) + 1;
|
|
96
|
+
byStandard.set(f.standard, (byStandard.get(f.standard) || 0) + 1);
|
|
97
|
+
const rule = `${f.standard} › ${f.rule || "—"}`;
|
|
98
|
+
byRule.set(rule, (byRule.get(rule) || 0) + 1);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const sorted = (map) => [...map.entries()].sort((a, b) => b[1] - a[1]);
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
runs: runs.length,
|
|
106
|
+
firstRun: runs[0]?.at ?? null,
|
|
107
|
+
lastRun: runs[runs.length - 1]?.at ?? null,
|
|
108
|
+
total,
|
|
109
|
+
patterns,
|
|
110
|
+
bySeverity,
|
|
111
|
+
byStandard: sorted(byStandard),
|
|
112
|
+
byRule: sorted(byRule),
|
|
113
|
+
byWeek: [...byWeek.entries()].sort(),
|
|
114
|
+
/** Standards that have never caught anything: candidates for removal. */
|
|
115
|
+
idle: (() => {
|
|
116
|
+
const seen = new Set(byStandard.keys());
|
|
117
|
+
return { seen };
|
|
118
|
+
})(),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** ISO-ish week key, `2026-W38`, for the trend column. */
|
|
123
|
+
function weekOf(date) {
|
|
124
|
+
const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
|
125
|
+
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
|
|
126
|
+
const start = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
|
127
|
+
const week = Math.ceil(((d - start) / 86400000 + 1) / 7);
|
|
128
|
+
return `${d.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Findings recorded in the last `days` days — the free one-line footer. */
|
|
132
|
+
function recentCount(cwd = process.cwd(), days = 7) {
|
|
133
|
+
const since = new Date(Date.now() - days * 86400000);
|
|
134
|
+
return summarise(cwd, since).total;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
module.exports = { read, record, summarise, recentCount, weekOf, filePath, FILE, MAX_RUNS };
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* coordinates.js
|
|
3
|
+
*
|
|
4
|
+
* Parsing and did-you-mean help for package coordinates
|
|
5
|
+
* (`<model>/<category>/<preset>`).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
"use strict";
|
|
9
|
+
|
|
10
|
+
const { getAllPresets, listModels } = require("./registry");
|
|
11
|
+
|
|
12
|
+
/** Used when a coordinate names no model. Matches the website's default. */
|
|
13
|
+
const DEFAULT_MODEL = "claude";
|
|
14
|
+
|
|
15
|
+
/** Case- and separator-insensitive membership test against the model list. */
|
|
16
|
+
function knownModel(part) {
|
|
17
|
+
const normalized = String(part || "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
18
|
+
return listModels().some((m) => m.toLowerCase().replace(/[^a-z0-9]/g, "") === normalized);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Split a coordinate string into its parts.
|
|
23
|
+
*
|
|
24
|
+
* Tolerates leading/trailing slashes and stray whitespace, so
|
|
25
|
+
* `claude/Security/`, `/claude/Security`, and `claude / Security` all parse
|
|
26
|
+
* the same way.
|
|
27
|
+
*
|
|
28
|
+
* Two grammars are accepted:
|
|
29
|
+
*
|
|
30
|
+
* claude/Security/owasp model-first, the original form
|
|
31
|
+
* Security/owasp model-less, resolved against the default model
|
|
32
|
+
*
|
|
33
|
+
* The model-less form is what the website now shows and what detect.js has
|
|
34
|
+
* always emitted for recommendations. It is distinguished by testing whether
|
|
35
|
+
* the first segment actually names a model family, not by counting segments —
|
|
36
|
+
* `claude/Security` and `Security/owasp` are both two parts and mean different
|
|
37
|
+
* things.
|
|
38
|
+
*
|
|
39
|
+
* `explicitModel` records which grammar was used, so callers can tell "the
|
|
40
|
+
* user asked for claude" from "nobody said, so we assumed claude".
|
|
41
|
+
*/
|
|
42
|
+
function parseCoordinates(raw) {
|
|
43
|
+
const parts = String(raw || "")
|
|
44
|
+
.split("/")
|
|
45
|
+
.map((p) => p.trim())
|
|
46
|
+
.filter(Boolean);
|
|
47
|
+
|
|
48
|
+
const modelFirst = parts.length === 0 || knownModel(parts[0]);
|
|
49
|
+
const [model, category, preset] = modelFirst
|
|
50
|
+
? parts
|
|
51
|
+
: [DEFAULT_MODEL, ...parts];
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
raw: String(raw || ""),
|
|
55
|
+
model: model || null,
|
|
56
|
+
category: category || null,
|
|
57
|
+
preset: preset || null,
|
|
58
|
+
// Depth stays the caller-visible shape: how many coordinate levels were
|
|
59
|
+
// resolved, so progressive browsing (`list claude/Security`) is unchanged.
|
|
60
|
+
depth: modelFirst ? parts.length : parts.length + 1,
|
|
61
|
+
explicitModel: modelFirst && parts.length > 0,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Levenshtein distance, capped — used only for short package names. */
|
|
66
|
+
function distance(a, b) {
|
|
67
|
+
if (a === b) return 0;
|
|
68
|
+
const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
69
|
+
for (let i = 1; i <= a.length; i++) {
|
|
70
|
+
let diagonal = prev[0];
|
|
71
|
+
prev[0] = i;
|
|
72
|
+
for (let j = 1; j <= b.length; j++) {
|
|
73
|
+
const carry = prev[j];
|
|
74
|
+
prev[j] = Math.min(
|
|
75
|
+
prev[j] + 1,
|
|
76
|
+
prev[j - 1] + 1,
|
|
77
|
+
diagonal + (a[i - 1] === b[j - 1] ? 0 : 1)
|
|
78
|
+
);
|
|
79
|
+
diagonal = carry;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return prev[b.length];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Closest package names to a typo'd one, for "did you mean" output.
|
|
87
|
+
* Only suggests names within a distance that scales with length, so a short
|
|
88
|
+
* query doesn't match half the registry.
|
|
89
|
+
*/
|
|
90
|
+
function suggest(model, name, limit = 3) {
|
|
91
|
+
const query = String(name || "").toLowerCase();
|
|
92
|
+
if (!query) return [];
|
|
93
|
+
const threshold = Math.max(2, Math.floor(query.length / 3));
|
|
94
|
+
|
|
95
|
+
return getAllPresets(model)
|
|
96
|
+
.map((entry) => ({ entry, d: distance(query, entry.preset.toLowerCase()) }))
|
|
97
|
+
.filter(({ entry, d }) => d <= threshold || entry.preset.toLowerCase().includes(query))
|
|
98
|
+
.sort((a, b) => a.d - b.d)
|
|
99
|
+
.slice(0, limit)
|
|
100
|
+
.map(({ entry }) => entry);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = { parseCoordinates, suggest, distance, listModels, knownModel, DEFAULT_MODEL };
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* credentials.js
|
|
3
|
+
*
|
|
4
|
+
* Where the license key lives between runs.
|
|
5
|
+
*
|
|
6
|
+
* A mode-0600 JSON file in the user's config directory, which is what npm,
|
|
7
|
+
* `gh`, Stripe and Vercel all do. `keytar` was the other candidate and was
|
|
8
|
+
* rejected: it is a native module, so it needs a toolchain or a prebuilt
|
|
9
|
+
* binary for every platform, and it is the single most common reason a CLI
|
|
10
|
+
* fails to install in Alpine, in a slim Docker image, or on a CI runner
|
|
11
|
+
* without libsecret. A package manager for supply-chain-sensitive content
|
|
12
|
+
* should not add a node-gyp dependency to store one string.
|
|
13
|
+
*
|
|
14
|
+
* Resolution order, highest first:
|
|
15
|
+
*
|
|
16
|
+
* 1. AGENTMD_PRO_KEY — CI and one-off runs, nothing touches disk
|
|
17
|
+
* 2. .agentmd/enterprise.json — a key committed by the company for its repo
|
|
18
|
+
* 3. ~/.agentmd/credentials.json (0600)
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
"use strict";
|
|
22
|
+
|
|
23
|
+
const fs = require("fs");
|
|
24
|
+
const os = require("os");
|
|
25
|
+
const path = require("path");
|
|
26
|
+
|
|
27
|
+
const FILE = "credentials.json";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The config directory. Honours AGENTMD_CONFIG_HOME first (tests and
|
|
31
|
+
* containers), then XDG_CONFIG_HOME, then ~/.agentmd.
|
|
32
|
+
*/
|
|
33
|
+
function configDir() {
|
|
34
|
+
if (process.env.AGENTMD_CONFIG_HOME) return path.resolve(process.env.AGENTMD_CONFIG_HOME);
|
|
35
|
+
if (process.env.XDG_CONFIG_HOME) return path.join(path.resolve(process.env.XDG_CONFIG_HOME), "agentmd");
|
|
36
|
+
return path.join(os.homedir(), ".agentmd");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const credentialsPath = () => path.join(configDir(), FILE);
|
|
40
|
+
|
|
41
|
+
/** Stored credentials, or null. A corrupt file reads as "not signed in". */
|
|
42
|
+
function read() {
|
|
43
|
+
try {
|
|
44
|
+
const parsed = JSON.parse(fs.readFileSync(credentialsPath(), "utf-8"));
|
|
45
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Write credentials with owner-only permissions.
|
|
53
|
+
*
|
|
54
|
+
* The mode is set on the descriptor as the file is created, not with a
|
|
55
|
+
* chmod afterwards: between the two there is a window where the key is
|
|
56
|
+
* world-readable, and on a shared CI box that window is the whole exposure.
|
|
57
|
+
*/
|
|
58
|
+
function write(data) {
|
|
59
|
+
const dir = configDir();
|
|
60
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
61
|
+
const file = credentialsPath();
|
|
62
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
63
|
+
fs.writeFileSync(tmp, JSON.stringify({ ...data, updatedAt: new Date().toISOString() }, null, 2) + "\n", {
|
|
64
|
+
mode: 0o600,
|
|
65
|
+
});
|
|
66
|
+
fs.renameSync(tmp, file); // atomic: a half-written file never becomes the real one
|
|
67
|
+
return file;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Remove stored credentials. Returns true when there was something to remove. */
|
|
71
|
+
function clear() {
|
|
72
|
+
try {
|
|
73
|
+
fs.unlinkSync(credentialsPath());
|
|
74
|
+
return true;
|
|
75
|
+
} catch (err) {
|
|
76
|
+
if (err.code === "ENOENT") return false;
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The key to use, and where it came from. `source` is worth carrying: when a
|
|
83
|
+
* key is rejected, the first question is always which key was used.
|
|
84
|
+
*/
|
|
85
|
+
function resolveKey(cwd = process.cwd()) {
|
|
86
|
+
const fromEnv = process.env.AGENTMD_PRO_KEY;
|
|
87
|
+
if (fromEnv) return { key: fromEnv.trim(), source: "AGENTMD_PRO_KEY" };
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const enterprise = JSON.parse(fs.readFileSync(path.join(cwd, ".agentmd", "enterprise.json"), "utf-8"));
|
|
91
|
+
if (enterprise && typeof enterprise.licenseKey === "string" && enterprise.licenseKey) {
|
|
92
|
+
return { key: enterprise.licenseKey.trim(), source: ".agentmd/enterprise.json" };
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
// no enterprise config, or it is not readable — fall through
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const stored = read();
|
|
99
|
+
if (stored && typeof stored.key === "string" && stored.key) {
|
|
100
|
+
return { key: stored.key, source: credentialsPath() };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { key: null, source: null };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Permissions as a string ("600"), or null when the file is absent. */
|
|
107
|
+
function permissions() {
|
|
108
|
+
try {
|
|
109
|
+
return (fs.statSync(credentialsPath()).mode & 0o777).toString(8).padStart(3, "0");
|
|
110
|
+
} catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = { read, write, clear, resolveKey, configDir, credentialsPath, permissions };
|