plain-forge 1.0.10 → 1.0.12
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 +8 -44
- package/bin/cli.mjs +369 -36
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -42,9 +42,9 @@ Each skill operates on the same one-question-at-a-time, write-immediately, refin
|
|
|
42
42
|
|
|
43
43
|
plain-forge ships as a set of skills, rules, and docs that plug into your AI coding tool of choice. Install it once, then invoke `forge-plain` (or `add-feature` to add a feature to an existing ***plain project) from any project.
|
|
44
44
|
|
|
45
|
-
### Install with `npx plain-forge install`
|
|
45
|
+
### Install with `npx plain-forge install`
|
|
46
46
|
|
|
47
|
-
The
|
|
47
|
+
The one and only way to install plain-forge. It works for every supported runtime and ships **all** plain-forge content (skills, rules, **and** docs).
|
|
48
48
|
|
|
49
49
|
```bash
|
|
50
50
|
npx plain-forge install
|
|
@@ -81,41 +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
85
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
These work but only install the skill files. Rules and docs do **not** travel with them, so use them only if you have a reason not to use `npx plain-forge install`.
|
|
89
|
-
|
|
90
|
-
#### `npx skills` CLI
|
|
86
|
+
#### Updating an existing install
|
|
91
87
|
|
|
92
88
|
```bash
|
|
93
|
-
npx
|
|
89
|
+
npx plain-forge@latest update
|
|
94
90
|
```
|
|
95
91
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
#### Claude Code native plugin flow
|
|
99
|
-
|
|
100
|
-
Requires the [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code). Inside a Claude Code session, run the following **three commands** one after the other:
|
|
101
|
-
|
|
102
|
-
```text
|
|
103
|
-
/plugin marketplace add Codeplain-ai/plain-forge
|
|
104
|
-
/plugin install plain-forge@plain-forge
|
|
105
|
-
/reload-plugins
|
|
106
|
-
```
|
|
107
|
-
|
|
108
|
-
Without the reload the skills won't appear in the current session.
|
|
109
|
-
|
|
110
|
-
#### Codex native plugin flow
|
|
111
|
-
|
|
112
|
-
Requires the [OpenAI Codex CLI](https://developers.openai.com/codex/cli/reference). From your shell:
|
|
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.
|
|
113
93
|
|
|
114
|
-
|
|
115
|
-
codex plugin marketplace add Codeplain-ai/plain-forge
|
|
116
|
-
```
|
|
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.
|
|
117
95
|
|
|
118
|
-
|
|
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.
|
|
119
97
|
|
|
120
98
|
## Usage
|
|
121
99
|
|
|
@@ -206,20 +184,6 @@ bin/
|
|
|
206
184
|
.opencode/ # OpenCode plugin layout
|
|
207
185
|
```
|
|
208
186
|
|
|
209
|
-
### Contributing
|
|
210
|
-
|
|
211
|
-
After editing anything under `forge/` or `runtimes/*/templates/`, regenerate the runtime outputs:
|
|
212
|
-
|
|
213
|
-
```bash
|
|
214
|
-
npm install # required after every fresh clone (node_modules/ is gitignored)
|
|
215
|
-
npm run build # regenerate runtime outputs for Claude, Codex, OpenCode
|
|
216
|
-
npm run clean # remove generated outputs and rebuild from scratch
|
|
217
|
-
```
|
|
218
|
-
|
|
219
|
-
If `npm run build` errors with `sh: tsx: command not found`, it means `node_modules/` is missing — run `npm install` first.
|
|
220
|
-
|
|
221
|
-
The build is idempotent — re-running it produces no `git diff`.
|
|
222
|
-
|
|
223
187
|
## Available Skills
|
|
224
188
|
|
|
225
189
|
### Core Workflow
|
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,53 @@ async function main() {
|
|
|
265
555
|
}
|
|
266
556
|
}
|
|
267
557
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
+
// `__filename` (from import.meta.url) is realpath-resolved by Node, but
|
|
561
|
+
// process.argv[1] is the path as invoked — under npx / a global install it's a
|
|
562
|
+
// symlink in node_modules/.bin or the npx cache. Resolve both through realpath
|
|
563
|
+
// so the comparison survives symlinked bins; otherwise main() silently never
|
|
564
|
+
// runs (spinner, then nothing).
|
|
565
|
+
function isInvokedDirectly() {
|
|
566
|
+
const invoked = process.argv[1];
|
|
567
|
+
if (!invoked) return false;
|
|
568
|
+
try {
|
|
569
|
+
return fs.realpathSync(invoked) === __filename;
|
|
570
|
+
} catch {
|
|
571
|
+
return path.resolve(invoked) === __filename;
|
|
271
572
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
573
|
+
}
|
|
574
|
+
const invokedDirectly = isInvokedDirectly();
|
|
575
|
+
if (invokedDirectly) {
|
|
576
|
+
main().catch((err) => {
|
|
577
|
+
if (err instanceof Error && err.message === "cancelled") {
|
|
578
|
+
process.exit(130);
|
|
579
|
+
}
|
|
580
|
+
console.error(err instanceof Error ? (err.stack ?? err.message) : err);
|
|
581
|
+
process.exit(1);
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
export {
|
|
586
|
+
AGENTS,
|
|
587
|
+
SCOPES,
|
|
588
|
+
CONTENT_DIRS,
|
|
589
|
+
MANIFEST_REL,
|
|
590
|
+
FORGE_SIGNATURE_SKILLS,
|
|
591
|
+
hasForgeSignature,
|
|
592
|
+
parseArgs,
|
|
593
|
+
toPosix,
|
|
594
|
+
readPkgVersion,
|
|
595
|
+
compareVersions,
|
|
596
|
+
isUpToDate,
|
|
597
|
+
copyTreeTracked,
|
|
598
|
+
writeContent,
|
|
599
|
+
manifestPathFor,
|
|
600
|
+
readManifest,
|
|
601
|
+
writeManifest,
|
|
602
|
+
removeEmptyDirsUpward,
|
|
603
|
+
collectPruneCandidates,
|
|
604
|
+
deleteForgeFile,
|
|
605
|
+
detectInstalls,
|
|
606
|
+
promptConfirm,
|
|
607
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "plain-forge",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.12",
|
|
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",
|