plain-forge 1.0.9 → 1.0.11
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 +13 -1
- package/bin/cli.mjs +357 -37
- package/forge/rules/integrations.md +5 -2
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -81,7 +81,19 @@ Each install writes three subfolders under the chosen directory:
|
|
|
81
81
|
docs/ # shared reference docs
|
|
82
82
|
```
|
|
83
83
|
|
|
84
|
-
|
|
84
|
+
`install` refuses to run if plain-forge is already present at the target directory — it prints a message pointing you at `update` and exits non-zero. Use `update` (below) to refresh an existing install.
|
|
85
|
+
|
|
86
|
+
#### Updating an existing install
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
npx plain-forge@latest update
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`update` auto-detects every plain-forge install in the current folder and in your home directory (across all agent layouts) and refreshes each one in place — no agent/scope prompts. For each install it compares the version recorded in the manifest against the version you're running: if the package version did not increase, it leaves that install untouched and tells you it's already up to date. Unlike `install`, it also **prunes** files that were removed from the package (e.g. a deleted skill) by consulting a manifest (`<agent-dir>/.plain-forge/manifest.json`) that records exactly which files plain-forge wrote. Your own skills and any third-party skills sharing the same directory are never in that manifest, so they are never touched.
|
|
93
|
+
|
|
94
|
+
Each deprecated file is confirmed individually before it's deleted — you'll see its path and a `[y/N]` prompt. Denied files stay on disk and remain tracked, so the next `update` re-offers them. Pass `--yes` (or `-y`) to remove all deprecated files without prompting (useful in CI); when there's no interactive terminal and `--yes` is not given, nothing is deleted.
|
|
95
|
+
|
|
96
|
+
Installs that predate the manifest (anyone who installed before this feature existed) have no manifest to read. `update` still finds them by their skill footprint: if the `forge-plain`, `add-feature`, `debug-specs`, and `load-plain-reference` skills are all present in an agent directory, it's treated as a plain-forge install. Such installs are refreshed without pruning (overwrite-only), and gain a manifest going forward so later updates can prune.
|
|
85
97
|
|
|
86
98
|
### Alternative install paths (skills only — no rules or docs)
|
|
87
99
|
|
package/bin/cli.mjs
CHANGED
|
@@ -17,6 +17,30 @@ const AGENTS = {
|
|
|
17
17
|
};
|
|
18
18
|
const SCOPES = ["project", "global"];
|
|
19
19
|
|
|
20
|
+
// Subfolders plain-forge writes under an agent directory.
|
|
21
|
+
const CONTENT_DIRS = ["skills", "rules", "docs"];
|
|
22
|
+
// Manifest recording exactly which files this package installed, so `update`
|
|
23
|
+
// can prune our own stale files without touching user or third-party content.
|
|
24
|
+
const MANIFEST_REL = path.join(".plain-forge", "manifest.json");
|
|
25
|
+
// Flagship skills every plain-forge install ships. Used to recognize legacy
|
|
26
|
+
// installs that predate the manifest: if all of these skill directories are
|
|
27
|
+
// present, plain-forge is installed even without a manifest.
|
|
28
|
+
const FORGE_SIGNATURE_SKILLS = [
|
|
29
|
+
"forge-plain",
|
|
30
|
+
"add-feature",
|
|
31
|
+
"debug-specs",
|
|
32
|
+
"load-plain-reference",
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
// True when baseDir looks like a plain-forge install by its skill footprint
|
|
36
|
+
// alone (the manifest-less fallback). Requires every flagship skill so an
|
|
37
|
+
// unrelated agent dir with one similarly-named skill is not misdetected.
|
|
38
|
+
function hasForgeSignature(baseDir) {
|
|
39
|
+
return FORGE_SIGNATURE_SKILLS.every((skill) =>
|
|
40
|
+
fs.existsSync(path.join(baseDir, "skills", skill)),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
20
44
|
const BANNER = `\x1b[38;2;224;255;110m██████╗ ██╗ █████╗ ██╗███╗ ██╗ ███████╗ ██████╗ ██████╗ ██████╗ ███████╗
|
|
21
45
|
██╔══██╗██║ ██╔══██╗██║████╗ ██║ ██╔════╝██╔═══██╗██╔══██╗██╔════╝ ██╔════╝
|
|
22
46
|
██████╔╝██║ ███████║██║██╔██╗ ██║█████╗█████╗ ██║ ██║██████╔╝██║ ███╗█████╗
|
|
@@ -51,18 +75,31 @@ function printBanner() {
|
|
|
51
75
|
}
|
|
52
76
|
|
|
53
77
|
function usage() {
|
|
54
|
-
console.log(`Usage: plain-forge
|
|
78
|
+
console.log(`Usage: plain-forge <command> [options]
|
|
79
|
+
|
|
80
|
+
Commands:
|
|
81
|
+
install Install plain-forge into an agent directory
|
|
82
|
+
update Refresh every existing plain-forge install in cwd and $HOME
|
|
55
83
|
|
|
56
|
-
|
|
84
|
+
Install options:
|
|
57
85
|
--agent <claude|codex|forgecode|universal> Target agent layout
|
|
58
86
|
--scope <project|global> Install into cwd or $HOME
|
|
59
87
|
-h, --help Show this help
|
|
60
88
|
|
|
89
|
+
Update options:
|
|
90
|
+
-y, --yes Remove deprecated files without
|
|
91
|
+
confirming each one
|
|
92
|
+
|
|
61
93
|
Examples:
|
|
62
94
|
plain-forge install --agent claude --scope project
|
|
63
95
|
plain-forge install --agent universal --scope global
|
|
96
|
+
plain-forge update
|
|
97
|
+
plain-forge update --yes
|
|
64
98
|
|
|
65
|
-
|
|
99
|
+
"install" fails if plain-forge is already installed at the target — use
|
|
100
|
+
"update" to refresh it. Missing install flags are prompted interactively.
|
|
101
|
+
"update" auto-detects installs and prunes only files plain-forge wrote
|
|
102
|
+
(confirming each removal), leaving your own and third-party skills untouched.`);
|
|
66
103
|
}
|
|
67
104
|
|
|
68
105
|
function parseArgs(argv) {
|
|
@@ -71,6 +108,7 @@ function parseArgs(argv) {
|
|
|
71
108
|
const a = argv[i];
|
|
72
109
|
if (a === "--agent") out.agent = argv[++i];
|
|
73
110
|
else if (a === "--scope") out.scope = argv[++i];
|
|
111
|
+
else if (a === "-y" || a === "--yes") out.yes = true;
|
|
74
112
|
else if (a === "-h" || a === "--help") out.help = true;
|
|
75
113
|
else out._.push(a);
|
|
76
114
|
}
|
|
@@ -141,21 +179,191 @@ function promptChoice(question, choices) {
|
|
|
141
179
|
});
|
|
142
180
|
}
|
|
143
181
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
182
|
+
// Ask a yes/no question. Defaults to "no" when stdin is not a TTY, so a
|
|
183
|
+
// non-interactive run never deletes anything without an explicit --yes.
|
|
184
|
+
function promptConfirm(question) {
|
|
185
|
+
const input = process.stdin;
|
|
186
|
+
const output = process.stdout;
|
|
187
|
+
if (!input.isTTY) return Promise.resolve(false);
|
|
188
|
+
|
|
189
|
+
const rl = readline.createInterface({ input, output });
|
|
190
|
+
return new Promise((resolve) => {
|
|
191
|
+
rl.question(`${question} [y/N] `, (answer) => {
|
|
192
|
+
rl.close();
|
|
193
|
+
resolve(/^y(es)?$/i.test(answer.trim()));
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const toPosix = (p) => p.split(path.sep).join("/");
|
|
199
|
+
|
|
200
|
+
function readPkgVersion() {
|
|
201
|
+
try {
|
|
202
|
+
const pkg = JSON.parse(
|
|
203
|
+
fs.readFileSync(path.join(pkgRoot, "package.json"), "utf8"),
|
|
204
|
+
);
|
|
205
|
+
return pkg.version ?? "unknown";
|
|
206
|
+
} catch {
|
|
207
|
+
return "unknown";
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Compare two dotted numeric versions. Returns 1 if a > b, -1 if a < b, 0 if
|
|
212
|
+
// equal, or null when either version is not purely numeric (e.g. "unknown").
|
|
213
|
+
function compareVersions(a, b) {
|
|
214
|
+
const parse = (v) => String(v).split(".").map(Number);
|
|
215
|
+
const pa = parse(a);
|
|
216
|
+
const pb = parse(b);
|
|
217
|
+
if (pa.some(Number.isNaN) || pb.some(Number.isNaN)) return null;
|
|
218
|
+
const len = Math.max(pa.length, pb.length);
|
|
219
|
+
for (let i = 0; i < len; i++) {
|
|
220
|
+
const x = pa[i] ?? 0;
|
|
221
|
+
const y = pb[i] ?? 0;
|
|
222
|
+
if (x > y) return 1;
|
|
223
|
+
if (x < y) return -1;
|
|
224
|
+
}
|
|
225
|
+
return 0;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// An install is up to date when the package version did not increase over the
|
|
229
|
+
// version recorded in its manifest. Indeterminate versions ("unknown", or a
|
|
230
|
+
// missing manifest version) are never treated as up to date, so the refresh
|
|
231
|
+
// proceeds rather than silently skipping.
|
|
232
|
+
function isUpToDate(installedVersion, currentVersion) {
|
|
233
|
+
const cmp = compareVersions(currentVersion, installedVersion);
|
|
234
|
+
return cmp !== null && cmp <= 0;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Copy srcDir into destDir file-by-file (dereferencing symlinks), returning the
|
|
238
|
+
// list of file paths written, each relative to destDir.
|
|
239
|
+
function copyTreeTracked(srcDir, destDir) {
|
|
240
|
+
const written = [];
|
|
241
|
+
if (!fs.existsSync(srcDir)) return written;
|
|
242
|
+
|
|
243
|
+
const walk = (rel) => {
|
|
244
|
+
const srcPath = path.join(srcDir, rel);
|
|
245
|
+
const destPath = path.join(destDir, rel);
|
|
246
|
+
const stat = fs.statSync(srcPath); // follows symlinks → dereferences
|
|
247
|
+
if (stat.isDirectory()) {
|
|
248
|
+
fs.mkdirSync(destPath, { recursive: true });
|
|
249
|
+
for (const entry of fs.readdirSync(srcPath)) {
|
|
250
|
+
walk(path.join(rel, entry));
|
|
251
|
+
}
|
|
153
252
|
} else {
|
|
154
|
-
fs.
|
|
253
|
+
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
|
254
|
+
fs.copyFileSync(srcPath, destPath);
|
|
255
|
+
written.push(rel);
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
for (const entry of fs.readdirSync(srcDir)) {
|
|
260
|
+
walk(entry);
|
|
261
|
+
}
|
|
262
|
+
return written;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Copy every content dir into baseDir. Returns the flat list of files written
|
|
266
|
+
// (each relative to baseDir, for the manifest) and the per-dir counts. A count
|
|
267
|
+
// is the number of top-level items in that dir — i.e. the number of skills
|
|
268
|
+
// (each a directory) or rules, not the total file count, since a single skill
|
|
269
|
+
// can span several files.
|
|
270
|
+
function writeContent(baseDir) {
|
|
271
|
+
const counts = {};
|
|
272
|
+
const files = [];
|
|
273
|
+
for (const dir of CONTENT_DIRS) {
|
|
274
|
+
const written = copyTreeTracked(
|
|
275
|
+
path.join(forgeDir, dir),
|
|
276
|
+
path.join(baseDir, dir),
|
|
277
|
+
);
|
|
278
|
+
const topLevel = new Set(written.map((rel) => rel.split(path.sep)[0]));
|
|
279
|
+
counts[dir] = topLevel.size;
|
|
280
|
+
for (const rel of written) files.push(path.join(dir, rel));
|
|
281
|
+
}
|
|
282
|
+
return { counts, files };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function manifestPathFor(baseDir) {
|
|
286
|
+
return path.join(baseDir, MANIFEST_REL);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function readManifest(baseDir) {
|
|
290
|
+
try {
|
|
291
|
+
const data = JSON.parse(fs.readFileSync(manifestPathFor(baseDir), "utf8"));
|
|
292
|
+
if (data && Array.isArray(data.files)) return data;
|
|
293
|
+
} catch {
|
|
294
|
+
/* missing or malformed manifest → treat as absent */
|
|
295
|
+
}
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function writeManifest(baseDir, files) {
|
|
300
|
+
const target = manifestPathFor(baseDir);
|
|
301
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
302
|
+
const manifest = {
|
|
303
|
+
name: "plain-forge",
|
|
304
|
+
version: readPkgVersion(),
|
|
305
|
+
files: files.map(toPosix).sort(),
|
|
306
|
+
};
|
|
307
|
+
fs.writeFileSync(target, JSON.stringify(manifest, null, 2) + "\n");
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Remove now-empty directories from `dir` upward, stopping at (and never
|
|
311
|
+
// removing) stopAt.
|
|
312
|
+
function removeEmptyDirsUpward(dir, stopAt) {
|
|
313
|
+
let cur = dir;
|
|
314
|
+
while (cur !== stopAt && cur.startsWith(stopAt + path.sep)) {
|
|
315
|
+
try {
|
|
316
|
+
if (fs.readdirSync(cur).length > 0) break;
|
|
317
|
+
fs.rmdirSync(cur);
|
|
318
|
+
cur = path.dirname(cur);
|
|
319
|
+
} catch {
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Files present in the prior manifest but absent from the fresh copy, that
|
|
326
|
+
// still exist on disk. Only paths plain-forge itself recorded are ever
|
|
327
|
+
// considered — user/third-party files are never in the manifest.
|
|
328
|
+
function collectPruneCandidates(baseDir, oldFiles, newFiles) {
|
|
329
|
+
const keep = new Set(newFiles.map(toPosix));
|
|
330
|
+
const candidates = [];
|
|
331
|
+
for (const rel of oldFiles) {
|
|
332
|
+
if (keep.has(toPosix(rel))) continue;
|
|
333
|
+
if (fs.existsSync(path.join(baseDir, rel))) candidates.push(toPosix(rel));
|
|
334
|
+
}
|
|
335
|
+
return candidates;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function deleteForgeFile(baseDir, rel) {
|
|
339
|
+
const target = path.join(baseDir, rel);
|
|
340
|
+
try {
|
|
341
|
+
fs.rmSync(target, { force: true });
|
|
342
|
+
removeEmptyDirsUpward(path.dirname(target), baseDir);
|
|
343
|
+
return true;
|
|
344
|
+
} catch {
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Find every plain-forge install in cwd and $HOME. An install is recognized by
|
|
350
|
+
// its manifest, or — for installs predating the manifest — by the presence of
|
|
351
|
+
// the flagship skill.
|
|
352
|
+
function detectInstalls() {
|
|
353
|
+
const installs = [];
|
|
354
|
+
for (const scope of SCOPES) {
|
|
355
|
+
const root = scope === "global" ? os.homedir() : process.cwd();
|
|
356
|
+
for (const [agent, dirName] of Object.entries(AGENTS)) {
|
|
357
|
+
const baseDir = path.join(root, dirName);
|
|
358
|
+
if (!fs.existsSync(baseDir)) continue;
|
|
359
|
+
const manifest = readManifest(baseDir);
|
|
360
|
+
const isLegacy = !manifest && hasForgeSignature(baseDir);
|
|
361
|
+
if (manifest || isLegacy) {
|
|
362
|
+
installs.push({ agent, scope, baseDir, manifest });
|
|
363
|
+
}
|
|
155
364
|
}
|
|
156
|
-
count++;
|
|
157
365
|
}
|
|
158
|
-
return
|
|
366
|
+
return installs;
|
|
159
367
|
}
|
|
160
368
|
|
|
161
369
|
async function cmdInstall(args) {
|
|
@@ -180,27 +388,106 @@ async function cmdInstall(args) {
|
|
|
180
388
|
const root = scope === "global" ? os.homedir() : process.cwd();
|
|
181
389
|
const baseDir = path.join(root, AGENTS[agent]);
|
|
182
390
|
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
path.join(baseDir, "docs"),
|
|
194
|
-
);
|
|
391
|
+
const alreadyInstalled =
|
|
392
|
+
readManifest(baseDir) !== null || hasForgeSignature(baseDir);
|
|
393
|
+
if (alreadyInstalled) {
|
|
394
|
+
console.error(`plain-forge is already installed in ${baseDir}.`);
|
|
395
|
+
console.error(`run "plain-forge update" to refresh it.`);
|
|
396
|
+
process.exit(1);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const { counts, files } = writeContent(baseDir);
|
|
400
|
+
writeManifest(baseDir, files);
|
|
195
401
|
|
|
196
402
|
console.log(`installed into ${baseDir}`);
|
|
197
|
-
console.log(` skills: ${
|
|
198
|
-
console.log(` rules: ${
|
|
199
|
-
console.log(` docs: ${
|
|
403
|
+
console.log(` skills: ${counts.skills}`);
|
|
404
|
+
console.log(` rules: ${counts.rules}`);
|
|
405
|
+
console.log(` docs: ${counts.docs}`);
|
|
200
406
|
console.log();
|
|
201
407
|
printNextSteps(agent);
|
|
202
408
|
}
|
|
203
409
|
|
|
410
|
+
async function cmdUpdate(args) {
|
|
411
|
+
printBanner();
|
|
412
|
+
|
|
413
|
+
const installs = detectInstalls();
|
|
414
|
+
if (installs.length === 0) {
|
|
415
|
+
console.log(
|
|
416
|
+
"no existing plain-forge installations found in this folder or your home directory.",
|
|
417
|
+
);
|
|
418
|
+
console.log(`run "plain-forge install" to set one up.`);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const version = readPkgVersion();
|
|
423
|
+
let updated = 0;
|
|
424
|
+
for (const inst of installs) {
|
|
425
|
+
const hasManifest = inst.manifest != null;
|
|
426
|
+
|
|
427
|
+
// The up-to-date check applies only to manifest-tracked installs. With no
|
|
428
|
+
// manifest there is no recorded version to compare against, so the version
|
|
429
|
+
// check is skipped and the install is always refreshed — a manifest is then
|
|
430
|
+
// written for it at the end of this iteration (see writeManifest below).
|
|
431
|
+
if (hasManifest && isUpToDate(inst.manifest.version, version)) {
|
|
432
|
+
console.log(
|
|
433
|
+
`${inst.agent} (${inst.scope}) is already up to date (v${inst.manifest.version}).`,
|
|
434
|
+
);
|
|
435
|
+
console.log();
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const oldFiles = inst.manifest?.files ?? [];
|
|
440
|
+
const { counts, files } = writeContent(inst.baseDir);
|
|
441
|
+
|
|
442
|
+
console.log(`updated ${inst.agent} (${inst.scope}) → ${inst.baseDir}`);
|
|
443
|
+
console.log(
|
|
444
|
+
` skills: ${counts.skills} rules: ${counts.rules} docs: ${counts.docs}`,
|
|
445
|
+
);
|
|
446
|
+
|
|
447
|
+
// Pruning only applies to manifest-tracked installs. Each deprecated file
|
|
448
|
+
// is confirmed individually before removal; denied files stay on disk and
|
|
449
|
+
// remain tracked so the next update re-offers them.
|
|
450
|
+
const kept = [];
|
|
451
|
+
if (!hasManifest) {
|
|
452
|
+
console.log(` pruned: skipped (no manifest from prior install)`);
|
|
453
|
+
} else {
|
|
454
|
+
const candidates = collectPruneCandidates(inst.baseDir, oldFiles, files);
|
|
455
|
+
let pruned = 0;
|
|
456
|
+
for (const rel of candidates) {
|
|
457
|
+
console.log(
|
|
458
|
+
` The file corresponds to a plain-forge file that has been deprecated or removed:`,
|
|
459
|
+
);
|
|
460
|
+
console.log(` ${rel}`);
|
|
461
|
+
const remove = args.yes
|
|
462
|
+
? true
|
|
463
|
+
: await promptConfirm(" Please confirm its removal.");
|
|
464
|
+
if (remove && deleteForgeFile(inst.baseDir, rel)) {
|
|
465
|
+
pruned++;
|
|
466
|
+
} else {
|
|
467
|
+
kept.push(rel);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
console.log(
|
|
471
|
+
` pruned: ${pruned}${kept.length ? ` kept: ${kept.length}` : ""}`,
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Manifest reflects what's actually on disk: the fresh files plus any
|
|
476
|
+
// deprecated files the user chose to keep.
|
|
477
|
+
writeManifest(inst.baseDir, files.concat(kept));
|
|
478
|
+
console.log();
|
|
479
|
+
updated++;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (updated === 0) {
|
|
483
|
+
console.log(
|
|
484
|
+
`you are already using the up-to-date plain-forge (v${version}).`,
|
|
485
|
+
);
|
|
486
|
+
} else {
|
|
487
|
+
console.log(`updated ${updated} installation(s) to v${version}.`);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
204
491
|
function printNextSteps(agent) {
|
|
205
492
|
const bold = (s) => `\x1b[1;97m${s}\x1b[0m`;
|
|
206
493
|
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
@@ -258,6 +545,9 @@ async function main() {
|
|
|
258
545
|
case "install":
|
|
259
546
|
await cmdInstall(args);
|
|
260
547
|
break;
|
|
548
|
+
case "update":
|
|
549
|
+
await cmdUpdate(args);
|
|
550
|
+
break;
|
|
261
551
|
default:
|
|
262
552
|
console.error(`unknown command "${cmd}"`);
|
|
263
553
|
usage();
|
|
@@ -265,10 +555,40 @@ async function main() {
|
|
|
265
555
|
}
|
|
266
556
|
}
|
|
267
557
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
558
|
+
// Only run the CLI when executed directly — importing this module (e.g. from
|
|
559
|
+
// the test suite) must not trigger main() or process.exit().
|
|
560
|
+
const invokedDirectly =
|
|
561
|
+
process.argv[1] && path.resolve(process.argv[1]) === __filename;
|
|
562
|
+
if (invokedDirectly) {
|
|
563
|
+
main().catch((err) => {
|
|
564
|
+
if (err instanceof Error && err.message === "cancelled") {
|
|
565
|
+
process.exit(130);
|
|
566
|
+
}
|
|
567
|
+
console.error(err instanceof Error ? (err.stack ?? err.message) : err);
|
|
568
|
+
process.exit(1);
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
export {
|
|
573
|
+
AGENTS,
|
|
574
|
+
SCOPES,
|
|
575
|
+
CONTENT_DIRS,
|
|
576
|
+
MANIFEST_REL,
|
|
577
|
+
FORGE_SIGNATURE_SKILLS,
|
|
578
|
+
hasForgeSignature,
|
|
579
|
+
parseArgs,
|
|
580
|
+
toPosix,
|
|
581
|
+
readPkgVersion,
|
|
582
|
+
compareVersions,
|
|
583
|
+
isUpToDate,
|
|
584
|
+
copyTreeTracked,
|
|
585
|
+
writeContent,
|
|
586
|
+
manifestPathFor,
|
|
587
|
+
readManifest,
|
|
588
|
+
writeManifest,
|
|
589
|
+
removeEmptyDirsUpward,
|
|
590
|
+
collectPruneCandidates,
|
|
591
|
+
deleteForgeFile,
|
|
592
|
+
detectInstalls,
|
|
593
|
+
promptConfirm,
|
|
594
|
+
};
|
|
@@ -44,8 +44,10 @@ A single `.plain` module can (and typically will) reference many resources. That
|
|
|
44
44
|
|
|
45
45
|
Documentation lies — it goes stale, omits undocumented fields, describes a different API version, papers over breaking changes. Every integration spec must be grounded in what the API really returns, not what the docs claim it returns.
|
|
46
46
|
|
|
47
|
+
- **Discover the documentation links with web search first, then `fetch` all of them.** Documentation moves, gets reorganized, and is versioned — so never assume a doc URL from memory. Begin every topic by issuing **web searches with human-readable queries** to find the canonical pages, then `fetch` **every** relevant link the search surfaces — the endpoint reference, the auth guide, the webhook catalog, the error reference, the pagination/rate-limit docs, the changelog. The search step finds *which* URLs are authoritative and current; the `fetch` step retrieves their actual content. Do not stop at the first hit — fetch the full set so a topic is grounded in all of its sources, not a single page.
|
|
48
|
+
- If the environment has **no web-search tool** (only URL-based `fetch` is available), say so explicitly, construct the URL from the provider's well-known documentation root and crawl outward by `fetch`-ing that root and following its links, and ask the user for the canonical URL whenever you cannot reach the right page that way. Never substitute memory for the search-then-fetch step.
|
|
47
49
|
- **Always `fetch` the provider's documentation — even if you already "know" the API.** The only acceptable source of truth for what the API looks like *today* is the provider's own live documentation, retrieved with `fetch` at spec-authoring time. This applies without exception — there is no API well-known enough to skip this step, and a spec authored from memory is a spec authored against the wrong contract. Concretely:
|
|
48
|
-
- Before authoring **any** endpoint, auth, error, pagination, or webhook concept,
|
|
50
|
+
- Before authoring **any** endpoint, auth, error, pagination, or webhook concept, **web-search to locate the relevant documentation page(s), then `fetch` each one** and quote concrete details (status codes, field names, header names, error formats) directly from the fetched content into the resources under `resources/`. Never paraphrase from memory.
|
|
49
51
|
- Save the fetched documentation snapshot under `resources/docs/<provider>/<page>.md` (or `.html` if structure matters) so the spec has a stable doc artifact the renderer and reviewers can consult, independent of the live URL changing or going behind auth.
|
|
50
52
|
- If a documentation page is unreachable (paywall, login wall, JS-only render that `fetch` can't see), say so explicitly and ask the user for the canonical content rather than filling the gap from memory.
|
|
51
53
|
- **The fetched documentation is then cross-checked against the live API** — see the rest of this section.
|
|
@@ -133,4 +135,5 @@ A production-ready integration spec captures every corner case the API can throw
|
|
|
133
135
|
- **Authoring against unverified credentials.** Validate first; if the user has no credentials yet, flag it in the module's frontmatter description and re-validate once credentials arrive
|
|
134
136
|
- **`requires`-ing a separate-stack module** (a Python backend `requires`-ing a React frontend, or vice versa) — see [`requires-modules.md`](requires-modules.md). Use a shared API schema in `resources/` instead
|
|
135
137
|
- **Authoring Phase 1 specs from the docs first and "reconciling" with the live API later.** Probe the API as you reach each topic; the live response is the source of truth from the moment it's captured
|
|
136
|
-
- **Writing any integration spec from memory of the provider's API instead of `fetch`-ing
|
|
138
|
+
- **Writing any integration spec from memory of the provider's API instead of web-searching for its documentation and `fetch`-ing every relevant page first.** No matter how well-known the API (Stripe, GitHub, Slack, Salesforce, AWS, OpenAI, …), the canonical pages must be located with web search and then retrieved with `fetch` at spec-authoring time and saved under `resources/docs/<provider>/` — see *Live API must be cross-checked against the documentation*. Authoring from memory bakes in whatever version of the API was current during training, which is always older than the version the integration will actually call
|
|
139
|
+
- **Guessing a documentation URL from memory and `fetch`-ing it without searching first.** A remembered URL may 404, redirect to a stale version, or miss the page that actually documents the topic. Search to find the authoritative, current links, then fetch the full set
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "plain-forge",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.11",
|
|
4
4
|
"description": "Conversational spec-writing tool for ***plain specification language",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -32,7 +32,8 @@
|
|
|
32
32
|
],
|
|
33
33
|
"scripts": {
|
|
34
34
|
"build": "tsx bin/forge-build.ts",
|
|
35
|
-
"clean": "tsx bin/forge-build.ts --clean"
|
|
35
|
+
"clean": "tsx bin/forge-build.ts --clean",
|
|
36
|
+
"test": "node --test \"test/**/*.test.mjs\""
|
|
36
37
|
},
|
|
37
38
|
"devDependencies": {
|
|
38
39
|
"@types/node": "^22.10.0",
|