plain-forge 1.0.15 → 1.0.17
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 +29 -11
- package/bin/cli.mjs +405 -21
- package/forge/rules/definitions.md +2 -1
- package/forge/rules/examples/integration-embedded-testing/run_conformance_tests_java.sh +14 -3
- package/forge/rules/exported-concepts.md +2 -1
- package/forge/rules/func-specs.md +2 -1
- package/forge/rules/impl-reqs.md +2 -1
- package/forge/rules/import-modules.md +2 -1
- package/forge/rules/integration-embedded-testing.md +11 -8
- package/forge/rules/integration-embedded.md +6 -5
- package/forge/rules/integration-standalone.md +2 -1
- package/forge/rules/integrations.md +2 -1
- package/forge/rules/line-length.md +2 -1
- package/forge/rules/linked-resources.md +2 -1
- package/forge/rules/required-concepts.md +2 -1
- package/forge/rules/requires-modules.md +2 -1
- package/forge/rules/test-reqs.md +2 -1
- package/forge/skills/implement-conformance-testing-script/SKILL.md +56 -43
- package/forge/skills/implement-conformance-testing-script/assets/run_conformance_tests_cypress.ps1 +34 -21
- package/forge/skills/implement-conformance-testing-script/assets/run_conformance_tests_golang.ps1 +16 -5
- package/forge/skills/implement-conformance-testing-script/assets/run_conformance_tests_java.sh +22 -11
- package/forge/skills/implement-conformance-testing-script/assets/run_conformance_tests_python.ps1 +13 -2
- package/forge/skills/implement-conformance-testing-script/assets/run_conformance_tests_python.sh +16 -6
- package/forge/skills/implement-prepare-environment-script/SKILL.md +23 -18
- package/forge/skills/implement-prepare-environment-script/assets/prepare_environment_java.sh +8 -4
- package/forge/skills/implement-prepare-environment-script/assets/prepare_environment_python.sh +8 -4
- package/forge/skills/implement-unit-testing-script/SKILL.md +14 -11
- package/forge/skills/implement-unit-testing-script/assets/run_unittests_flutter.ps1 +6 -1
- package/forge/skills/implement-unit-testing-script/assets/run_unittests_golang.ps1 +6 -1
- package/forge/skills/implement-unit-testing-script/assets/run_unittests_java.sh +6 -3
- package/forge/skills/implement-unit-testing-script/assets/run_unittests_python.ps1 +6 -1
- package/forge/skills/implement-unit-testing-script/assets/run_unittests_python.sh +8 -4
- package/forge/skills/implement-unit-testing-script/assets/run_unittests_react.ps1 +6 -2
- package/forge/skills/init-config-file/SKILL.md +5 -5
- package/forge/skills/load-plain-reference/SKILL.md +97 -42
- package/forge/skills/plain-healthcheck/SKILL.md +1 -1
- package/forge/skills/run-codeplain/SKILL.md +12 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -58,29 +58,47 @@ npx plain-forge install --agent claude --scope project
|
|
|
58
58
|
|
|
59
59
|
**Agent options:**
|
|
60
60
|
|
|
61
|
-
| `--agent` | Installs into | Use when |
|
|
61
|
+
| `--agent` | Installs into (project · global) | Use when |
|
|
62
62
|
|-----------|---------------|----------|
|
|
63
|
-
| `claude` | `.claude/` | You use Claude Code |
|
|
64
|
-
| `codex` | `.
|
|
65
|
-
| `forgecode` | `.
|
|
66
|
-
| `
|
|
63
|
+
| `claude` | `.claude/` · `~/.claude/` | You use Claude Code |
|
|
64
|
+
| `codex` | `.agents/` · `~/.agents/` | You use the OpenAI Codex CLI |
|
|
65
|
+
| `forgecode` | `.forge/` · `~/forge/` | You use ForgeCode |
|
|
66
|
+
| `opencode` | `.opencode/` · `~/.config/opencode/` | You use OpenCode |
|
|
67
|
+
| `universal` | `.agents/` · `~/.agents/` | You want a runtime-neutral layout that any agent reading from `.agents/` can pick up |
|
|
68
|
+
|
|
69
|
+
> **Why `codex` and `forgecode` don't install into `.codex/` or `.forgecode/`.** Those tools load *skills* from specific directories that are **not** named after the tool. Per the current (2026) docs, Codex reads skills from `.agents/skills` (`~/.agents/skills` globally) — `.codex/` is config-only — and ForgeCode reads them from `.forge/skills` (`~/forge/skills` globally). plain-forge installs where the skills will actually load. As a result `codex` and `universal` share the `.agents/` layout; the install manifest records which agent you chose so `update`/`uninstall` behave correctly.
|
|
67
70
|
|
|
68
71
|
**Scope options:**
|
|
69
72
|
|
|
70
73
|
| `--scope` | Installs into | Use when |
|
|
71
74
|
|-----------|---------------|----------|
|
|
72
|
-
| `project` |
|
|
73
|
-
| `global` |
|
|
75
|
+
| `project` | the agent's project dir in the current working directory | You want plain-forge in just this project |
|
|
76
|
+
| `global` | the agent's user-level dir in your home directory | You want plain-forge available in every project on the machine |
|
|
77
|
+
|
|
78
|
+
(See the per-agent table above for the exact project/global directory each agent uses — a few diverge from the plain `~/<dir>` pattern, e.g. OpenCode global is `~/.config/opencode/` and ForgeCode global is `~/forge/`.)
|
|
74
79
|
|
|
75
80
|
Each install writes three subfolders under the chosen directory:
|
|
76
81
|
|
|
77
82
|
```
|
|
78
83
|
<agent-dir>/
|
|
79
84
|
skills/ # every plain-forge skill
|
|
80
|
-
rules/ # spec-writing rules (
|
|
85
|
+
rules/ # spec-writing rules (see "How the rules get applied" below)
|
|
81
86
|
docs/ # shared reference docs
|
|
82
87
|
```
|
|
83
88
|
|
|
89
|
+
#### How the rules get applied per agent
|
|
90
|
+
|
|
91
|
+
The `skills/` are discovered natively by every supported agent. The `rules/` (the `.plain`-authoring guidance) are applied differently because each agent loads instruction files its own way — so plain-forge does a bit of extra, agent-specific wiring:
|
|
92
|
+
|
|
93
|
+
| Agent | How rules are applied |
|
|
94
|
+
|-------|-----------------------|
|
|
95
|
+
| **Claude Code** | Native — Claude Code auto-loads `.claude/rules/*.md` at launch. Each rule carries `paths: "**/*.plain"` frontmatter so Claude scopes it to `.plain` files. No extra wiring. |
|
|
96
|
+
| **OpenCode** | OpenCode doesn't auto-load a `rules/` dir; it reads globs from the `instructions` array in `opencode.json`. plain-forge **merges** a rules glob there — `.opencode/rules/*.md` (project, written to `./opencode.json`) or the absolute `~/.config/opencode/rules/*.md` (global, written to `~/.config/opencode/opencode.json`; absolute because OpenCode doesn't expand `~`). |
|
|
97
|
+
| **Codex / ForgeCode** | Neither auto-loads a `rules/` dir; both read custom instructions only from `AGENTS.md`. plain-forge appends a fenced, **managed pointer block** to `AGENTS.md` telling the agent to read the installed rule files. Project → repo-root `./AGENTS.md`; global → `~/.codex/AGENTS.md` (Codex) or `~/forge/AGENTS.md` (ForgeCode). |
|
|
98
|
+
| **Universal** | Verbatim copy only — the rules land in `.agents/rules/`, and it's up to whichever agent reads `.agents/` to consume them. No config is generated. |
|
|
99
|
+
|
|
100
|
+
In every case where plain-forge touches a config or `AGENTS.md` file, it **merges** into what's already there — adding only its own glob/block and leaving all your other content intact — and it never writes into a malformed config (it warns instead). `uninstall` reverses the wiring: it removes only plain-forge's glob/block, deleting the file only if it contained nothing else.
|
|
101
|
+
|
|
84
102
|
`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
103
|
|
|
86
104
|
#### Updating an existing install
|
|
@@ -101,7 +119,7 @@ Installs that predate the manifest (anyone who installed before this feature exi
|
|
|
101
119
|
npx plain-forge uninstall
|
|
102
120
|
```
|
|
103
121
|
|
|
104
|
-
`uninstall` reads the install manifest (`<agent-dir>/.plain-forge/manifest.json`) and deletes **exactly** the files plain-forge wrote, then the manifest itself, then any directory left empty (including the agent directory). Your own skills and third-party content are never in the manifest, so they are never touched.
|
|
122
|
+
`uninstall` reads the install manifest (`<agent-dir>/.plain-forge/manifest.json`) and deletes **exactly** the files plain-forge wrote, then the manifest itself, then removes its rules wiring (the `opencode.json` glob or the `AGENTS.md` pointer block, for the relevant agents), then any directory left empty (including the agent directory). Your own skills and third-party content are never in the manifest, so they are never touched.
|
|
105
123
|
|
|
106
124
|
By default it removes **every** agent layout in the **project** scope (the current folder). Narrow it with flags:
|
|
107
125
|
|
|
@@ -111,7 +129,7 @@ npx plain-forge uninstall --agent claude --scope global
|
|
|
111
129
|
|
|
112
130
|
| Flag | Default | Values |
|
|
113
131
|
|------|---------|--------|
|
|
114
|
-
| `--agent` | `*` (all agents) | `claude`, `codex`, `forgecode`, `universal`, or `*` |
|
|
132
|
+
| `--agent` | `*` (all agents) | `claude`, `codex`, `forgecode`, `opencode`, `universal`, or `*` |
|
|
115
133
|
| `--scope` | `project` | `project` (cwd) or `global` (home directory) |
|
|
116
134
|
|
|
117
135
|
If an install has **no manifest** (e.g. one that predates manifests), `uninstall` cannot tell which files are plain-forge's, so it refuses to delete anything: it prints an error, lists the directories to clean up by hand, and exits non-zero. Refresh such an install with `update` first (which writes a manifest going forward), then `uninstall`.
|
|
@@ -191,7 +209,7 @@ test/
|
|
|
191
209
|
package.json # ships only `bin/cli.mjs` and `forge/` to npm
|
|
192
210
|
```
|
|
193
211
|
|
|
194
|
-
On `install`, the CLI reads `forge/skills` and `forge/rules` and writes them into the chosen agent directory (`.claude/`, `.
|
|
212
|
+
On `install`, the CLI reads `forge/skills` and `forge/rules` and writes them into the chosen agent directory (`.claude/`, `.agents/` for Codex/universal, `.forge/`, or `.opencode/` — see the per-agent table above for global-scope paths), recording every file it wrote in `<agent-dir>/.plain-forge/manifest.json` so `update` can later refresh and prune precisely. For agents that need it, it also wires the rules into `opencode.json` or `AGENTS.md` (see [How the rules get applied per agent](#how-the-rules-get-applied-per-agent)).
|
|
195
213
|
|
|
196
214
|
## Available Skills
|
|
197
215
|
|
package/bin/cli.mjs
CHANGED
|
@@ -9,10 +9,22 @@ const __filename = fileURLToPath(import.meta.url);
|
|
|
9
9
|
const pkgRoot = path.resolve(path.dirname(__filename), "..");
|
|
10
10
|
const forgeDir = path.join(pkgRoot, "forge");
|
|
11
11
|
|
|
12
|
+
// Agent name → the project-scope content directory plain-forge writes into.
|
|
13
|
+
// These are the directories each tool actually loads skills from (verified
|
|
14
|
+
// against the 2026 docs), NOT necessarily a dir named after the tool:
|
|
15
|
+
// - Codex reads skills from .agents/skills (never .codex/skills); .codex is
|
|
16
|
+
// config-only. So codex shares the .agents layout with `universal`.
|
|
17
|
+
// - ForgeCode reads skills from .forge/skills (never .forgecode/skills); its
|
|
18
|
+
// global dir is ~/forge (no dot) — see resolveBaseDir.
|
|
19
|
+
// - OpenCode reads project skills from .opencode; global is ~/.config/opencode
|
|
20
|
+
// — see resolveBaseDir.
|
|
21
|
+
// Some scopes/agents diverge from `path.join(root, dir)`; resolveBaseDir owns
|
|
22
|
+
// those exceptions.
|
|
12
23
|
const AGENTS = {
|
|
13
24
|
claude: ".claude",
|
|
14
|
-
codex: ".
|
|
15
|
-
forgecode: ".
|
|
25
|
+
codex: ".agents",
|
|
26
|
+
forgecode: ".forge",
|
|
27
|
+
opencode: ".opencode",
|
|
16
28
|
universal: ".agents",
|
|
17
29
|
};
|
|
18
30
|
const SCOPES = ["project", "global"];
|
|
@@ -83,7 +95,8 @@ Commands:
|
|
|
83
95
|
uninstall Remove a plain-forge install using its manifest
|
|
84
96
|
|
|
85
97
|
Install options:
|
|
86
|
-
--agent <claude|codex|forgecode|universal>
|
|
98
|
+
--agent <claude|codex|forgecode|opencode|universal>
|
|
99
|
+
Target agent layout
|
|
87
100
|
--scope <project|global> Install into cwd or $HOME
|
|
88
101
|
-h, --help Show this help
|
|
89
102
|
|
|
@@ -92,7 +105,8 @@ Update options:
|
|
|
92
105
|
confirming each one
|
|
93
106
|
|
|
94
107
|
Uninstall options:
|
|
95
|
-
--agent <claude|codex|forgecode|universal|*>
|
|
108
|
+
--agent <claude|codex|forgecode|opencode|universal|*>
|
|
109
|
+
Which install to remove
|
|
96
110
|
(default: * — every agent)
|
|
97
111
|
--scope <project|global> Where to look (default: project)
|
|
98
112
|
|
|
@@ -292,6 +306,337 @@ function writeContent(baseDir) {
|
|
|
292
306
|
return { counts, files };
|
|
293
307
|
}
|
|
294
308
|
|
|
309
|
+
// Absolute install directory for an agent in a given scope. Global scope roots
|
|
310
|
+
// at $HOME, project scope at the cwd. OpenCode is the one exception: its global
|
|
311
|
+
// config lives under XDG at ~/.config/opencode — it does NOT read ~/.opencode —
|
|
312
|
+
// while its project layout is ./.opencode like every other agent.
|
|
313
|
+
function resolveBaseDir(agent, scope) {
|
|
314
|
+
const root = scope === "global" ? os.homedir() : process.cwd();
|
|
315
|
+
if (agent === "opencode" && scope === "global") {
|
|
316
|
+
return path.join(root, ".config", "opencode");
|
|
317
|
+
}
|
|
318
|
+
if (agent === "forgecode" && scope === "global") {
|
|
319
|
+
// ForgeCode's global content dir is ~/forge, not ~/.forge.
|
|
320
|
+
return path.join(root, "forge");
|
|
321
|
+
}
|
|
322
|
+
return path.join(root, AGENTS[agent]);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// --- OpenCode instructions wiring -------------------------------------------
|
|
326
|
+
// OpenCode does not auto-read a rules/ directory the way it does skills/; it
|
|
327
|
+
// loads extra instruction files from the `instructions` glob array in
|
|
328
|
+
// opencode.json. So for opencode we merge a glob pointing at our rules dir into
|
|
329
|
+
// that file (creating it if needed, never disturbing the user's own keys) so
|
|
330
|
+
// the ***plain rules actually apply. This is opencode-specific — every other
|
|
331
|
+
// agent just gets the verbatim file copy.
|
|
332
|
+
|
|
333
|
+
const OPENCODE_SCHEMA = "https://opencode.ai/config.json";
|
|
334
|
+
|
|
335
|
+
// Location of opencode.json for a scope. Project config lives at the project
|
|
336
|
+
// root (cwd), global config under ~/.config/opencode.
|
|
337
|
+
function opencodeConfigPath(scope) {
|
|
338
|
+
if (scope === "global") {
|
|
339
|
+
return path.join(os.homedir(), ".config", "opencode", "opencode.json");
|
|
340
|
+
}
|
|
341
|
+
return path.join(process.cwd(), "opencode.json");
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// The instructions glob to add. Project scope uses a cwd-relative glob (opencode
|
|
345
|
+
// resolves relative instruction paths against the launch cwd). Global scope must
|
|
346
|
+
// be absolute: opencode does not expand ~ and would resolve a relative path
|
|
347
|
+
// against the project cwd, not the config dir. Only top-level rules/*.md are
|
|
348
|
+
// wired in — the examples/ subtree holds reference scripts, not instructions.
|
|
349
|
+
function opencodeRulesGlob(scope) {
|
|
350
|
+
if (scope === "global") {
|
|
351
|
+
return toPosix(
|
|
352
|
+
path.join(os.homedir(), ".config", "opencode", "rules", "*.md"),
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
return ".opencode/rules/*.md";
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Add our rules glob to opencode.json's `instructions`, creating or merging the
|
|
359
|
+
// file. Returns a status: "created" | "merged" | "present" | "skipped".
|
|
360
|
+
function mergeOpencodeInstructions(scope) {
|
|
361
|
+
const configPath = opencodeConfigPath(scope);
|
|
362
|
+
const glob = opencodeRulesGlob(scope);
|
|
363
|
+
const existed = fs.existsSync(configPath);
|
|
364
|
+
|
|
365
|
+
let config = {};
|
|
366
|
+
if (existed) {
|
|
367
|
+
try {
|
|
368
|
+
config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
369
|
+
} catch {
|
|
370
|
+
return { status: "skipped", reason: "malformed", configPath, glob };
|
|
371
|
+
}
|
|
372
|
+
if (config === null || typeof config !== "object" || Array.isArray(config)) {
|
|
373
|
+
return { status: "skipped", reason: "not-an-object", configPath, glob };
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const list = Array.isArray(config.instructions) ? config.instructions : [];
|
|
378
|
+
if (list.includes(glob)) return { status: "present", configPath, glob };
|
|
379
|
+
|
|
380
|
+
// Only stamp $schema onto a file we are creating; never touch an existing one.
|
|
381
|
+
if (!existed && config.$schema === undefined) config.$schema = OPENCODE_SCHEMA;
|
|
382
|
+
config.instructions = list.concat(glob);
|
|
383
|
+
|
|
384
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
385
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
386
|
+
return { status: existed ? "merged" : "created", configPath, glob };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Reverse of the merge: drop our glob from opencode.json's `instructions`. If
|
|
390
|
+
// that leaves a file containing only our scaffold ($schema + now-empty
|
|
391
|
+
// instructions), delete it; otherwise write back the trimmed config, leaving
|
|
392
|
+
// every user-authored key intact. Returns "removed" | "updated" | "absent" |
|
|
393
|
+
// "skipped".
|
|
394
|
+
function unmergeOpencodeInstructions(scope) {
|
|
395
|
+
const configPath = opencodeConfigPath(scope);
|
|
396
|
+
const glob = opencodeRulesGlob(scope);
|
|
397
|
+
if (!fs.existsSync(configPath)) return { status: "absent", configPath, glob };
|
|
398
|
+
|
|
399
|
+
let config;
|
|
400
|
+
try {
|
|
401
|
+
config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
402
|
+
} catch {
|
|
403
|
+
return { status: "skipped", reason: "malformed", configPath, glob };
|
|
404
|
+
}
|
|
405
|
+
if (config === null || typeof config !== "object" || Array.isArray(config)) {
|
|
406
|
+
return { status: "skipped", reason: "not-an-object", configPath, glob };
|
|
407
|
+
}
|
|
408
|
+
if (!Array.isArray(config.instructions) || !config.instructions.includes(glob)) {
|
|
409
|
+
return { status: "absent", configPath, glob };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
config.instructions = config.instructions.filter((g) => g !== glob);
|
|
413
|
+
|
|
414
|
+
const onlyScaffold =
|
|
415
|
+
config.instructions.length === 0 &&
|
|
416
|
+
Object.keys(config).every((k) => k === "$schema" || k === "instructions");
|
|
417
|
+
if (onlyScaffold) {
|
|
418
|
+
fs.rmSync(configPath, { force: true });
|
|
419
|
+
return { status: "removed", configPath, glob };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (config.instructions.length === 0) delete config.instructions;
|
|
423
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
424
|
+
return { status: "updated", configPath, glob };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Human-readable line for a merge result, printed under the install/update
|
|
428
|
+
// summary. A skip is a warning the user should see (their config is untouched).
|
|
429
|
+
function reportOpencodeMerge(result) {
|
|
430
|
+
switch (result.status) {
|
|
431
|
+
case "created":
|
|
432
|
+
console.log(` opencode.json: created, wired rules via "${result.glob}"`);
|
|
433
|
+
break;
|
|
434
|
+
case "merged":
|
|
435
|
+
console.log(` opencode.json: added rules glob "${result.glob}"`);
|
|
436
|
+
break;
|
|
437
|
+
case "present":
|
|
438
|
+
console.log(` opencode.json: rules glob already present`);
|
|
439
|
+
break;
|
|
440
|
+
case "skipped":
|
|
441
|
+
console.log(
|
|
442
|
+
` opencode.json: left untouched (${result.reason}) — add "${result.glob}" to its "instructions" manually`,
|
|
443
|
+
);
|
|
444
|
+
break;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Human-readable line for an unmerge result during uninstall.
|
|
449
|
+
function reportOpencodeUnmerge(result) {
|
|
450
|
+
switch (result.status) {
|
|
451
|
+
case "removed":
|
|
452
|
+
console.log(` opencode.json: removed (was only plain-forge's rules wiring)`);
|
|
453
|
+
break;
|
|
454
|
+
case "updated":
|
|
455
|
+
console.log(` opencode.json: removed rules glob, kept your other config`);
|
|
456
|
+
break;
|
|
457
|
+
case "skipped":
|
|
458
|
+
console.log(
|
|
459
|
+
` opencode.json: left untouched (${result.reason}) — remove "${result.glob}" from "instructions" manually`,
|
|
460
|
+
);
|
|
461
|
+
break;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// --- Codex / ForgeCode instructions wiring ----------------------------------
|
|
466
|
+
// Neither tool auto-reads a rules/ directory; both load custom instructions
|
|
467
|
+
// only from AGENTS.md (Codex: repo AGENTS.md or ~/.codex/AGENTS.md; ForgeCode:
|
|
468
|
+
// repo AGENTS.md or ~/forge/AGENTS.md). AGENTS.md has no include/glob mechanism,
|
|
469
|
+
// so we append a managed pointer block that tells the agent to read our rules
|
|
470
|
+
// dir when touching .plain files. The block is fenced by markers so it can be
|
|
471
|
+
// refreshed or removed idempotently without disturbing the user's own content.
|
|
472
|
+
|
|
473
|
+
const AGENTS_MD_BEGIN = "<!-- BEGIN plain-forge (managed) -->";
|
|
474
|
+
const AGENTS_MD_END = "<!-- END plain-forge (managed) -->";
|
|
475
|
+
|
|
476
|
+
// Agents whose rules are delivered through AGENTS.md rather than a natively
|
|
477
|
+
// auto-loaded rules/ dir (claude) or a config glob (opencode).
|
|
478
|
+
function usesAgentsMd(agent) {
|
|
479
|
+
return agent === "codex" || agent === "forgecode";
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
483
|
+
|
|
484
|
+
// Where the tool reads AGENTS.md from for a scope. Project scope is the repo
|
|
485
|
+
// root (cwd) for both tools; global scope differs per tool.
|
|
486
|
+
function agentsMdPath(agent, scope) {
|
|
487
|
+
if (scope === "project") return path.join(process.cwd(), "AGENTS.md");
|
|
488
|
+
if (agent === "forgecode") return path.join(os.homedir(), "forge", "AGENTS.md");
|
|
489
|
+
return path.join(os.homedir(), ".codex", "AGENTS.md"); // codex global
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Glob the pointer block references — the installed rules dir. Project scope is
|
|
493
|
+
// written relative to the repo root; global scope must be absolute (these files
|
|
494
|
+
// are consulted from arbitrary working directories).
|
|
495
|
+
function agentsMdRulesGlob(agent, scope) {
|
|
496
|
+
const rulesDir = path.join(resolveBaseDir(agent, scope), "rules");
|
|
497
|
+
if (scope === "global") return toPosix(path.join(rulesDir, "*.md"));
|
|
498
|
+
return toPosix(path.join(path.relative(process.cwd(), rulesDir), "*.md"));
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function agentsMdBlock(glob) {
|
|
502
|
+
return [
|
|
503
|
+
AGENTS_MD_BEGIN,
|
|
504
|
+
"## ***plain authoring rules (plain-forge)",
|
|
505
|
+
"",
|
|
506
|
+
"When creating or editing `.plain` specification files, first read and follow",
|
|
507
|
+
`every rule file matching \`${glob}\`. Each file covers one section or topic of`,
|
|
508
|
+
"the ***plain language; they are installed and maintained by plain-forge.",
|
|
509
|
+
AGENTS_MD_END,
|
|
510
|
+
].join("\n");
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Add or refresh the managed pointer block in AGENTS.md, creating the file if
|
|
514
|
+
// needed and preserving all user content. Returns a status:
|
|
515
|
+
// "created" | "merged" | "refreshed" | "present".
|
|
516
|
+
function mergeAgentsMd(agent, scope) {
|
|
517
|
+
const filePath = agentsMdPath(agent, scope);
|
|
518
|
+
const glob = agentsMdRulesGlob(agent, scope);
|
|
519
|
+
const block = agentsMdBlock(glob);
|
|
520
|
+
const existed = fs.existsSync(filePath);
|
|
521
|
+
let content = existed ? fs.readFileSync(filePath, "utf8") : "";
|
|
522
|
+
|
|
523
|
+
const hadBlock = content.includes(AGENTS_MD_BEGIN);
|
|
524
|
+
if (hadBlock) {
|
|
525
|
+
const re = new RegExp(
|
|
526
|
+
`${escapeRe(AGENTS_MD_BEGIN)}[\\s\\S]*?${escapeRe(AGENTS_MD_END)}`,
|
|
527
|
+
);
|
|
528
|
+
const replaced = content.replace(re, block);
|
|
529
|
+
if (replaced === content) return { status: "present", filePath, glob };
|
|
530
|
+
content = replaced;
|
|
531
|
+
} else {
|
|
532
|
+
if (content && !content.endsWith("\n")) content += "\n";
|
|
533
|
+
if (content) content += "\n";
|
|
534
|
+
content += block + "\n";
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
538
|
+
fs.writeFileSync(filePath, content);
|
|
539
|
+
const status = !existed ? "created" : hadBlock ? "refreshed" : "merged";
|
|
540
|
+
return { status, filePath, glob };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Remove the managed pointer block. Deletes the file if nothing but the block
|
|
544
|
+
// (and whitespace) remains; otherwise writes back the user's content.
|
|
545
|
+
// Returns "removed" | "updated" | "absent".
|
|
546
|
+
function unmergeAgentsMd(agent, scope) {
|
|
547
|
+
const filePath = agentsMdPath(agent, scope);
|
|
548
|
+
if (!fs.existsSync(filePath)) return { status: "absent", filePath };
|
|
549
|
+
let content = fs.readFileSync(filePath, "utf8");
|
|
550
|
+
if (!content.includes(AGENTS_MD_BEGIN)) return { status: "absent", filePath };
|
|
551
|
+
|
|
552
|
+
const re = new RegExp(
|
|
553
|
+
`\\n*${escapeRe(AGENTS_MD_BEGIN)}[\\s\\S]*?${escapeRe(AGENTS_MD_END)}\\n*`,
|
|
554
|
+
);
|
|
555
|
+
content = content.replace(re, "\n");
|
|
556
|
+
|
|
557
|
+
if (content.trim() === "") {
|
|
558
|
+
fs.rmSync(filePath, { force: true });
|
|
559
|
+
return { status: "removed", filePath };
|
|
560
|
+
}
|
|
561
|
+
content = content.replace(/\n{3,}/g, "\n\n").replace(/^\n+/, "");
|
|
562
|
+
if (!content.endsWith("\n")) content += "\n";
|
|
563
|
+
fs.writeFileSync(filePath, content);
|
|
564
|
+
return { status: "updated", filePath };
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function reportAgentsMdMerge(result) {
|
|
568
|
+
const rel = result.filePath;
|
|
569
|
+
switch (result.status) {
|
|
570
|
+
case "created":
|
|
571
|
+
console.log(` AGENTS.md: created at ${rel}, wired rules via "${result.glob}"`);
|
|
572
|
+
break;
|
|
573
|
+
case "merged":
|
|
574
|
+
console.log(` AGENTS.md: added rules block to ${rel}`);
|
|
575
|
+
break;
|
|
576
|
+
case "refreshed":
|
|
577
|
+
console.log(` AGENTS.md: refreshed rules block in ${rel}`);
|
|
578
|
+
break;
|
|
579
|
+
case "present":
|
|
580
|
+
console.log(` AGENTS.md: rules block already up to date`);
|
|
581
|
+
break;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function reportAgentsMdUnmerge(result) {
|
|
586
|
+
switch (result.status) {
|
|
587
|
+
case "removed":
|
|
588
|
+
console.log(` AGENTS.md: removed (was only plain-forge's rules block)`);
|
|
589
|
+
break;
|
|
590
|
+
case "updated":
|
|
591
|
+
console.log(` AGENTS.md: removed rules block, kept your other content`);
|
|
592
|
+
break;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Wire rules for an agent whose mechanism needs it, during install/update.
|
|
597
|
+
// The skills/rules/docs and manifest are already on disk by the time this runs,
|
|
598
|
+
// so a failure here (e.g. an unwritable opencode.json/AGENTS.md) must not fail
|
|
599
|
+
// the whole install — it degrades to a warning and the install still succeeds.
|
|
600
|
+
function wireRules(agent, scope) {
|
|
601
|
+
try {
|
|
602
|
+
if (agent === "opencode") {
|
|
603
|
+
reportOpencodeMerge(mergeOpencodeInstructions(scope));
|
|
604
|
+
} else if (usesAgentsMd(agent)) {
|
|
605
|
+
reportAgentsMdMerge(mergeAgentsMd(agent, scope));
|
|
606
|
+
}
|
|
607
|
+
} catch (err) {
|
|
608
|
+
warnRulesWiring("wire", agent, err);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Reverse of wireRules, during uninstall. Also non-fatal: the manifest files
|
|
613
|
+
// are already gone, so a failure to tidy up the rules wiring is a warning, not
|
|
614
|
+
// an uninstall failure.
|
|
615
|
+
function unwireRules(agent, scope) {
|
|
616
|
+
try {
|
|
617
|
+
if (agent === "opencode") {
|
|
618
|
+
reportOpencodeUnmerge(unmergeOpencodeInstructions(scope));
|
|
619
|
+
} else if (usesAgentsMd(agent)) {
|
|
620
|
+
reportAgentsMdUnmerge(unmergeAgentsMd(agent, scope));
|
|
621
|
+
}
|
|
622
|
+
} catch (err) {
|
|
623
|
+
warnRulesWiring("unwire", agent, err);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function warnRulesWiring(action, agent, err) {
|
|
628
|
+
const target = agent === "opencode" ? "opencode.json" : "AGENTS.md";
|
|
629
|
+
const verb = action === "wire" ? "wire up" : "clean up";
|
|
630
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
631
|
+
console.warn(
|
|
632
|
+
` warning: could not ${verb} the ${agent} rules in ${target} (${msg}).`,
|
|
633
|
+
);
|
|
634
|
+
console.warn(
|
|
635
|
+
` the skills and rule files were installed successfully; see the README`,
|
|
636
|
+
);
|
|
637
|
+
console.warn(` section "How the rules get applied per agent" to finish by hand.`);
|
|
638
|
+
}
|
|
639
|
+
|
|
295
640
|
function manifestPathFor(baseDir) {
|
|
296
641
|
return path.join(baseDir, MANIFEST_REL);
|
|
297
642
|
}
|
|
@@ -306,12 +651,16 @@ function readManifest(baseDir) {
|
|
|
306
651
|
return null;
|
|
307
652
|
}
|
|
308
653
|
|
|
309
|
-
function writeManifest(baseDir, files) {
|
|
654
|
+
function writeManifest(baseDir, files, agent) {
|
|
310
655
|
const target = manifestPathFor(baseDir);
|
|
311
656
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
657
|
+
// `agent` records which agent layout produced this install. It matters when
|
|
658
|
+
// two agents resolve to the same dir (codex and universal both use .agents):
|
|
659
|
+
// it lets detect/update/uninstall know whether AGENTS.md rule-wiring applies.
|
|
312
660
|
const manifest = {
|
|
313
661
|
name: "plain-forge",
|
|
314
662
|
version: readPkgVersion(),
|
|
663
|
+
...(agent ? { agent } : {}),
|
|
315
664
|
files: files.map(toPosix).sort(),
|
|
316
665
|
};
|
|
317
666
|
fs.writeFileSync(target, JSON.stringify(manifest, null, 2) + "\n");
|
|
@@ -361,15 +710,22 @@ function deleteForgeFile(baseDir, rel) {
|
|
|
361
710
|
// the flagship skill.
|
|
362
711
|
function detectInstalls() {
|
|
363
712
|
const installs = [];
|
|
713
|
+
const seen = new Set();
|
|
364
714
|
for (const scope of SCOPES) {
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
|
|
715
|
+
for (const agent of Object.keys(AGENTS)) {
|
|
716
|
+
const baseDir = resolveBaseDir(agent, scope);
|
|
717
|
+
// Some agents share a directory (codex and universal both use .agents).
|
|
718
|
+
// Only inspect each physical dir once so a single install isn't detected
|
|
719
|
+
// — and later updated/pruned — twice.
|
|
720
|
+
if (seen.has(baseDir)) continue;
|
|
721
|
+
seen.add(baseDir);
|
|
368
722
|
if (!fs.existsSync(baseDir)) continue;
|
|
369
723
|
const manifest = readManifest(baseDir);
|
|
370
724
|
const isLegacy = !manifest && hasForgeSignature(baseDir);
|
|
371
725
|
if (manifest || isLegacy) {
|
|
372
|
-
|
|
726
|
+
// Prefer the agent recorded at install time; fall back to the dir's
|
|
727
|
+
// first-mapped agent for legacy/manifest-less installs.
|
|
728
|
+
installs.push({ agent: manifest?.agent ?? agent, scope, baseDir, manifest });
|
|
373
729
|
}
|
|
374
730
|
}
|
|
375
731
|
}
|
|
@@ -395,8 +751,7 @@ async function cmdInstall(args) {
|
|
|
395
751
|
process.exit(2);
|
|
396
752
|
}
|
|
397
753
|
|
|
398
|
-
const
|
|
399
|
-
const baseDir = path.join(root, AGENTS[agent]);
|
|
754
|
+
const baseDir = resolveBaseDir(agent, scope);
|
|
400
755
|
|
|
401
756
|
const alreadyInstalled =
|
|
402
757
|
readManifest(baseDir) !== null || hasForgeSignature(baseDir);
|
|
@@ -407,12 +762,13 @@ async function cmdInstall(args) {
|
|
|
407
762
|
}
|
|
408
763
|
|
|
409
764
|
const { counts, files } = writeContent(baseDir);
|
|
410
|
-
writeManifest(baseDir, files);
|
|
765
|
+
writeManifest(baseDir, files, agent);
|
|
411
766
|
|
|
412
767
|
console.log(`installed into ${baseDir}`);
|
|
413
768
|
console.log(` skills: ${counts.skills}`);
|
|
414
769
|
console.log(` rules: ${counts.rules}`);
|
|
415
770
|
console.log(` docs: ${counts.docs}`);
|
|
771
|
+
wireRules(agent, scope);
|
|
416
772
|
console.log();
|
|
417
773
|
printNextSteps(agent);
|
|
418
774
|
}
|
|
@@ -453,6 +809,10 @@ async function cmdUpdate(args) {
|
|
|
453
809
|
console.log(
|
|
454
810
|
` skills: ${counts.skills} rules: ${counts.rules} docs: ${counts.docs}`,
|
|
455
811
|
);
|
|
812
|
+
// Re-assert rules wiring (opencode.json / AGENTS.md) — installs from before
|
|
813
|
+
// this feature lack it; the merge is idempotent for those that already have
|
|
814
|
+
// it, and refreshes the glob if the layout changed.
|
|
815
|
+
wireRules(inst.agent, inst.scope);
|
|
456
816
|
|
|
457
817
|
// Pruning only applies to manifest-tracked installs. Each deprecated file
|
|
458
818
|
// is confirmed individually before removal; denied files stay on disk and
|
|
@@ -484,7 +844,7 @@ async function cmdUpdate(args) {
|
|
|
484
844
|
|
|
485
845
|
// Manifest reflects what's actually on disk: the fresh files plus any
|
|
486
846
|
// deprecated files the user chose to keep.
|
|
487
|
-
writeManifest(inst.baseDir, files.concat(kept));
|
|
847
|
+
writeManifest(inst.baseDir, files.concat(kept), inst.agent);
|
|
488
848
|
console.log();
|
|
489
849
|
updated++;
|
|
490
850
|
}
|
|
@@ -528,7 +888,7 @@ async function cmdUninstall(args) {
|
|
|
528
888
|
let hadError = false;
|
|
529
889
|
|
|
530
890
|
for (const agent of agents) {
|
|
531
|
-
const baseDir =
|
|
891
|
+
const baseDir = resolveBaseDir(agent, scope);
|
|
532
892
|
if (!fs.existsSync(baseDir)) continue;
|
|
533
893
|
|
|
534
894
|
const manifest = readManifest(baseDir);
|
|
@@ -560,19 +920,31 @@ async function cmdUninstall(args) {
|
|
|
560
920
|
if (deleteForgeFile(baseDir, rel)) deleted++;
|
|
561
921
|
else failed++;
|
|
562
922
|
}
|
|
563
|
-
//
|
|
564
|
-
// directory and the agent directory if nothing else remains in it.
|
|
923
|
+
// Remove the manifest itself.
|
|
565
924
|
fs.rmSync(manifestPathFor(baseDir), { force: true });
|
|
566
|
-
removeEmptyDirsUpward(
|
|
567
|
-
path.join(baseDir, path.dirname(MANIFEST_REL)),
|
|
568
|
-
baseDir,
|
|
569
|
-
);
|
|
570
|
-
removeEmptyDirsUpward(baseDir, root);
|
|
571
925
|
|
|
572
926
|
console.log(`uninstalled ${agent} (${scope}) from ${baseDir}`);
|
|
573
927
|
console.log(
|
|
574
928
|
` removed ${deleted} file(s)${failed ? `, ${failed} could not be removed` : ""} + manifest`,
|
|
575
929
|
);
|
|
930
|
+
|
|
931
|
+
// Undo the rules wiring (opencode.json / AGENTS.md). Keyed off the agent
|
|
932
|
+
// recorded at install time, not the requested name — codex and universal
|
|
933
|
+
// share the .agents dir, and only the codex install wired AGENTS.md. Done
|
|
934
|
+
// before the dir cleanup so a now-orphaned config file living inside baseDir
|
|
935
|
+
// (e.g. forgecode-global ~/forge/AGENTS.md) can be removed and let baseDir
|
|
936
|
+
// itself be pruned.
|
|
937
|
+
unwireRules(manifest.agent ?? agent, scope);
|
|
938
|
+
|
|
939
|
+
// Prune the now-empty .plain-forge directory and the agent directory if
|
|
940
|
+
// nothing else remains in it.
|
|
941
|
+
removeEmptyDirsUpward(
|
|
942
|
+
path.join(baseDir, path.dirname(MANIFEST_REL)),
|
|
943
|
+
baseDir,
|
|
944
|
+
);
|
|
945
|
+
// Stop at the agent dir's parent so only the agent dir itself is pruned
|
|
946
|
+
// (e.g. for opencode-global we remove ~/.config/opencode, never ~/.config).
|
|
947
|
+
removeEmptyDirsUpward(baseDir, path.dirname(baseDir));
|
|
576
948
|
console.log();
|
|
577
949
|
removed++;
|
|
578
950
|
}
|
|
@@ -626,6 +998,8 @@ function agentLabel(agent) {
|
|
|
626
998
|
return "Codex";
|
|
627
999
|
case "forgecode":
|
|
628
1000
|
return "ForgeCode";
|
|
1001
|
+
case "opencode":
|
|
1002
|
+
return "OpenCode";
|
|
629
1003
|
case "universal":
|
|
630
1004
|
return "AI coding agent";
|
|
631
1005
|
default:
|
|
@@ -698,6 +1072,16 @@ export {
|
|
|
698
1072
|
isUpToDate,
|
|
699
1073
|
copyTreeTracked,
|
|
700
1074
|
writeContent,
|
|
1075
|
+
resolveBaseDir,
|
|
1076
|
+
opencodeConfigPath,
|
|
1077
|
+
opencodeRulesGlob,
|
|
1078
|
+
mergeOpencodeInstructions,
|
|
1079
|
+
unmergeOpencodeInstructions,
|
|
1080
|
+
usesAgentsMd,
|
|
1081
|
+
agentsMdPath,
|
|
1082
|
+
agentsMdRulesGlob,
|
|
1083
|
+
mergeAgentsMd,
|
|
1084
|
+
unmergeAgentsMd,
|
|
701
1085
|
manifestPathFor,
|
|
702
1086
|
readManifest,
|
|
703
1087
|
writeManifest,
|
|
@@ -21,7 +21,18 @@ fi
|
|
|
21
21
|
|
|
22
22
|
current_dir=$(pwd)
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
# Resolve the conformance tests folder to an absolute path ($2 may be relative
|
|
25
|
+
# on older renderers; newer renderers pass it as an absolute path).
|
|
26
|
+
case "$2" in
|
|
27
|
+
/*) CONFORMANCE_TESTS_SOURCE="$2" ;;
|
|
28
|
+
*) CONFORMANCE_TESTS_SOURCE="$current_dir/$2" ;;
|
|
29
|
+
esac
|
|
30
|
+
|
|
31
|
+
# Scratch folder lives in the system temp directory so no build debris is left
|
|
32
|
+
# inside the project. It is removed again when the script exits.
|
|
33
|
+
CONFORMANCE_TESTS_FOLDER="/tmp/java_conformance"
|
|
34
|
+
|
|
35
|
+
trap 'rm -rf "$CONFORMANCE_TESTS_FOLDER"' EXIT
|
|
25
36
|
|
|
26
37
|
cd "$current_dir" 2>/dev/null
|
|
27
38
|
|
|
@@ -43,8 +54,8 @@ else
|
|
|
43
54
|
mkdir -p $CONFORMANCE_TESTS_FOLDER
|
|
44
55
|
fi
|
|
45
56
|
|
|
46
|
-
printf "Copying all files and folders from $
|
|
47
|
-
cp -R $
|
|
57
|
+
printf "Copying all files and folders from $CONFORMANCE_TESTS_SOURCE to $CONFORMANCE_TESTS_FOLDER...\n"
|
|
58
|
+
cp -R "$CONFORMANCE_TESTS_SOURCE"/* $CONFORMANCE_TESTS_FOLDER
|
|
48
59
|
|
|
49
60
|
# Move to the subfolder
|
|
50
61
|
printf "Moving to $CONFORMANCE_TESTS_FOLDER...\n"
|