plain-forge 1.0.10 → 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.
Files changed (3) hide show
  1. package/README.md +13 -1
  2. package/bin/cli.mjs +357 -37
  3. 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
- Re-running `npx plain-forge install` overwrites the destination silently, so it doubles as the upgrade path `npx plain-forge@latest install` pulls the newest release every time.
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 install [options]
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
- Options:
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
- Missing flags are prompted interactively.`);
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
- function copyTree(srcDir, destDir) {
145
- if (!fs.existsSync(srcDir)) return 0;
146
- fs.mkdirSync(destDir, { recursive: true });
147
- let count = 0;
148
- for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
149
- const src = path.join(srcDir, entry.name);
150
- const dest = path.join(destDir, entry.name);
151
- if (entry.isDirectory()) {
152
- fs.cpSync(src, dest, { recursive: true, force: true, dereference: true });
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.copyFileSync(src, dest);
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 count;
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 skillsCount = copyTree(
184
- path.join(forgeDir, "skills"),
185
- path.join(baseDir, "skills"),
186
- );
187
- const rulesCount = copyTree(
188
- path.join(forgeDir, "rules"),
189
- path.join(baseDir, "rules"),
190
- );
191
- const docsCount = copyTree(
192
- path.join(forgeDir, "docs"),
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: ${skillsCount}`);
198
- console.log(` rules: ${rulesCount}`);
199
- console.log(` docs: ${docsCount}`);
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
- main().catch((err) => {
269
- if (err instanceof Error && err.message === "cancelled") {
270
- process.exit(130);
271
- }
272
- console.error(err instanceof Error ? (err.stack ?? err.message) : err);
273
- process.exit(1);
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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plain-forge",
3
- "version": "1.0.10",
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",