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,293 @@
1
+ /**
2
+ * review.js
3
+ *
4
+ * `agentmd review` — check a diff against the standards installed in this
5
+ * project. Turns the installed markdown from passive text into an active
6
+ * gate: locally before a commit, or in CI on every pull request.
7
+ *
8
+ * agentmd review working tree vs HEAD
9
+ * agentmd review --base origin/main the branch's changes (CI)
10
+ * agentmd review --fail-on=high exit 1 if a high-severity finding exists
11
+ * agentmd review --github also post the report as a PR comment
12
+ * agentmd review --fast deterministic patterns only: free, offline, milliseconds
13
+ * agentmd review --local judge with a local Ollama model instead of the cloud
14
+ *
15
+ * Every run starts with the pattern fast-path (no network). The cloud path
16
+ * needs ANTHROPIC_API_KEY (or an `ant auth login` profile) and sends only the
17
+ * diff and the installed standards; --local sends them to Ollama on this
18
+ * machine instead, so nothing leaves it.
19
+ */
20
+
21
+ "use strict";
22
+
23
+ const fs = require("fs");
24
+ const path = require("path");
25
+ const { execFileSync } = require("child_process");
26
+ const pc = require("picocolors");
27
+ const { readManifest, resolvePresetPath } = require("../lib/manifest");
28
+ const { cmd } = require("../lib/invocation");
29
+ const analytics = require("../lib/analytics");
30
+ const { track, EVENTS } = require("../lib/telemetry");
31
+ const { runPatterns } = require("../lib/patterns");
32
+
33
+ const A = cmd();
34
+ const MODEL = process.env.AGENTMD_REVIEW_MODEL || "claude-opus-5";
35
+ const LOCAL_MODEL = process.env.AGENTMD_LOCAL_MODEL || "qwen2.5-coder:7b";
36
+ const OLLAMA = (process.env.OLLAMA_HOST || "http://localhost:11434").replace(/\/$/, "");
37
+ const MAX_STANDARDS_CHARS = 160_000;
38
+ const SEVERITIES = ["low", "medium", "high"];
39
+
40
+ function flagValue(args, name) {
41
+ const withEq = args.find((a) => a.startsWith(`--${name}=`));
42
+ if (withEq) return withEq.slice(name.length + 3);
43
+ const i = args.indexOf(`--${name}`);
44
+ return i !== -1 && args[i + 1] && !args[i + 1].startsWith("--") ? args[i + 1] : null;
45
+ }
46
+
47
+ function git(cwd, ...argv) {
48
+ return execFileSync("git", argv, { cwd, encoding: "utf-8", maxBuffer: 64 * 1024 * 1024 });
49
+ }
50
+
51
+ /** Installed standards, largest-first trimmed to a budget so the request stays bounded. */
52
+ function loadStandards(cwd, manifest, changedFiles) {
53
+ const all = manifest.presets.map((p) => {
54
+ let text = "";
55
+ try { text = fs.readFileSync(resolvePresetPath(cwd, p.file), "utf-8"); } catch {}
56
+ return { id: `${p.category}/${p.preset}`, category: p.category, text };
57
+ }).filter((s) => s.text);
58
+
59
+ // Standards whose category plausibly matches the touched files go first, so
60
+ // if we must drop some, we drop the least relevant.
61
+ const touched = changedFiles.join("\n").toLowerCase();
62
+ const relevance = (s) => (touched.includes(s.category.toLowerCase().split(" ")[0]) ? 0 : 1);
63
+ all.sort((a, b) => relevance(a) - relevance(b) || a.id.localeCompare(b.id));
64
+
65
+ const kept = [], dropped = [];
66
+ let used = 0;
67
+ for (const s of all) {
68
+ if (used + s.text.length > MAX_STANDARDS_CHARS) { dropped.push(s.id); continue; }
69
+ kept.push(s); used += s.text.length;
70
+ }
71
+ return { kept, dropped };
72
+ }
73
+
74
+ function parseFindings(text) {
75
+ const m = /\{[\s\S]*\}/.exec(text);
76
+ if (!m) return null;
77
+ try {
78
+ const obj = JSON.parse(m[0]);
79
+ return Array.isArray(obj.findings) ? obj : null;
80
+ } catch { return null; }
81
+ }
82
+
83
+ async function postGithubComment(body) {
84
+ const token = process.env.GITHUB_TOKEN;
85
+ const repo = process.env.GITHUB_REPOSITORY;
86
+ const eventPath = process.env.GITHUB_EVENT_PATH;
87
+ if (!token || !repo || !eventPath) throw new Error("--github needs GITHUB_TOKEN, GITHUB_REPOSITORY and GITHUB_EVENT_PATH (set automatically in GitHub Actions)");
88
+ const event = JSON.parse(fs.readFileSync(eventPath, "utf-8"));
89
+ const number = event.pull_request?.number ?? event.issue?.number;
90
+ if (!number) throw new Error("--github: no pull request number in the event payload");
91
+ const res = await fetch(`https://api.github.com/repos/${repo}/issues/${number}/comments`, {
92
+ method: "POST",
93
+ headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json", "Content-Type": "application/json" },
94
+ body: JSON.stringify({ body }),
95
+ });
96
+ if (!res.ok) throw new Error(`GitHub API ${res.status}: ${await res.text()}`);
97
+ }
98
+
99
+ async function review(args) {
100
+ args = args || [];
101
+ const cwd = process.cwd();
102
+ const base = flagValue(args, "base");
103
+ const failOn = flagValue(args, "fail-on");
104
+ const toGithub = args.includes("--github");
105
+ const fast = args.includes("--fast");
106
+ const local = args.includes("--local");
107
+ const modelName = flagValue(args, "model") || (local ? LOCAL_MODEL : MODEL);
108
+ if (failOn && !SEVERITIES.includes(failOn)) {
109
+ console.error(pc.red(`--fail-on must be one of ${SEVERITIES.join(", ")}`));
110
+ process.exit(2);
111
+ }
112
+
113
+ const manifest = readManifest(cwd);
114
+ if (manifest.presets.length === 0) {
115
+ console.log(pc.yellow("\nNo packages installed — nothing to review against."));
116
+ console.log(pc.dim(` Run '${A} init' first.\n`));
117
+ return;
118
+ }
119
+
120
+ let diff, changedFiles;
121
+ try {
122
+ if (base) {
123
+ diff = git(cwd, "diff", `${base}...HEAD`);
124
+ changedFiles = git(cwd, "diff", "--name-only", `${base}...HEAD`).split("\n").filter(Boolean);
125
+ } else {
126
+ // Working tree vs HEAD, plus untracked files — a brand-new file is
127
+ // exactly what most needs reviewing, and plain `git diff` skips it.
128
+ diff = git(cwd, "diff", "HEAD");
129
+ changedFiles = git(cwd, "diff", "--name-only", "HEAD").split("\n").filter(Boolean);
130
+ const untracked = git(cwd, "ls-files", "--others", "--exclude-standard").split("\n").filter(Boolean);
131
+ for (const f of untracked) {
132
+ try { git(cwd, "diff", "--no-index", "--", "/dev/null", f); }
133
+ catch (e) { if (e.stdout) diff += e.stdout; } // --no-index exits 1 when the files differ
134
+ changedFiles.push(f);
135
+ }
136
+ }
137
+ } catch (e) {
138
+ console.error(pc.red(`git diff failed: ${e.message.split("\n")[0]}`));
139
+ process.exit(2);
140
+ }
141
+ if (!diff.trim()) {
142
+ console.log(pc.dim(`\nNo changes ${base ? `since ${base}` : "in the working tree"} — nothing to review.\n`));
143
+ return;
144
+ }
145
+
146
+ const { kept, dropped } = loadStandards(cwd, manifest, changedFiles);
147
+ const installedIds = new Set(manifest.presets.map((p) => `${p.category}/${p.preset}`));
148
+
149
+ // Fast-path: deterministic patterns, always, before any model.
150
+ const patternFindings = runPatterns(diff, installedIds);
151
+
152
+ console.log(pc.bold(`\nReviewing ${pc.cyan(changedFiles.length)} changed file${changedFiles.length === 1 ? "" : "s"} against ${pc.cyan(kept.length)} standard${kept.length === 1 ? "" : "s"}`) + pc.dim(fast ? " (patterns only)" : ` (${modelName}${local ? ", local" : ""})`));
153
+ if (dropped.length) console.log(pc.yellow(` ${dropped.length} standard${dropped.length === 1 ? "" : "s"} left out to fit the request: ${dropped.join(", ")}`));
154
+ if (patternFindings.length) console.log(pc.dim(` ${patternFindings.length} finding${patternFindings.length === 1 ? "" : "s"} from the pattern fast-path`));
155
+
156
+ if (fast) {
157
+ return report({ findings: patternFindings, summary: patternFindings.length ? "Pattern checks only — run without --fast for a full review." : "No pattern violations. Run without --fast for a full review." }, kept, "patterns", toGithub, failOn);
158
+ }
159
+
160
+ let Anthropic = null, client = null;
161
+ if (!local) {
162
+ try { Anthropic = require("@anthropic-ai/sdk"); } catch {
163
+ console.error(pc.red("@anthropic-ai/sdk is not installed — reinstall activate-agentmd."));
164
+ process.exit(2);
165
+ }
166
+ client = new Anthropic();
167
+ }
168
+
169
+ const system = [
170
+ "You are a strict code reviewer. You are given engineering standards the project has adopted, then a diff.",
171
+ "Report only violations of those standards that are present in the diff. Do not invent rules; do not comment on style the standards do not cover; do not praise.",
172
+ "For every finding cite the standard id and the exact heading of the rule it breaks.",
173
+ 'Reply with JSON only, no prose: {"findings":[{"file":"path","line":123,"severity":"low|medium|high","standard":"Category/preset","rule":"heading text","issue":"what is wrong","fix":"smallest change that complies"}],"summary":"one sentence"}',
174
+ "If nothing violates a standard, reply {\"findings\":[],\"summary\":\"No violations of the installed standards.\"}.",
175
+ ].join("\n");
176
+
177
+ const standardsText = kept.map((s) => `<standard id="${s.id}">\n${s.text}\n</standard>`).join("\n\n");
178
+
179
+ const userMessage = `Changed files:\n${changedFiles.join("\n")}\n\n<diff>\n${diff}\n</diff>`;
180
+
181
+ // Local path: Ollama's chat endpoint on this machine. No key, no egress.
182
+ if (local) {
183
+ let text;
184
+ try {
185
+ const res = await fetch(`${OLLAMA}/api/chat`, {
186
+ method: "POST", headers: { "Content-Type": "application/json" },
187
+ body: JSON.stringify({ model: modelName, stream: false, format: "json", options: { num_ctx: 32768 },
188
+ messages: [{ role: "system", content: `${system}\n\n${standardsText}` }, { role: "user", content: userMessage }] }),
189
+ });
190
+ if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
191
+ text = (await res.json()).message?.content ?? "";
192
+ } catch (e) {
193
+ console.error(pc.red(`Ollama at ${OLLAMA} failed: ${e.message.split("\n")[0]}`));
194
+ console.error(pc.dim(` Is it running, and is "${modelName}" pulled? (ollama pull ${modelName})`));
195
+ process.exit(2);
196
+ }
197
+ const parsed = parseFindings(text);
198
+ if (!parsed) { console.log(pc.yellow("\nCould not parse the local model's reply as JSON; raw output follows.\n")); console.log(text); process.exit(2); }
199
+ return report(merge(patternFindings, parsed), kept, modelName, toGithub, failOn);
200
+ }
201
+
202
+ let response;
203
+ try {
204
+ response = await client.beta.messages.create({
205
+ model: modelName,
206
+ max_tokens: 16000,
207
+ betas: ["server-side-fallback-2026-07-01"],
208
+ fallbacks: "default",
209
+ system: [
210
+ { type: "text", text: system },
211
+ // The standards are the stable prefix; cache them so CI runs on the
212
+ // same repo pay for them once.
213
+ { type: "text", text: standardsText, cache_control: { type: "ephemeral" } },
214
+ ],
215
+ messages: [{ role: "user", content: userMessage }],
216
+ });
217
+ } catch (e) {
218
+ if (e instanceof Anthropic.AuthenticationError || /authentication method|api ?key/i.test(e.message)) {
219
+ console.error(pc.red("No API credentials. Set ANTHROPIC_API_KEY (or run `ant auth login`) and try again."));
220
+ }
221
+ else if (e instanceof Anthropic.RateLimitError) console.error(pc.red("Rate limited — retry in a moment."));
222
+ else console.error(pc.red(`Review failed: ${e.message}`));
223
+ process.exit(2);
224
+ }
225
+
226
+ if (response.stop_reason === "refusal") {
227
+ console.error(pc.red("The model declined to review this diff" + (response.stop_details?.explanation ? `: ${response.stop_details.explanation}` : ".")));
228
+ process.exit(2);
229
+ }
230
+
231
+ const text = response.content.filter((b) => b.type === "text").map((b) => b.text).join("\n");
232
+ const parsed = parseFindings(text);
233
+ if (!parsed) {
234
+ console.log(pc.yellow("\nCould not parse the review as JSON; raw output follows.\n"));
235
+ console.log(text);
236
+ process.exit(2);
237
+ }
238
+ return report(merge(patternFindings, parsed), kept, modelName, toGithub, failOn);
239
+ }
240
+
241
+ /** Pattern findings first; model findings that duplicate one (same file, line, standard) are dropped. */
242
+ function merge(patternFindings, parsed) {
243
+ const seen = new Set(patternFindings.map((f) => `${f.file}:${f.line}:${f.standard}`));
244
+ const rest = (parsed.findings || []).filter((f) => !seen.has(`${f.file}:${f.line}:${f.standard}`));
245
+ return { findings: [...patternFindings, ...rest], summary: parsed.summary };
246
+ }
247
+
248
+ async function report(parsed, kept, modelName, toGithub, failOn) {
249
+ const order = { high: 0, medium: 1, low: 2 };
250
+ const findings = parsed.findings.slice().sort((a, b) => (order[a.severity] ?? 3) - (order[b.severity] ?? 3));
251
+
252
+ // Every run is recorded locally, free, so `analytics` can answer "did these
253
+ // rules earn their tokens?" later. Nothing here leaves the machine.
254
+ analytics.record(process.cwd(), { findings, standards: kept, mode: modelName });
255
+ if (findings.length) track(EVENTS.HALLUCINATIONS_BLOCKED, { findings: findings.length, command: "review" });
256
+ const paint = { high: pc.red, medium: pc.yellow, low: pc.dim };
257
+
258
+ console.log("");
259
+ if (findings.length === 0) {
260
+ console.log(` ${pc.green("✓")} ${parsed.summary || "No violations of the installed standards."}`);
261
+ } else {
262
+ for (const f of findings) {
263
+ const sev = (paint[f.severity] || pc.white)(String(f.severity || "?").toUpperCase().padEnd(6));
264
+ console.log(` ${sev} ${pc.white(f.file)}${f.line ? pc.dim(":" + f.line) : ""} ${pc.dim(f.standard + " › " + f.rule)}${f.pattern ? pc.dim(" [pattern]") : ""}`);
265
+ console.log(` ${f.issue}`);
266
+ if (f.fix) console.log(` ${pc.dim("fix:")} ${f.fix}`);
267
+ }
268
+ console.log("");
269
+ console.log(` ${findings.length} finding${findings.length === 1 ? "" : "s"}${parsed.summary ? " — " + parsed.summary : ""}`);
270
+ }
271
+ console.log("");
272
+
273
+ if (toGithub) {
274
+ const lines = findings.length === 0
275
+ ? [`✅ **${A} review** — ${parsed.summary || "no violations of the installed standards."}`]
276
+ : [`### ${A} review — ${findings.length} finding${findings.length === 1 ? "" : "s"}`, "", "| Severity | File | Standard › rule | Issue | Fix |", "|---|---|---|---|---|",
277
+ ...findings.map((f) => `| ${f.severity} | \`${f.file}${f.line ? ":" + f.line : ""}\` | ${f.standard} › ${f.rule} | ${String(f.issue).replace(/\|/g, "\\|")} | ${String(f.fix || "").replace(/\|/g, "\\|")} |`),
278
+ "", `_${kept.length} standards checked · ${modelName}_`];
279
+ try { await postGithubComment(lines.join("\n")); console.log(pc.dim(" Posted to the pull request.\n")); }
280
+ catch (e) { console.error(pc.red(` Could not post to GitHub: ${e.message}`)); }
281
+ }
282
+
283
+ if (failOn) {
284
+ const threshold = SEVERITIES.indexOf(failOn);
285
+ const failing = findings.filter((f) => SEVERITIES.indexOf(f.severity) >= threshold);
286
+ if (failing.length) {
287
+ console.error(pc.red(` ${failing.length} finding${failing.length === 1 ? "" : "s"} at or above "${failOn}" — failing.\n`));
288
+ process.exit(1);
289
+ }
290
+ }
291
+ }
292
+
293
+ module.exports = { review };
@@ -0,0 +1,110 @@
1
+ /**
2
+ * search.js
3
+ *
4
+ * Handles: agentmd search <query> [--model=<name>] [--category=<name>] [--limit=<n>]
5
+ *
6
+ * Searches package names, categories and descriptions. Results are ranked by
7
+ * how well they match (see registry.searchPresets) rather than by directory
8
+ * order, so `search jwt` leads with Security/jwt.
9
+ *
10
+ * By default it searches one model — the same package exists for every model
11
+ * family, so showing each copy would multiply the output for no extra
12
+ * information.
13
+ */
14
+
15
+ "use strict";
16
+
17
+ const pc = require("picocolors");
18
+ const { searchPresets, listModels } = require("../lib/registry");
19
+ const { cmd } = require("../lib/invocation");
20
+
21
+ /** How the user invoked us — "npx activate-agentmd" or "agentmd". */
22
+ const A = cmd();
23
+
24
+ const DEFAULT_MODEL = "claude";
25
+ const DEFAULT_LIMIT = 20;
26
+
27
+ function flagValue(args, name) {
28
+ const hit = args.find((a) => a.startsWith(`--${name}=`));
29
+ return hit ? hit.slice(name.length + 3) : null;
30
+ }
31
+
32
+ function search(args) {
33
+ args = args || [];
34
+ const query = args.filter((a) => !a.startsWith("--")).join(" ");
35
+
36
+ if (!query) {
37
+ console.error(pc.red(`Usage: ${A} search <query>`));
38
+ console.error(pc.dim(` ${A} search authentication`));
39
+ console.error(pc.dim(` ${A} search caching --category=Performance`));
40
+ console.error(pc.dim(` ${A} search docker --model=gemini`));
41
+ process.exit(1);
42
+ }
43
+
44
+ const allModels = args.includes("--all-models");
45
+ const model = flagValue(args, "model") || DEFAULT_MODEL;
46
+ const category = flagValue(args, "category");
47
+ const limit = Number(flagValue(args, "limit")) || DEFAULT_LIMIT;
48
+
49
+ const models = allModels ? listModels() : [model];
50
+ for (const m of models) {
51
+ if (!listModels().includes(m)) {
52
+ console.error(pc.red(`Unknown model: "${m}"`));
53
+ console.error(pc.dim(` Available: ${listModels().join(", ")}`));
54
+ process.exit(1);
55
+ }
56
+ }
57
+
58
+ // Every family ships the same package set, so searching all of them returned
59
+ // 11 identical rows per hit and a match count 11× the truth. Collapse on
60
+ // Category/preset and keep the first — the package is what matched, not the
61
+ // family it happens to be phrased for.
62
+ let results = models.flatMap((m) => searchPresets(m, query));
63
+ if (allModels) {
64
+ const seen = new Set();
65
+ results = results.filter((r) => {
66
+ const key = `${r.category}/${r.preset}`.toLowerCase();
67
+ if (seen.has(key)) return false;
68
+ seen.add(key);
69
+ return true;
70
+ });
71
+ }
72
+ if (category) {
73
+ results = results.filter((r) => r.category.toLowerCase() === category.toLowerCase());
74
+ }
75
+
76
+ if (results.length === 0) {
77
+ console.log(pc.yellow(`\nNothing matched "${query}"` + (category ? ` in ${category}` : "")));
78
+ console.log("");
79
+ console.log(pc.dim(` Browse categories: ${A} list ` + model));
80
+ console.log(pc.dim(` Scan this project: ${A} init\n`));
81
+ return;
82
+ }
83
+
84
+ const shown = results.slice(0, limit);
85
+ console.log(
86
+ pc.bold(`\n${results.length} match${results.length === 1 ? "" : "es"} for "${pc.cyan(query)}"`) +
87
+ (results.length > shown.length ? pc.dim(` (showing ${shown.length})`) : "") +
88
+ "\n"
89
+ );
90
+
91
+ const width = Math.max(...shown.map((r) => (r.category + "/" + r.preset).length));
92
+ for (const entry of shown) {
93
+ const coordinate = `${entry.category}/${entry.preset}`;
94
+ const description = entry.description ? pc.dim(" · " + entry.description.slice(0, 60)) : "";
95
+ console.log(` ${pc.white(coordinate.padEnd(width))} ${pc.dim("v" + entry.version)}${description}`);
96
+ }
97
+
98
+ console.log("");
99
+ console.log(pc.dim(` Details: ${A} info ${shown[0].id}`));
100
+ console.log(pc.dim(` Install: ${A} install ${shown[0].id}`));
101
+ if (results.length > shown.length) {
102
+ console.log(pc.dim(` More: ${A} search ${query} --limit=${results.length}`));
103
+ }
104
+ if (!allModels) {
105
+ console.log(pc.dim(` Searched ${model}; add --all-models to search all ${listModels().length}.`));
106
+ }
107
+ console.log("");
108
+ }
109
+
110
+ module.exports = { search };
@@ -0,0 +1,245 @@
1
+ /**
2
+ * sync.js
3
+ *
4
+ * Handles: agentmd sync --private [--push|--pull] [--dry] (Pro)
5
+ *
6
+ * Your company's own standards, shared the way the public ones are.
7
+ *
8
+ * The public registry covers what everyone needs. What it cannot cover is the
9
+ * rule your team argues about in every review — the one about which service
10
+ * owns writes to `orders`, or why every handler takes a request id. Those live
11
+ * in `.agentmd/presets/local-*.md` and `.agentmd/overrides/`, and until now
12
+ * they only existed in whichever repo someone first wrote them in.
13
+ *
14
+ * A private registry is a place the team can put them. It is deliberately
15
+ * boring: a directory of markdown, reachable either over HTTPS or as a path
16
+ * on a shared volume. A git repo on a network share works. An S3 bucket
17
+ * mounted with s3fs works. No service to run, and if we disappear tomorrow
18
+ * the files are still just files.
19
+ */
20
+
21
+ "use strict";
22
+
23
+ const fs = require("fs");
24
+ const path = require("path");
25
+ const pc = require("picocolors");
26
+ const { load, ssoStatus, FILE: ENTERPRISE_FILE } = require("../lib/enterprise");
27
+ const { fetchText } = require("../lib/fetcher");
28
+ const { checksum, resolveInProject } = require("../lib/manifest");
29
+ const { cmd } = require("../lib/invocation");
30
+
31
+ const A = cmd();
32
+
33
+ /** Where a project's own standards live, relative to the project root. */
34
+ const LOCAL_DIRS = [path.join(".agentmd", "presets"), path.join(".agentmd", "overrides")];
35
+ /** Only a project's *own* standards sync; registry copies come from the registry. */
36
+ const LOCAL_PREFIX = "local-";
37
+
38
+ const has = (args, name) => (args || []).includes(`--${name}`);
39
+
40
+ /** Files this project owns and would push. */
41
+ function localStandards(cwd) {
42
+ const out = [];
43
+ for (const dir of LOCAL_DIRS) {
44
+ const full = path.join(cwd, dir);
45
+ let entries = [];
46
+ try { entries = fs.readdirSync(full); } catch { continue; }
47
+ for (const name of entries) {
48
+ if (!name.endsWith(".md")) continue;
49
+ // Presets carry registry copies too; only the local- ones are ours.
50
+ if (dir.endsWith("presets") && !name.startsWith(LOCAL_PREFIX)) continue;
51
+ const file = path.join(full, name);
52
+ const content = fs.readFileSync(file, "utf-8");
53
+ out.push({ name, rel: path.join(dir, name), content, checksum: checksum(content), bytes: Buffer.byteLength(content) });
54
+ }
55
+ }
56
+ return out.sort((a, b) => a.rel.localeCompare(b.rel));
57
+ }
58
+
59
+ /** An https endpoint, or a directory on disk. Both are first-class. */
60
+ const isHttp = (target) => /^https:\/\//i.test(target);
61
+
62
+ async function readRemote(target, name) {
63
+ if (isHttp(target)) {
64
+ try {
65
+ return await fetchText(`${target.replace(/\/$/, "")}/${encodeURIComponent(name)}`);
66
+ } catch (err) {
67
+ if (/HTTP 404/.test(err.message)) return null;
68
+ throw err;
69
+ }
70
+ }
71
+ try {
72
+ return fs.readFileSync(path.join(target, name), "utf-8");
73
+ } catch (err) {
74
+ if (err.code === "ENOENT") return null;
75
+ throw err;
76
+ }
77
+ }
78
+
79
+ async function listRemote(target) {
80
+ if (isHttp(target)) {
81
+ // An index.json is the contract for an HTTPS registry: a plain list of
82
+ // filenames. It keeps the server a static file host rather than an API.
83
+ try {
84
+ const raw = await fetchText(`${target.replace(/\/$/, "")}/index.json`);
85
+ const parsed = JSON.parse(raw);
86
+ return Array.isArray(parsed) ? parsed : Array.isArray(parsed.standards) ? parsed.standards : [];
87
+ } catch {
88
+ return null; // no index — pull cannot enumerate, push still can
89
+ }
90
+ }
91
+ try {
92
+ return fs.readdirSync(target).filter((f) => f.endsWith(".md"));
93
+ } catch (err) {
94
+ if (err.code === "ENOENT") return [];
95
+ throw err;
96
+ }
97
+ }
98
+
99
+ function writeRemote(target, name, content) {
100
+ if (isHttp(target)) {
101
+ // Deliberately not implemented: an unauthenticated PUT to a company's
102
+ // standards registry is a supply-chain hole, and every host does auth
103
+ // differently. Push to the filesystem path — a git checkout, a mounted
104
+ // share — and let the existing review process publish it.
105
+ throw new Error(
106
+ `Pushing over HTTPS is not supported. Point \`privateRegistry\` at a directory ` +
107
+ `(a git checkout or a mounted share) to push, and serve that directory over HTTPS for pulls.`
108
+ );
109
+ }
110
+ fs.mkdirSync(target, { recursive: true });
111
+ fs.writeFileSync(path.join(target, name), content, "utf-8");
112
+ }
113
+
114
+ async function sync(args) {
115
+ args = args || [];
116
+ const cwd = process.cwd();
117
+ const dry = has(args, "dry");
118
+ const push = has(args, "push");
119
+ const pull = has(args, "pull");
120
+
121
+ if (!has(args, "private")) {
122
+ console.error(pc.red(`\n ${A} sync needs --private.`));
123
+ console.error(pc.dim(" It is the only mode: your own standards, to and from your own registry.\n"));
124
+ process.exitCode = 1;
125
+ return;
126
+ }
127
+ if (push && pull) {
128
+ console.error(pc.red("\n --push and --pull are opposites. Pick one, or neither to see the difference.\n"));
129
+ process.exitCode = 1;
130
+ return;
131
+ }
132
+
133
+ const { config, problems } = load(cwd);
134
+ for (const p of problems) console.error(pc.yellow(` ! ${p}`));
135
+
136
+ const target = config.privateRegistry;
137
+ if (!target) {
138
+ console.error(pc.red(`\n No private registry configured.`));
139
+ console.error(pc.dim(` Add one to ${ENTERPRISE_FILE}:\n`));
140
+ console.error(pc.dim(` { "privateRegistry": "/mnt/team/agentmd-standards" }\n`));
141
+ console.error(pc.dim(` or set AGENTMD_PRIVATE_REGISTRY. An https URL for pulls, a directory for both.\n`));
142
+ process.exitCode = 1;
143
+ return;
144
+ }
145
+
146
+ const sso = ssoStatus(config);
147
+ if (!sso.satisfied) {
148
+ console.error(pc.red(`\n ${sso.hint}\n`));
149
+ process.exitCode = 1;
150
+ return;
151
+ }
152
+
153
+ const local = localStandards(cwd);
154
+ const remoteNames = await listRemote(target);
155
+
156
+ console.log(pc.bold(`\nPrivate registry ${pc.dim(isHttp(target) ? target : path.resolve(target))}`));
157
+ if (sso.required) console.log(pc.dim(` SSO satisfied via ${sso.via}`));
158
+ console.log("");
159
+
160
+ // ── status: what differs, without changing anything ────────────────────
161
+ if (!push && !pull) {
162
+ if (!local.length && !(remoteNames || []).length) {
163
+ console.log(pc.dim(" Nothing local, nothing remote.\n"));
164
+ console.log(pc.dim(` Write a standard at .agentmd/presets/local-Team-conventions.md, or run '${A} extract'.\n`));
165
+ return;
166
+ }
167
+ const remote = new Set(remoteNames || []);
168
+ for (const f of local) {
169
+ const there = remote.has(f.name) ? await readRemote(target, f.name) : null;
170
+ const state =
171
+ there === null ? pc.green("new") :
172
+ checksum(there) === f.checksum ? pc.dim("in sync") : pc.yellow("differs");
173
+ console.log(` ${state.padEnd(18)} ${pc.white(f.rel)} ${pc.dim(`${(f.bytes / 1024).toFixed(1)} KB`)}`);
174
+ }
175
+ for (const name of remoteNames || []) {
176
+ if (!local.some((f) => f.name === name)) console.log(` ${pc.cyan("remote only").padEnd(18)} ${pc.white(name)}`);
177
+ }
178
+ console.log(pc.dim(`\n '${A} sync --private --push' to publish yours · '--pull' to take theirs\n`));
179
+ return;
180
+ }
181
+
182
+ // ── push ───────────────────────────────────────────────────────────────
183
+ if (push) {
184
+ if (!local.length) {
185
+ console.log(pc.dim(" No local standards to push. Files matching .agentmd/presets/local-*.md and .agentmd/overrides/*.md are pushed.\n"));
186
+ return;
187
+ }
188
+ let changed = 0;
189
+ for (const f of local) {
190
+ const there = await readRemote(target, f.name);
191
+ if (there !== null && checksum(there) === f.checksum) {
192
+ console.log(` ${pc.dim("=")} ${pc.white(f.name.padEnd(42))} ${pc.dim("already current")}`);
193
+ continue;
194
+ }
195
+ if (dry) {
196
+ console.log(` ${pc.yellow("~")} ${pc.white(f.name.padEnd(42))} ${pc.dim(there === null ? "would create" : "would update")}`);
197
+ changed++;
198
+ continue;
199
+ }
200
+ writeRemote(target, f.name, f.content);
201
+ console.log(` ${pc.green("✓")} ${pc.white(f.name.padEnd(42))} ${pc.dim(there === null ? "created" : "updated")}`);
202
+ changed++;
203
+ }
204
+ console.log(dry ? pc.dim(`\n --dry: nothing written. ${changed} file(s) would change.\n`) : `\n ${changed} file(s) published.\n`);
205
+ return;
206
+ }
207
+
208
+ // ── pull ───────────────────────────────────────────────────────────────
209
+ if (remoteNames === null) {
210
+ console.error(pc.red(` Cannot list ${target} — an https registry needs an index.json listing its files.\n`));
211
+ process.exitCode = 1;
212
+ return;
213
+ }
214
+ if (!remoteNames.length) {
215
+ console.log(pc.dim(" The registry is empty — nothing to pull.\n"));
216
+ return;
217
+ }
218
+
219
+ const presets = path.join(".agentmd", "presets");
220
+ let changed = 0;
221
+ for (const name of remoteNames) {
222
+ const content = await readRemote(target, name);
223
+ if (content === null) continue;
224
+ // Remote filenames decide where bytes land, so they go through the same
225
+ // guard as every other write rather than being trusted.
226
+ const dest = resolveInProject(cwd, path.join(name.startsWith(LOCAL_PREFIX) ? presets : path.join(".agentmd", "overrides"), path.basename(name)));
227
+ const before = fs.existsSync(dest) ? fs.readFileSync(dest, "utf-8") : null;
228
+ if (before === content) {
229
+ console.log(` ${pc.dim("=")} ${pc.white(name.padEnd(42))} ${pc.dim("already current")}`);
230
+ continue;
231
+ }
232
+ if (dry) {
233
+ console.log(` ${pc.yellow("~")} ${pc.white(name.padEnd(42))} ${pc.dim(before === null ? "would create" : "would update")}`);
234
+ changed++;
235
+ continue;
236
+ }
237
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
238
+ fs.writeFileSync(dest, content, "utf-8");
239
+ console.log(` ${pc.green("✓")} ${pc.white(name.padEnd(42))} ${pc.dim(before === null ? "created" : "updated")}`);
240
+ changed++;
241
+ }
242
+ console.log(dry ? pc.dim(`\n --dry: nothing written. ${changed} file(s) would change.\n`) : `\n ${changed} file(s) pulled. Run '${A} link' to wire them in.\n`);
243
+ }
244
+
245
+ module.exports = { sync, localStandards, isHttp };