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,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* detect.js
|
|
3
|
+
*
|
|
4
|
+
* Inspects a project directory and maps what it finds to packages that
|
|
5
|
+
* actually exist in the registry.
|
|
6
|
+
*
|
|
7
|
+
* Two rules govern this file:
|
|
8
|
+
*
|
|
9
|
+
* 1. Never recommend a package that isn't in the registry. Every id below is
|
|
10
|
+
* asserted against the registry by the test suite, so a renamed preset
|
|
11
|
+
* breaks CI instead of producing a dead recommendation.
|
|
12
|
+
* 2. Detecting a technology is not the same as having packages for it. The
|
|
13
|
+
* registry has no Python, Go or Rust packages today; those stacks are
|
|
14
|
+
* reported as detected with zero recommendations rather than being
|
|
15
|
+
* mapped to something vaguely adjacent.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
"use strict";
|
|
19
|
+
|
|
20
|
+
const fs = require("fs");
|
|
21
|
+
const path = require("path");
|
|
22
|
+
|
|
23
|
+
/** Files we look for, keyed by the signal they represent. */
|
|
24
|
+
const FILE_SIGNALS = [
|
|
25
|
+
{ file: "Dockerfile", signal: "docker" },
|
|
26
|
+
{ file: "docker-compose.yml", signal: "docker-compose" },
|
|
27
|
+
{ file: "docker-compose.yaml", signal: "docker-compose" },
|
|
28
|
+
{ file: "vercel.json", signal: "vercel" },
|
|
29
|
+
{ file: "requirements.txt", signal: "python" },
|
|
30
|
+
{ file: "pyproject.toml", signal: "python" },
|
|
31
|
+
{ file: "go.mod", signal: "go" },
|
|
32
|
+
{ file: "Cargo.toml", signal: "rust" },
|
|
33
|
+
{ file: "pom.xml", signal: "java" },
|
|
34
|
+
{ file: "build.gradle", signal: "java" },
|
|
35
|
+
{ file: "tsconfig.json", signal: "typescript" },
|
|
36
|
+
{ file: "tailwind.config.js", signal: "tailwind" },
|
|
37
|
+
{ file: "tailwind.config.ts", signal: "tailwind" },
|
|
38
|
+
{ file: "next.config.js", signal: "nextjs" },
|
|
39
|
+
{ file: "next.config.ts", signal: "nextjs" },
|
|
40
|
+
{ file: "next.config.mjs", signal: "nextjs" },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
const DIR_SIGNALS = [
|
|
44
|
+
{ dir: path.join(".github", "workflows"), signal: "github-actions" },
|
|
45
|
+
{ dir: "prisma", signal: "prisma" },
|
|
46
|
+
{ dir: "k8s", signal: "kubernetes" },
|
|
47
|
+
{ dir: "kubernetes", signal: "kubernetes" },
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/** npm dependency name (or prefix, with a trailing *) -> signal. */
|
|
51
|
+
const DEPENDENCY_SIGNALS = {
|
|
52
|
+
next: "nextjs",
|
|
53
|
+
react: "react",
|
|
54
|
+
vue: "vue",
|
|
55
|
+
"@angular/core": "angular",
|
|
56
|
+
svelte: "svelte",
|
|
57
|
+
typescript: "typescript",
|
|
58
|
+
tailwindcss: "tailwind",
|
|
59
|
+
express: "express",
|
|
60
|
+
fastify: "fastify",
|
|
61
|
+
"@nestjs/core": "nestjs",
|
|
62
|
+
prisma: "prisma",
|
|
63
|
+
"@prisma/client": "prisma",
|
|
64
|
+
"drizzle-orm": "orm",
|
|
65
|
+
typeorm: "orm",
|
|
66
|
+
sequelize: "orm",
|
|
67
|
+
mongoose: "mongodb",
|
|
68
|
+
mongodb: "mongodb",
|
|
69
|
+
pg: "postgres",
|
|
70
|
+
postgres: "postgres",
|
|
71
|
+
mysql2: "mysql",
|
|
72
|
+
redis: "redis",
|
|
73
|
+
ioredis: "redis",
|
|
74
|
+
graphql: "graphql",
|
|
75
|
+
vitest: "unit-tests",
|
|
76
|
+
jest: "unit-tests",
|
|
77
|
+
mocha: "unit-tests",
|
|
78
|
+
"@playwright/test": "e2e-tests",
|
|
79
|
+
playwright: "e2e-tests",
|
|
80
|
+
cypress: "e2e-tests",
|
|
81
|
+
"next-auth": "auth",
|
|
82
|
+
"@auth/core": "auth",
|
|
83
|
+
passport: "auth",
|
|
84
|
+
jsonwebtoken: "jwt",
|
|
85
|
+
jose: "jwt",
|
|
86
|
+
zustand: "state-management",
|
|
87
|
+
redux: "state-management",
|
|
88
|
+
"@reduxjs/toolkit": "state-management",
|
|
89
|
+
"@tanstack/react-query": "state-management",
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* signal -> packages, as `<category>/<preset>` (the model is chosen by the
|
|
94
|
+
* caller). Signals with an empty list are reported but produce nothing —
|
|
95
|
+
* that's deliberate, see the header.
|
|
96
|
+
*/
|
|
97
|
+
const RECOMMENDATIONS = {
|
|
98
|
+
nextjs: ["Frontend/nextjs", "Backend/nextjs", "Frontend/server-components", "Frontend/routing", "Frontend/metadata"],
|
|
99
|
+
react: ["Frontend/react", "Frontend/hooks", "Frontend/client-components"],
|
|
100
|
+
typescript: ["Frontend/typescript"],
|
|
101
|
+
tailwind: ["Frontend/tailwind"],
|
|
102
|
+
"state-management": ["Frontend/state-management"],
|
|
103
|
+
express: ["Backend/express", "Backend/middlewares", "Backend/error-handling"],
|
|
104
|
+
prisma: ["Database/prisma", "Database/orm", "Database/migration", "Database/schema-design"],
|
|
105
|
+
orm: ["Database/orm", "Database/migration", "Database/schema-design"],
|
|
106
|
+
postgres: ["Database/postgres", "Database/indexes", "Database/query-optimization"],
|
|
107
|
+
mysql: ["Database/mysql", "Database/indexes"],
|
|
108
|
+
mongodb: ["Database/mongodb", "Database/schema-design"],
|
|
109
|
+
redis: ["Database/redis", "Performance/caching"],
|
|
110
|
+
graphql: ["API/graphql", "API/pagination"],
|
|
111
|
+
auth: ["Security/authentication", "Security/authorization", "Security/oauth"],
|
|
112
|
+
jwt: ["Security/jwt", "Security/authentication"],
|
|
113
|
+
"unit-tests": ["Testing/unit", "Testing/test-strategy"],
|
|
114
|
+
"e2e-tests": ["Testing/e2e", "Testing/accessibility"],
|
|
115
|
+
docker: ["DevOps/docker", "DevOps/environments"],
|
|
116
|
+
"docker-compose": ["DevOps/docker-compose"],
|
|
117
|
+
kubernetes: ["DevOps/kubernetes", "DevOps/rollback"],
|
|
118
|
+
"github-actions": ["DevOps/github-actions", "DevOps/cicd"],
|
|
119
|
+
vercel: ["DevOps/vercel", "DevOps/deployment"],
|
|
120
|
+
|
|
121
|
+
// Detected, but the registry has nothing for them yet. Listing them with an
|
|
122
|
+
// empty array is how `init` stays honest about coverage.
|
|
123
|
+
vue: [],
|
|
124
|
+
angular: [],
|
|
125
|
+
svelte: [],
|
|
126
|
+
fastify: [],
|
|
127
|
+
nestjs: [],
|
|
128
|
+
python: ["Backend/python-conventions", "Testing/pytest", "Backend/python-async"],
|
|
129
|
+
fastapi: ["Backend/fastapi", "Backend/pydantic", "Backend/python-async"],
|
|
130
|
+
django: ["Backend/django", "Testing/pytest"],
|
|
131
|
+
flask: ["Backend/flask", "Testing/pytest"],
|
|
132
|
+
pydantic: ["Backend/pydantic"],
|
|
133
|
+
sqlalchemy: ["Database/sqlalchemy"],
|
|
134
|
+
pytest: ["Testing/pytest"],
|
|
135
|
+
go: ["Backend/go-conventions", "Backend/go-errors", "Backend/go-http", "Backend/go-concurrency", "Testing/go-testing", "Database/go-database", "Performance/go-performance"],
|
|
136
|
+
rust: [],
|
|
137
|
+
java: [],
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/** Recommended for every project regardless of stack. */
|
|
141
|
+
const BASELINE = ["Security/owasp", "Review/code-review", "AI/agent-rules"];
|
|
142
|
+
|
|
143
|
+
/** Human labels for the signals `init` prints. */
|
|
144
|
+
const LABELS = {
|
|
145
|
+
nextjs: "Next.js",
|
|
146
|
+
react: "React",
|
|
147
|
+
vue: "Vue",
|
|
148
|
+
angular: "Angular",
|
|
149
|
+
svelte: "Svelte",
|
|
150
|
+
typescript: "TypeScript",
|
|
151
|
+
tailwind: "Tailwind CSS",
|
|
152
|
+
"state-management": "Client state management",
|
|
153
|
+
express: "Express",
|
|
154
|
+
fastify: "Fastify",
|
|
155
|
+
nestjs: "NestJS",
|
|
156
|
+
prisma: "Prisma",
|
|
157
|
+
orm: "SQL ORM",
|
|
158
|
+
postgres: "PostgreSQL",
|
|
159
|
+
mysql: "MySQL",
|
|
160
|
+
mongodb: "MongoDB",
|
|
161
|
+
redis: "Redis",
|
|
162
|
+
graphql: "GraphQL",
|
|
163
|
+
auth: "Authentication",
|
|
164
|
+
jwt: "JWT",
|
|
165
|
+
"unit-tests": "Unit testing",
|
|
166
|
+
"e2e-tests": "End-to-end testing",
|
|
167
|
+
docker: "Docker",
|
|
168
|
+
"docker-compose": "Docker Compose",
|
|
169
|
+
kubernetes: "Kubernetes",
|
|
170
|
+
"github-actions": "GitHub Actions",
|
|
171
|
+
vercel: "Vercel",
|
|
172
|
+
python: "Python",
|
|
173
|
+
fastapi: "FastAPI",
|
|
174
|
+
django: "Django",
|
|
175
|
+
flask: "Flask",
|
|
176
|
+
pydantic: "Pydantic",
|
|
177
|
+
sqlalchemy: "SQLAlchemy",
|
|
178
|
+
pytest: "pytest",
|
|
179
|
+
go: "Go",
|
|
180
|
+
rust: "Rust",
|
|
181
|
+
java: "Java",
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* A directory holding one of these is treated as a workspace worth scanning
|
|
186
|
+
* on its own.
|
|
187
|
+
*
|
|
188
|
+
* Deliberately not a workspace-config parser. Reading `workspaces` globs from
|
|
189
|
+
* package.json would miss pnpm-workspace.yaml (needs a YAML parser), Cargo
|
|
190
|
+
* `[workspace] members`, Go multi-module layouts and Nx project graphs — four
|
|
191
|
+
* formats, four parsers, four things to keep current. Looking for the manifest
|
|
192
|
+
* files themselves covers every one of those layouts, and any layout nobody
|
|
193
|
+
* has invented yet, in one bounded walk.
|
|
194
|
+
*/
|
|
195
|
+
const WORKSPACE_MARKERS = [
|
|
196
|
+
"package.json",
|
|
197
|
+
"pyproject.toml",
|
|
198
|
+
"requirements.txt",
|
|
199
|
+
"go.mod",
|
|
200
|
+
"Cargo.toml",
|
|
201
|
+
"pom.xml",
|
|
202
|
+
"build.gradle",
|
|
203
|
+
"Dockerfile",
|
|
204
|
+
];
|
|
205
|
+
|
|
206
|
+
/** Never descended into: build output, dependencies, and virtualenvs. */
|
|
207
|
+
const SKIP_DIRS = new Set([
|
|
208
|
+
"node_modules", ".git", ".next", ".nuxt", ".svelte-kit", ".turbo", ".venv",
|
|
209
|
+
"venv", "__pycache__", "dist", "build", "out", "target", "vendor", "coverage",
|
|
210
|
+
".cache", ".terraform", "bin", "obj",
|
|
211
|
+
]);
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* How deep to look for workspaces. `apps/web`, `services/api` and
|
|
215
|
+
* `packages/ui/core` are all within three segments of the root, which covers
|
|
216
|
+
* every monorepo layout in common use without walking a whole source tree.
|
|
217
|
+
*/
|
|
218
|
+
const MAX_DEPTH = 3;
|
|
219
|
+
|
|
220
|
+
function exists(cwd, relative) {
|
|
221
|
+
return fs.existsSync(path.join(cwd, relative));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Directories to scan: the root, plus every nested directory holding a
|
|
226
|
+
* manifest. Returns paths relative to `cwd`, root first.
|
|
227
|
+
*/
|
|
228
|
+
function findWorkspaces(cwd, maxDepth = MAX_DEPTH) {
|
|
229
|
+
const found = ["."];
|
|
230
|
+
const walk = (relative, depth) => {
|
|
231
|
+
if (depth > maxDepth) return;
|
|
232
|
+
let entries;
|
|
233
|
+
try {
|
|
234
|
+
entries = fs.readdirSync(path.join(cwd, relative), { withFileTypes: true });
|
|
235
|
+
} catch {
|
|
236
|
+
return; // unreadable directory shouldn't abort the scan
|
|
237
|
+
}
|
|
238
|
+
for (const entry of entries) {
|
|
239
|
+
if (!entry.isDirectory() || SKIP_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
|
|
240
|
+
const child = relative === "." ? entry.name : path.join(relative, entry.name);
|
|
241
|
+
if (WORKSPACE_MARKERS.some((m) => exists(cwd, path.join(child, m)))) found.push(child);
|
|
242
|
+
walk(child, depth + 1);
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
walk(".", 1);
|
|
246
|
+
return found;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Scan exactly one directory. Returns Map of signal -> evidence. */
|
|
250
|
+
function scanOne(dir) {
|
|
251
|
+
const found = new Map();
|
|
252
|
+
const note = (signal, via) => {
|
|
253
|
+
if (!found.has(signal)) found.set(signal, via);
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
for (const { file, signal } of FILE_SIGNALS) {
|
|
257
|
+
if (exists(dir, file)) note(signal, file);
|
|
258
|
+
}
|
|
259
|
+
for (const { dir: sub, signal } of DIR_SIGNALS) {
|
|
260
|
+
if (exists(dir, sub)) note(signal, sub + "/");
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const pkgPath = path.join(dir, "package.json");
|
|
264
|
+
if (fs.existsSync(pkgPath)) {
|
|
265
|
+
let pkg;
|
|
266
|
+
try {
|
|
267
|
+
pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
268
|
+
} catch {
|
|
269
|
+
pkg = null; // malformed package.json shouldn't abort the whole scan
|
|
270
|
+
}
|
|
271
|
+
if (pkg) {
|
|
272
|
+
const deps = Object.keys({ ...pkg.dependencies, ...pkg.devDependencies });
|
|
273
|
+
for (const dep of deps) {
|
|
274
|
+
const signal = Object.prototype.hasOwnProperty.call(DEPENDENCY_SIGNALS, dep)
|
|
275
|
+
? DEPENDENCY_SIGNALS[dep]
|
|
276
|
+
: null;
|
|
277
|
+
if (signal) note(signal, `package.json (${dep})`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Python dependency files are plain text, so a name match is enough. Only
|
|
283
|
+
// libraries with a package behind them are listed.
|
|
284
|
+
for (const file of ["requirements.txt", "pyproject.toml"]) {
|
|
285
|
+
const p = path.join(dir, file);
|
|
286
|
+
if (!fs.existsSync(p)) continue;
|
|
287
|
+
let text = "";
|
|
288
|
+
try { text = fs.readFileSync(p, "utf-8").toLowerCase(); } catch { continue; }
|
|
289
|
+
for (const lib of ["fastapi", "django", "flask", "pydantic", "sqlalchemy", "pytest"]) {
|
|
290
|
+
if (new RegExp(`(^|[\\s"'\\[])${lib}([\\s"'\\]=<>~!;,]|$)`, "m").test(text)) note(lib, `${file} (${lib})`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return found;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Scan a project, including every workspace in a monorepo.
|
|
299
|
+
*
|
|
300
|
+
* Returns { signals, packages, workspaces }. `signals` and `packages` are the
|
|
301
|
+
* union across the whole repo — one `agentmd link` writes one config, so the
|
|
302
|
+
* agent needs the union regardless of where each signal came from. The
|
|
303
|
+
* per-workspace breakdown is kept so `init` can show which package produced
|
|
304
|
+
* what rather than presenting a merged list as if it were one stack.
|
|
305
|
+
*/
|
|
306
|
+
function detect(cwd) {
|
|
307
|
+
const dirs = findWorkspaces(cwd);
|
|
308
|
+
const evidence = new Map(); // signal -> "apps/web/package.json (next)"
|
|
309
|
+
const workspaces = [];
|
|
310
|
+
|
|
311
|
+
for (const relative of dirs) {
|
|
312
|
+
const found = scanOne(path.join(cwd, relative));
|
|
313
|
+
if (found.size === 0) continue;
|
|
314
|
+
|
|
315
|
+
for (const [signal, via] of found) {
|
|
316
|
+
if (!evidence.has(signal)) {
|
|
317
|
+
evidence.set(signal, relative === "." ? via : `${relative}/${via}`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const ids = [...found.keys()];
|
|
322
|
+
workspaces.push({
|
|
323
|
+
path: relative,
|
|
324
|
+
signals: ids.map((signal) => ({ signal, label: LABELS[signal] || signal, via: found.get(signal) })),
|
|
325
|
+
packages: [...new Set(ids.flatMap((s) => RECOMMENDATIONS[s] || []))],
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const signals = [...evidence.entries()].map(([signal, via]) => ({
|
|
330
|
+
signal,
|
|
331
|
+
label: LABELS[signal] || signal,
|
|
332
|
+
via,
|
|
333
|
+
packages: RECOMMENDATIONS[signal] || [],
|
|
334
|
+
}));
|
|
335
|
+
|
|
336
|
+
const packages = [...new Set(signals.flatMap((s) => s.packages).concat(BASELINE))];
|
|
337
|
+
|
|
338
|
+
return { signals, packages, workspaces };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
module.exports = {
|
|
342
|
+
detect,
|
|
343
|
+
findWorkspaces,
|
|
344
|
+
scanOne,
|
|
345
|
+
RECOMMENDATIONS,
|
|
346
|
+
BASELINE,
|
|
347
|
+
LABELS,
|
|
348
|
+
DEPENDENCY_SIGNALS,
|
|
349
|
+
FILE_SIGNALS,
|
|
350
|
+
DIR_SIGNALS,
|
|
351
|
+
WORKSPACE_MARKERS,
|
|
352
|
+
};
|
package/src/lib/diff.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* diff.js
|
|
3
|
+
*
|
|
4
|
+
* Line diff for `update --dry`, so you can see what an update would do to a
|
|
5
|
+
* package before it touches the file.
|
|
6
|
+
*
|
|
7
|
+
* "Trust us, run it and find out" is not a reasonable thing to ask of a tool
|
|
8
|
+
* that rewrites instructions an agent will act on. The preview is the point.
|
|
9
|
+
*
|
|
10
|
+
* Hand-rolled rather than pulled from npm: the CLI's zero-runtime-dependency
|
|
11
|
+
* property is worth more than the 40 lines below, and a package manager for
|
|
12
|
+
* supply-chain-sensitive content adding a transitive tree to print a diff
|
|
13
|
+
* would be its own punchline.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
"use strict";
|
|
17
|
+
|
|
18
|
+
const pc = require("picocolors");
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Above this many differing lines on either side, the quadratic LCS table
|
|
22
|
+
* stops being worth it (1500² Uint32 is ~9 MB) and a full replace reads no
|
|
23
|
+
* worse than a line-matched diff would.
|
|
24
|
+
*/
|
|
25
|
+
const MAX_LCS = 1500;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Diff two line arrays into [marker, text] pairs where marker is " ", "-" or "+".
|
|
29
|
+
*
|
|
30
|
+
* Common prefix and suffix are trimmed first. For the edits this tool actually
|
|
31
|
+
* makes — a paragraph rewritten inside an otherwise stable document — that
|
|
32
|
+
* collapses the LCS problem to a handful of lines.
|
|
33
|
+
*/
|
|
34
|
+
function diffLines(a, b) {
|
|
35
|
+
let start = 0;
|
|
36
|
+
while (start < a.length && start < b.length && a[start] === b[start]) start++;
|
|
37
|
+
|
|
38
|
+
let endA = a.length;
|
|
39
|
+
let endB = b.length;
|
|
40
|
+
while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
|
|
41
|
+
endA--;
|
|
42
|
+
endB--;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const out = [];
|
|
46
|
+
for (let i = 0; i < start; i++) out.push([" ", a[i]]);
|
|
47
|
+
|
|
48
|
+
const midA = a.slice(start, endA);
|
|
49
|
+
const midB = b.slice(start, endB);
|
|
50
|
+
const n = midA.length;
|
|
51
|
+
const m = midB.length;
|
|
52
|
+
|
|
53
|
+
if (n > MAX_LCS || m > MAX_LCS) {
|
|
54
|
+
for (const line of midA) out.push(["-", line]);
|
|
55
|
+
for (const line of midB) out.push(["+", line]);
|
|
56
|
+
} else {
|
|
57
|
+
// table[i][j] = length of the LCS of midA[i..] and midB[j..]
|
|
58
|
+
const table = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
|
|
59
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
60
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
61
|
+
table[i][j] =
|
|
62
|
+
midA[i] === midB[j]
|
|
63
|
+
? table[i + 1][j + 1] + 1
|
|
64
|
+
: Math.max(table[i + 1][j], table[i][j + 1]);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let i = 0;
|
|
69
|
+
let j = 0;
|
|
70
|
+
while (i < n && j < m) {
|
|
71
|
+
if (midA[i] === midB[j]) {
|
|
72
|
+
out.push([" ", midA[i]]);
|
|
73
|
+
i++;
|
|
74
|
+
j++;
|
|
75
|
+
} else if (table[i + 1][j] >= table[i][j + 1]) {
|
|
76
|
+
out.push(["-", midA[i++]]);
|
|
77
|
+
} else {
|
|
78
|
+
out.push(["+", midB[j++]]);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
while (i < n) out.push(["-", midA[i++]]);
|
|
82
|
+
while (j < m) out.push(["+", midB[j++]]);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
for (let i = endA; i < a.length; i++) out.push([" ", a[i]]);
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Added and removed line counts, for a one-line summary. */
|
|
90
|
+
function stat(before, after) {
|
|
91
|
+
const ops = diffLines(before.split("\n"), after.split("\n"));
|
|
92
|
+
return {
|
|
93
|
+
added: ops.filter(([m]) => m === "+").length,
|
|
94
|
+
removed: ops.filter(([m]) => m === "-").length,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Drop runs of unchanged lines, keeping `context` on each side of every edit.
|
|
100
|
+
* Elided runs are marked "@" so the output never implies the file is shorter
|
|
101
|
+
* than it is.
|
|
102
|
+
*/
|
|
103
|
+
function toHunks(ops, context) {
|
|
104
|
+
const keep = new Set();
|
|
105
|
+
for (let i = 0; i < ops.length; i++) {
|
|
106
|
+
if (ops[i][0] === " ") continue;
|
|
107
|
+
for (let k = Math.max(0, i - context); k <= Math.min(ops.length - 1, i + context); k++) {
|
|
108
|
+
keep.add(k);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const out = [];
|
|
113
|
+
let previous = -1;
|
|
114
|
+
for (let i = 0; i < ops.length; i++) {
|
|
115
|
+
if (!keep.has(i)) continue;
|
|
116
|
+
if (previous !== -1 && i > previous + 1) out.push(["@", ""]);
|
|
117
|
+
out.push(ops[i]);
|
|
118
|
+
previous = i;
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Render a diff for the terminal. Returns "" when the two sides are identical. */
|
|
124
|
+
function formatDiff(before, after, options) {
|
|
125
|
+
const { context = 2, maxLines = 24 } = options || {};
|
|
126
|
+
const hunks = toHunks(diffLines(before.split("\n"), after.split("\n")), context);
|
|
127
|
+
if (hunks.length === 0) return "";
|
|
128
|
+
|
|
129
|
+
const lines = hunks.slice(0, maxLines).map(([marker, text]) => {
|
|
130
|
+
if (marker === "+") return pc.green(` + ${text}`);
|
|
131
|
+
if (marker === "-") return pc.red(` - ${text}`);
|
|
132
|
+
if (marker === "@") return pc.dim(" ⋮");
|
|
133
|
+
return pc.dim(` ${text}`);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const hidden = hunks.length - maxLines;
|
|
137
|
+
if (hidden > 0) lines.push(pc.dim(` … ${hidden} more diff line${hidden === 1 ? "" : "s"}`));
|
|
138
|
+
|
|
139
|
+
return lines.join("\n");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = { diffLines, formatDiff, stat };
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* enterprise.js
|
|
3
|
+
*
|
|
4
|
+
* `.agentmd/enterprise.json` — the file a company commits so every developer
|
|
5
|
+
* and every CI run in that repo is pointed at the same place, without anyone
|
|
6
|
+
* having to remember a flag.
|
|
7
|
+
*
|
|
8
|
+
* {
|
|
9
|
+
* "privateRegistry": "https://standards.acme.internal/agentmd",
|
|
10
|
+
* "cloudUrl": "https://ci.acme.internal/agentmd",
|
|
11
|
+
* "licenseKey": "agmd_…",
|
|
12
|
+
* "requireSso": true,
|
|
13
|
+
* "ssoProvider": "okta",
|
|
14
|
+
* "allowPublicRegistry": true,
|
|
15
|
+
* "pinnedStandards": ["Security/owasp", "Review/code-review"]
|
|
16
|
+
* }
|
|
17
|
+
*
|
|
18
|
+
* It is committed, so it holds no secrets beyond the license key — which is a
|
|
19
|
+
* per-company entitlement, not a credential that can spend money. Anything
|
|
20
|
+
* that could (an API key, an SSO client secret) stays in the environment, and
|
|
21
|
+
* this file only ever names it.
|
|
22
|
+
*
|
|
23
|
+
* Environment variables win over the file, so a developer can point at a
|
|
24
|
+
* staging registry without editing a shared, committed file.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
"use strict";
|
|
28
|
+
|
|
29
|
+
const fs = require("fs");
|
|
30
|
+
const path = require("path");
|
|
31
|
+
|
|
32
|
+
const FILE = path.join(".agentmd", "enterprise.json");
|
|
33
|
+
|
|
34
|
+
/** Keys we understand. An unknown key is surfaced rather than ignored. */
|
|
35
|
+
const KNOWN = [
|
|
36
|
+
"privateRegistry",
|
|
37
|
+
"cloudUrl",
|
|
38
|
+
"licenseKey",
|
|
39
|
+
"requireSso",
|
|
40
|
+
"ssoProvider",
|
|
41
|
+
"allowPublicRegistry",
|
|
42
|
+
"pinnedStandards",
|
|
43
|
+
"$schema",
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Read the config for this project.
|
|
48
|
+
*
|
|
49
|
+
* Always returns an object: `{ config, problems, path, exists }`. A malformed
|
|
50
|
+
* file is a problem to report, never a reason to abort — the local workflow
|
|
51
|
+
* must keep working when someone commits a stray comma.
|
|
52
|
+
*/
|
|
53
|
+
function load(cwd = process.cwd()) {
|
|
54
|
+
const file = path.join(cwd, FILE);
|
|
55
|
+
const problems = [];
|
|
56
|
+
|
|
57
|
+
let config = {};
|
|
58
|
+
let exists = false;
|
|
59
|
+
try {
|
|
60
|
+
const raw = fs.readFileSync(file, "utf-8");
|
|
61
|
+
exists = true;
|
|
62
|
+
config = JSON.parse(raw);
|
|
63
|
+
if (!config || typeof config !== "object" || Array.isArray(config)) {
|
|
64
|
+
problems.push(`${FILE} must contain a JSON object`);
|
|
65
|
+
config = {};
|
|
66
|
+
}
|
|
67
|
+
} catch (err) {
|
|
68
|
+
if (err.code !== "ENOENT") problems.push(`${FILE}: ${err.message}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
for (const key of Object.keys(config)) {
|
|
72
|
+
if (!KNOWN.includes(key)) problems.push(`${FILE}: unknown key \`${key}\``);
|
|
73
|
+
}
|
|
74
|
+
for (const key of ["privateRegistry", "cloudUrl"]) {
|
|
75
|
+
const value = config[key];
|
|
76
|
+
if (value === undefined) continue;
|
|
77
|
+
if (typeof value !== "string") { problems.push(`${FILE}: \`${key}\` must be a string`); continue; }
|
|
78
|
+
// A private registry may be an https endpoint or a path on a shared
|
|
79
|
+
// volume; a plaintext http endpoint is refused for the same reason the
|
|
80
|
+
// fetcher refuses one — this content becomes agent instructions.
|
|
81
|
+
if (/^http:\/\//i.test(value)) problems.push(`${FILE}: \`${key}\` must be https, not http`);
|
|
82
|
+
}
|
|
83
|
+
if (config.pinnedStandards !== undefined && !Array.isArray(config.pinnedStandards)) {
|
|
84
|
+
problems.push(`${FILE}: \`pinnedStandards\` must be an array`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The environment wins, so a developer can redirect without touching a file
|
|
88
|
+
// their whole team shares.
|
|
89
|
+
const resolved = {
|
|
90
|
+
...config,
|
|
91
|
+
privateRegistry: process.env.AGENTMD_PRIVATE_REGISTRY || config.privateRegistry || null,
|
|
92
|
+
cloudUrl: process.env.AGENTMD_CLOUD_URL || config.cloudUrl || null,
|
|
93
|
+
requireSso: config.requireSso === true,
|
|
94
|
+
allowPublicRegistry: config.allowPublicRegistry !== false,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
return { config: resolved, problems, path: file, exists };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Write a starter config. Refuses to clobber one that already exists. */
|
|
101
|
+
function init(cwd = process.cwd(), values = {}) {
|
|
102
|
+
const file = path.join(cwd, FILE);
|
|
103
|
+
if (fs.existsSync(file)) throw new Error(`${FILE} already exists`);
|
|
104
|
+
const body = {
|
|
105
|
+
$schema: "https://agent-dot-md.vercel.app/schema/enterprise.json",
|
|
106
|
+
privateRegistry: values.privateRegistry || "https://standards.example.internal/agentmd",
|
|
107
|
+
cloudUrl: values.cloudUrl || null,
|
|
108
|
+
licenseKey: values.licenseKey || "",
|
|
109
|
+
requireSso: values.requireSso ?? false,
|
|
110
|
+
allowPublicRegistry: true,
|
|
111
|
+
pinnedStandards: [],
|
|
112
|
+
};
|
|
113
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
114
|
+
fs.writeFileSync(file, JSON.stringify(body, null, 2) + "\n", "utf-8");
|
|
115
|
+
return file;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Whether SSO is satisfied.
|
|
120
|
+
*
|
|
121
|
+
* The CLI cannot perform an SSO handshake by itself — that belongs to the
|
|
122
|
+
* identity provider and the web session. What it can do is refuse to reach a
|
|
123
|
+
* private registry unless the company's chosen proof is present, and say
|
|
124
|
+
* exactly which one is missing. `requireSso` is enforced, not advisory.
|
|
125
|
+
*/
|
|
126
|
+
function ssoStatus(config, env = process.env) {
|
|
127
|
+
if (!config.requireSso) return { required: false, satisfied: true, via: null };
|
|
128
|
+
const token = env.AGENTMD_SSO_TOKEN || env.AGENTMD_OIDC_TOKEN;
|
|
129
|
+
if (token) return { required: true, satisfied: true, via: env.AGENTMD_SSO_TOKEN ? "AGENTMD_SSO_TOKEN" : "AGENTMD_OIDC_TOKEN" };
|
|
130
|
+
// GitHub Actions can mint an OIDC token for the job; that is the CI answer.
|
|
131
|
+
if (env.ACTIONS_ID_TOKEN_REQUEST_TOKEN) return { required: true, satisfied: true, via: "GitHub OIDC" };
|
|
132
|
+
return {
|
|
133
|
+
required: true,
|
|
134
|
+
satisfied: false,
|
|
135
|
+
via: null,
|
|
136
|
+
hint: `SSO is required by ${FILE}${config.ssoProvider ? ` (${config.ssoProvider})` : ""}. Set AGENTMD_SSO_TOKEN, or run in a job with GitHub OIDC enabled.`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
module.exports = { load, init, ssoStatus, FILE, KNOWN };
|