@entelligentsia/forgecli 0.11.2 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +324 -0
- package/README.md +2 -1
- package/dist/CHANGELOG-forge-plugin.md +210 -0
- package/dist/bin/forge.js +20 -1
- package/dist/bin/forge.js.map +1 -1
- package/dist/extensions/forgecli/ask-user-tool.js +32 -20
- package/dist/extensions/forgecli/ask-user-tool.js.map +1 -1
- package/dist/extensions/forgecli/config-layer.d.ts +15 -0
- package/dist/extensions/forgecli/config-layer.js +4 -1
- package/dist/extensions/forgecli/config-layer.js.map +1 -1
- package/dist/extensions/forgecli/config-writer.js +4 -1
- package/dist/extensions/forgecli/config-writer.js.map +1 -1
- package/dist/extensions/forgecli/enhance.js +1 -1
- package/dist/extensions/forgecli/enhance.js.map +1 -1
- package/dist/extensions/forgecli/fix-bug.js +31 -1
- package/dist/extensions/forgecli/fix-bug.js.map +1 -1
- package/dist/extensions/forgecli/forge-cli-schema.json +19 -0
- package/dist/extensions/forgecli/forge-tools.js +80 -0
- package/dist/extensions/forgecli/forge-tools.js.map +1 -1
- package/dist/extensions/forgecli/forge-update-command.js +24 -18
- package/dist/extensions/forgecli/forge-update-command.js.map +1 -1
- package/dist/extensions/forgecli/friction-emit.d.ts +97 -0
- package/dist/extensions/forgecli/friction-emit.js +246 -0
- package/dist/extensions/forgecli/friction-emit.js.map +1 -0
- package/dist/extensions/forgecli/health-check.d.ts +10 -0
- package/dist/extensions/forgecli/health-check.js +160 -8
- package/dist/extensions/forgecli/health-check.js.map +1 -1
- package/dist/extensions/forgecli/hook-dispatcher.js +24 -2
- package/dist/extensions/forgecli/hook-dispatcher.js.map +1 -1
- package/dist/extensions/forgecli/hooks/write-guard.js +5 -1
- package/dist/extensions/forgecli/hooks/write-guard.js.map +1 -1
- package/dist/extensions/forgecli/index.js +29 -5
- package/dist/extensions/forgecli/index.js.map +1 -1
- package/dist/extensions/forgecli/lib/store-error-remediation.d.ts +65 -0
- package/dist/extensions/forgecli/lib/store-error-remediation.js +298 -0
- package/dist/extensions/forgecli/lib/store-error-remediation.js.map +1 -0
- package/dist/extensions/forgecli/regenerate.d.ts +22 -0
- package/dist/extensions/forgecli/regenerate.js +133 -3
- package/dist/extensions/forgecli/regenerate.js.map +1 -1
- package/dist/extensions/forgecli/run-sprint.js +16 -1
- package/dist/extensions/forgecli/run-sprint.js.map +1 -1
- package/dist/extensions/forgecli/run-task.js +30 -8
- package/dist/extensions/forgecli/run-task.js.map +1 -1
- package/dist/extensions/forgecli/skill-curation-flag.d.ts +21 -0
- package/dist/extensions/forgecli/skill-curation-flag.js +71 -0
- package/dist/extensions/forgecli/skill-curation-flag.js.map +1 -0
- package/dist/extensions/forgecli/skill-curator-subagent.d.ts +101 -0
- package/dist/extensions/forgecli/skill-curator-subagent.js +342 -0
- package/dist/extensions/forgecli/skill-curator-subagent.js.map +1 -0
- package/dist/extensions/forgecli/skill-retriever.d.ts +84 -0
- package/dist/extensions/forgecli/skill-retriever.js +246 -0
- package/dist/extensions/forgecli/skill-retriever.js.map +1 -0
- package/dist/extensions/forgecli/skill-usage-tracker.d.ts +91 -0
- package/dist/extensions/forgecli/skill-usage-tracker.js +224 -0
- package/dist/extensions/forgecli/skill-usage-tracker.js.map +1 -0
- package/dist/extensions/forgecli/store-resolver.d.ts +18 -0
- package/dist/extensions/forgecli/store-resolver.js +44 -4
- package/dist/extensions/forgecli/store-resolver.js.map +1 -1
- package/dist/extensions/forgecli/store-validator.d.ts +3 -0
- package/dist/extensions/forgecli/store-validator.js +4 -2
- package/dist/extensions/forgecli/store-validator.js.map +1 -1
- package/dist/forge-payload/.base-pack/personas/supervisor.md +9 -0
- package/dist/forge-payload/.base-pack/workflows/enhance.md +344 -18
- package/dist/forge-payload/.claude-plugin/plugin.json +1 -1
- package/dist/forge-payload/.schemas/event.schema.json +20 -2
- package/dist/forge-payload/.schemas/migrations.json +112 -0
- package/dist/forge-payload/.schemas/proposal.schema.json +40 -0
- package/dist/forge-payload/agents/store-query-validator.md +103 -0
- package/dist/forge-payload/agents/tomoshibi.md +185 -0
- package/dist/forge-payload/commands/regenerate.md +109 -20
- package/dist/forge-payload/hooks/check-update.js +378 -0
- package/dist/forge-payload/hooks/forge-permissions.js +158 -0
- package/dist/forge-payload/hooks/triage-error.js +71 -0
- package/dist/forge-payload/hooks/validate-write.js +236 -0
- package/dist/forge-payload/integrity.json +32 -0
- package/dist/forge-payload/meta/workflows/meta-enhance.md +344 -18
- package/dist/forge-payload/schemas/structure-manifest.json +511 -0
- package/dist/forge-payload/tools/build-persona-pack.cjs +120 -11
- package/dist/forge-payload/tools/compression-gate.cjs +192 -0
- package/dist/forge-payload/tools/delete-candidate-detector.cjs +114 -0
- package/dist/forge-payload/tools/judge-proposal.cjs +177 -0
- package/dist/forge-payload/tools/manage-versions.cjs +132 -4
- package/dist/forge-payload/tools/queue-drain.cjs +152 -0
- package/dist/forge-payload/tools/replay-scoring.cjs +117 -0
- package/node_modules/@mariozechner/clipboard/package.json +2 -1
- package/node_modules/@mariozechner/clipboard-linux-x64-musl/README.md +3 -0
- package/node_modules/@mariozechner/clipboard-linux-x64-musl/clipboard.linux-x64-musl.node +0 -0
- package/node_modules/@mariozechner/clipboard-linux-x64-musl/package.json +25 -0
- package/package.json +4 -2
|
@@ -160,7 +160,18 @@ If `CONFIG_MODE == "fast"`: apply the single-file materialized check
|
|
|
160
160
|
not materialized, emit the stub-or-missing message and exit 0. Otherwise
|
|
161
161
|
proceed.
|
|
162
162
|
|
|
163
|
-
Before writing,
|
|
163
|
+
Before writing, check the file for manual modifications (mirrors the workflows
|
|
164
|
+
and templates pre-write guard — FORGE-BUG-037 / forge#106):
|
|
165
|
+
```sh
|
|
166
|
+
node "$FORGE_ROOT/tools/generation-manifest.cjs" check .forge/personas/<sub-target>.md
|
|
167
|
+
```
|
|
168
|
+
For exit 1 (modified): warn `△ .forge/personas/<sub-target>.md has been manually
|
|
169
|
+
modified (likely by /forge:enhance). Overwriting will discard your changes.
|
|
170
|
+
Proceed? (yes / no / show diff)`. Collect the answer before proceeding. On
|
|
171
|
+
`no` or `show diff` rejecting overwrite, skip this file and exit cleanly.
|
|
172
|
+
On exit 2 (untracked) or exit 3 (missing): proceed without prompting.
|
|
173
|
+
|
|
174
|
+
Then remove any existing manifest entry for this specific file (handles rename case):
|
|
164
175
|
```sh
|
|
165
176
|
node "$FORGE_ROOT/tools/generation-manifest.cjs" remove .forge/personas/<sub-target>.md 2>/dev/null || true
|
|
166
177
|
```
|
|
@@ -198,17 +209,46 @@ steps below apply to that single file.
|
|
|
198
209
|
node "$FORGE_ROOT/tools/banners.cjs" --badge bloom
|
|
199
210
|
```
|
|
200
211
|
Then emit: `Generating personas (<N> files in parallel)...` — use `N_materialized` in fast mode, `M_total` in full mode.
|
|
201
|
-
4.
|
|
212
|
+
4. Check each (enumerated, fast-mode-filtered) file for manual modifications
|
|
213
|
+
before any clearing or regeneration (mirrors the workflows + templates
|
|
214
|
+
pre-write guard — FORGE-BUG-037 / forge#106):
|
|
215
|
+
```sh
|
|
216
|
+
node "$FORGE_ROOT/tools/generation-manifest.cjs" check .forge/personas/<role>.md
|
|
217
|
+
```
|
|
218
|
+
For any exit 1 (modified): warn `△ .forge/personas/<role>.md has been manually
|
|
219
|
+
modified (likely by /forge:enhance). Overwriting will discard your changes.
|
|
220
|
+
Proceed? (yes / no / show diff)`. Collect answers before proceeding. Files
|
|
221
|
+
the user declines are removed from the regeneration set for this run. Exit
|
|
222
|
+
2 (untracked) and exit 3 (missing) require no prompt.
|
|
223
|
+
5. **Full mode only**: clear stale entries (skip in fast mode — see step 2):
|
|
202
224
|
```sh
|
|
203
225
|
node "$FORGE_ROOT/tools/generation-manifest.cjs" clear-namespace .forge/personas/
|
|
204
226
|
```
|
|
205
|
-
|
|
227
|
+
6. **Spawn the persona subagents in a SINGLE Agent tool message** using
|
|
206
228
|
`$FORGE_ROOT/init/generation/generate-persona.md` as the per-subagent rulebook
|
|
207
229
|
(same fan-out pattern as `/forge:init` Phase 4). Spawn one per filtered
|
|
208
230
|
entry — every entry in fast mode, every meta source in full mode.
|
|
209
|
-
|
|
231
|
+
7. Collect results. For each `done:` result → emit ` 〇 <filename>.md`.
|
|
210
232
|
Retry failures once. Any still failing: surface the id list.
|
|
211
|
-
|
|
233
|
+
8. **Replay user enhancements** (forge#107 / Approach A — layer 3 of the composition
|
|
234
|
+
contract declared at `manage-versions.cjs:13`). After fresh base-pack content
|
|
235
|
+
is written, restore any user-enhanced files captured by `/forge:enhance` Phase 2
|
|
236
|
+
snapshots:
|
|
237
|
+
```sh
|
|
238
|
+
node "$FORGE_ROOT/tools/manage-versions.cjs" replay --target personas
|
|
239
|
+
```
|
|
240
|
+
The tool walks all snapshots in `.forge/structure-versions.json`, finds enhanced
|
|
241
|
+
elements whose normalized path starts with `personas/`, and copies them from
|
|
242
|
+
the archive back over the freshly-generated content. Later snapshots win on
|
|
243
|
+
file collision. Files not captured by any snapshot remain at the fresh
|
|
244
|
+
base-pack version.
|
|
245
|
+
9. Re-record manifest hashes for the (now restored) files so subsequent
|
|
246
|
+
`generation-manifest check` calls reflect current on-disk content:
|
|
247
|
+
```sh
|
|
248
|
+
for each <role> in the filtered set:
|
|
249
|
+
node "$FORGE_ROOT/tools/generation-manifest.cjs" record .forge/personas/<role>.md
|
|
250
|
+
```
|
|
251
|
+
10. Emit ` 〇 personas — <N> files written` (fast mode appends ` (M-N deferred)` when `N < M`).
|
|
212
252
|
|
|
213
253
|
---
|
|
214
254
|
|
|
@@ -226,7 +266,19 @@ If `CONFIG_MODE == "fast"`: apply the single-file materialized check
|
|
|
226
266
|
If not materialized, emit the stub-or-missing message and exit 0. Otherwise
|
|
227
267
|
proceed.
|
|
228
268
|
|
|
229
|
-
Before writing,
|
|
269
|
+
Before writing, check the file for manual modifications (mirrors the workflows
|
|
270
|
+
and templates pre-write guard — FORGE-BUG-037 / forge#106):
|
|
271
|
+
```sh
|
|
272
|
+
node "$FORGE_ROOT/tools/generation-manifest.cjs" check .forge/skills/<sub-target>-skills.md
|
|
273
|
+
```
|
|
274
|
+
For exit 1 (modified): warn `△ .forge/skills/<sub-target>-skills.md has been
|
|
275
|
+
manually modified (likely by /forge:enhance). Overwriting will discard your
|
|
276
|
+
changes. Proceed? (yes / no / show diff)`. Collect the answer before
|
|
277
|
+
proceeding. On `no` or `show diff` rejecting overwrite, skip this file and
|
|
278
|
+
exit cleanly. On exit 2 (untracked) or exit 3 (missing): proceed without
|
|
279
|
+
prompting.
|
|
280
|
+
|
|
281
|
+
Then remove any existing manifest entry for this specific file:
|
|
230
282
|
```sh
|
|
231
283
|
node "$FORGE_ROOT/tools/generation-manifest.cjs" remove .forge/skills/<sub-target>-skills.md 2>/dev/null || true
|
|
232
284
|
```
|
|
@@ -251,15 +303,38 @@ apply to that single file.
|
|
|
251
303
|
node "$FORGE_ROOT/tools/banners.cjs" --badge tide
|
|
252
304
|
```
|
|
253
305
|
Then emit: `Generating skills (<N> files in parallel)...`
|
|
254
|
-
4.
|
|
306
|
+
4. Check each (enumerated, fast-mode-filtered) file for manual modifications
|
|
307
|
+
before any clearing or regeneration (mirrors the workflows + templates
|
|
308
|
+
pre-write guard — FORGE-BUG-037 / forge#106):
|
|
309
|
+
```sh
|
|
310
|
+
node "$FORGE_ROOT/tools/generation-manifest.cjs" check .forge/skills/<role>-skills.md
|
|
311
|
+
```
|
|
312
|
+
For any exit 1 (modified): warn `△ .forge/skills/<role>-skills.md has been
|
|
313
|
+
manually modified (likely by /forge:enhance). Overwriting will discard your
|
|
314
|
+
changes. Proceed? (yes / no / show diff)`. Collect answers before proceeding.
|
|
315
|
+
Files the user declines are removed from the regeneration set for this run.
|
|
316
|
+
Exit 2 (untracked) and exit 3 (missing) require no prompt.
|
|
317
|
+
5. **Full mode only**: clear stale entries (skip in fast mode):
|
|
255
318
|
```sh
|
|
256
319
|
node "$FORGE_ROOT/tools/generation-manifest.cjs" clear-namespace .forge/skills/
|
|
257
320
|
```
|
|
258
|
-
|
|
321
|
+
6. **Spawn the skill subagents in a SINGLE Agent tool message** using
|
|
259
322
|
`$FORGE_ROOT/init/generation/generate-skill.md` as the per-subagent rulebook.
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
323
|
+
7. Collect results. Retry failures once. Any still failing: surface the id list.
|
|
324
|
+
8. **Replay user enhancements** (forge#107 / Approach A):
|
|
325
|
+
```sh
|
|
326
|
+
node "$FORGE_ROOT/tools/manage-versions.cjs" replay --target skills
|
|
327
|
+
```
|
|
328
|
+
Walks snapshots; restores any enhanced `skills/<role>-skills.md` files from
|
|
329
|
+
the archive over the freshly-generated content. Later snapshots win on
|
|
330
|
+
collision.
|
|
331
|
+
9. Re-record manifest hashes for the (now restored) files:
|
|
332
|
+
```sh
|
|
333
|
+
for each <role> in the filtered set:
|
|
334
|
+
node "$FORGE_ROOT/tools/generation-manifest.cjs" record .forge/skills/<role>-skills.md
|
|
335
|
+
```
|
|
336
|
+
10. For each completed file, check manifest (warn on modified), emit ` 〇 <filename>.md`.
|
|
337
|
+
Fast mode appends `〇 skills — <N> files written (M-N deferred)` when `N < M`.
|
|
263
338
|
|
|
264
339
|
---
|
|
265
340
|
|
|
@@ -341,7 +416,7 @@ write, record hash.
|
|
|
341
416
|
node "$FORGE_ROOT/tools/generation-manifest.cjs" check .forge/workflows/{filename}.md
|
|
342
417
|
```
|
|
343
418
|
For any exit 1 (modified): warn `△ .forge/workflows/{filename}.md has been manually
|
|
344
|
-
modified. Overwriting
|
|
419
|
+
modified. Overwriting may discard manual edits not captured in any /forge:enhance snapshot. Edits captured by snapshots will be restored automatically via `manage-versions replay` after regeneration. Proceed? (yes / no / show diff)`
|
|
345
420
|
Collect answers before proceeding.
|
|
346
421
|
5. **Full mode only**: clear stale entries (skip in fast mode — clearing
|
|
347
422
|
would drop manifest entries for stubs we are intentionally leaving alone):
|
|
@@ -367,8 +442,15 @@ write, record hash.
|
|
|
367
442
|
Input: $FORGE_ROOT/meta/workflows/meta-orchestrate.md + .forge/workflows/
|
|
368
443
|
Output: .forge/workflows/orchestrate_task.md and .forge/workflows/run_sprint.md
|
|
369
444
|
```
|
|
370
|
-
9.
|
|
371
|
-
|
|
445
|
+
9. **Replay user enhancements** (forge#107 / Approach A):
|
|
446
|
+
```sh
|
|
447
|
+
node "$FORGE_ROOT/tools/manage-versions.cjs" replay --target workflows
|
|
448
|
+
```
|
|
449
|
+
Walks snapshots; restores enhanced `workflows/<name>.md` files. Later
|
|
450
|
+
snapshots win on collision.
|
|
451
|
+
10. For each written file: record hash `node "$FORGE_ROOT/tools/generation-manifest.cjs" record .forge/workflows/{filename}.md`
|
|
452
|
+
(this runs AFTER replay so the recorded hash reflects the restored content).
|
|
453
|
+
11. Emit ` 〇 workflows — <N> files written` (full mode: 18; fast mode:
|
|
372
454
|
`〇 workflows — N of M files regenerated (others remain as stubs)`).
|
|
373
455
|
|
|
374
456
|
**Do NOT touch:** `.claude/commands/`, `.forge/config.json`, or any knowledge base file.
|
|
@@ -464,14 +546,21 @@ Generate the single file (no fan-out needed). Record hash after writing.
|
|
|
464
546
|
6. **Spawn the template subagents in a SINGLE Agent tool message** using
|
|
465
547
|
`$FORGE_ROOT/init/generation/generate-template.md` as the per-subagent rulebook.
|
|
466
548
|
7. Collect results. Retry failures once. Any still failing: surface the id list.
|
|
467
|
-
8.
|
|
468
|
-
9. Re-record the one-shot init artifact not regenerated from a meta file:
|
|
549
|
+
8. **Replay user enhancements** (forge#107 / Approach A):
|
|
469
550
|
```sh
|
|
470
|
-
|
|
471
|
-
node "$FORGE_ROOT/tools/generation-manifest.cjs" record .forge/templates/CUSTOM_COMMAND_TEMPLATE.md
|
|
472
|
-
fi
|
|
551
|
+
node "$FORGE_ROOT/tools/manage-versions.cjs" replay --target templates
|
|
473
552
|
```
|
|
474
|
-
|
|
553
|
+
Walks snapshots; restores enhanced `templates/<STEM>.md` files. Later
|
|
554
|
+
snapshots win on collision.
|
|
555
|
+
9. For each written file: record hash, emit ` 〇 <filename>.md` (hash reflects
|
|
556
|
+
post-replay content).
|
|
557
|
+
10. Re-record the one-shot init artifact not regenerated from a meta file:
|
|
558
|
+
```sh
|
|
559
|
+
if [ -f ".forge/templates/CUSTOM_COMMAND_TEMPLATE.md" ]; then
|
|
560
|
+
node "$FORGE_ROOT/tools/generation-manifest.cjs" record .forge/templates/CUSTOM_COMMAND_TEMPLATE.md
|
|
561
|
+
fi
|
|
562
|
+
```
|
|
563
|
+
Fast-mode footer: emit `〇 templates — <N> files written (M-N deferred)` when `N < M`.
|
|
475
564
|
|
|
476
565
|
---
|
|
477
566
|
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Forge session-start hook — runs on SessionStart
|
|
3
|
+
// 1. Injects Forge-awareness context if this project has a .forge/ directory.
|
|
4
|
+
// 2. Checks once per day whether a newer version is available.
|
|
5
|
+
// 3. Detects distribution switches (forge@forge ↔ forge@skillforge) and
|
|
6
|
+
// refreshes paths.forgeRoot in .forge/config.json so subagents always
|
|
7
|
+
// reference the correct installed plugin path.
|
|
8
|
+
//
|
|
9
|
+
// Uses only Node.js built-ins — no npm dependencies required.
|
|
10
|
+
// Works on Linux, macOS, and Windows wherever Claude Code runs.
|
|
11
|
+
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
// This hook must never exit non-zero — a hook failure surfaces as noise to the
|
|
15
|
+
// user and blocks session start context. Any uncaught exception exits 0.
|
|
16
|
+
process.on('uncaughtException', () => process.exit(0));
|
|
17
|
+
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const os = require('os');
|
|
21
|
+
const https = require('https');
|
|
22
|
+
|
|
23
|
+
const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT || '.';
|
|
24
|
+
const dataDir = process.env.CLAUDE_PLUGIN_DATA || path.join(os.tmpdir(), 'forge-plugin-data');
|
|
25
|
+
// Plugin-level cache: throttle only (lastCheck, remoteVersion) — shared across all projects.
|
|
26
|
+
const pluginCacheFile = path.join(dataDir, 'update-check-cache.json');
|
|
27
|
+
// Project-level cache: migration state (migratedFrom, localVersion, distribution, forgeRoot) — per project.
|
|
28
|
+
const forgeDir = '.forge';
|
|
29
|
+
const hasForge = fs.existsSync(forgeDir) && fs.existsSync(path.join(forgeDir, 'config.json'));
|
|
30
|
+
const projectCacheFile = path.join(forgeDir, 'update-check-cache.json');
|
|
31
|
+
|
|
32
|
+
// Distribution detection — derived from plugin path at runtime.
|
|
33
|
+
// The cache path encodes the marketplace name, making this more reliable than
|
|
34
|
+
// reading fields from plugin.json (which may be stale after a switch).
|
|
35
|
+
function detectDistribution(root) {
|
|
36
|
+
return root.includes('/cache/skillforge/forge/') || root.includes('/marketplaces/skillforge/forge/')
|
|
37
|
+
? 'forge@skillforge' : 'forge@forge';
|
|
38
|
+
}
|
|
39
|
+
const currentDistribution = detectDistribution(pluginRoot);
|
|
40
|
+
|
|
41
|
+
// --- Multi-plugin scanning ---
|
|
42
|
+
// Scans all known plugin locations to detect multiple Forge installations.
|
|
43
|
+
// Returns array of installation records with version, distribution, scope, enabled status.
|
|
44
|
+
// Optional parameters for dependency injection (testing).
|
|
45
|
+
function scanPluginInstallations(options) {
|
|
46
|
+
const installations = [];
|
|
47
|
+
const homeDir = (options && options.homeDir) || os.homedir();
|
|
48
|
+
const cwd = (options && options.cwd) || process.cwd();
|
|
49
|
+
|
|
50
|
+
// Candidate paths — user scope (global) and project scope (local)
|
|
51
|
+
// Also scan skillforge subdirectory variant (skillforge/forge/forge)
|
|
52
|
+
const basePaths = [
|
|
53
|
+
path.join(homeDir, '.claude', 'plugins'),
|
|
54
|
+
path.join(cwd, '.claude', 'plugins'),
|
|
55
|
+
];
|
|
56
|
+
const variants = ['cache', 'marketplaces'];
|
|
57
|
+
const pluginNames = ['forge/forge', 'skillforge/forge/forge'];
|
|
58
|
+
|
|
59
|
+
const candidates = [];
|
|
60
|
+
for (const basePath of basePaths) {
|
|
61
|
+
for (const variant of variants) {
|
|
62
|
+
for (const pluginName of pluginNames) {
|
|
63
|
+
candidates.push(path.join(basePath, variant, pluginName));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
for (const candidate of candidates) {
|
|
69
|
+
try {
|
|
70
|
+
const pluginJsonPath = path.join(candidate, '.claude-plugin', 'plugin.json');
|
|
71
|
+
if (!fs.existsSync(pluginJsonPath)) continue;
|
|
72
|
+
|
|
73
|
+
const manifest = JSON.parse(fs.readFileSync(pluginJsonPath, 'utf8'));
|
|
74
|
+
// Determine scope: user-scope paths start with homeDir/.claude, project-scope start with cwd/.claude
|
|
75
|
+
// Use cwd-relative check first to avoid false positives when cwd is subdir of homeDir
|
|
76
|
+
const isProjectScope = candidate.startsWith(path.join(cwd, '.claude'));
|
|
77
|
+
const isUserScope = candidate.startsWith(path.join(homeDir, '.claude'));
|
|
78
|
+
const scope = isProjectScope ? 'project' : (isUserScope ? 'user' : 'unknown');
|
|
79
|
+
const enabled = isPluginEnabled(candidate, scope, homeDir, cwd);
|
|
80
|
+
|
|
81
|
+
// Avoid duplicates — skip if same path already recorded
|
|
82
|
+
if (installations.some(i => i.path === candidate)) continue;
|
|
83
|
+
|
|
84
|
+
installations.push({
|
|
85
|
+
path: candidate,
|
|
86
|
+
version: manifest.version || 'unknown',
|
|
87
|
+
distribution: detectDistribution(candidate),
|
|
88
|
+
scope: scope,
|
|
89
|
+
enabled: enabled,
|
|
90
|
+
});
|
|
91
|
+
} catch (e) {
|
|
92
|
+
// Non-fatal — skip broken installations silently
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return installations;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Check if forge plugin is enabled in settings files.
|
|
100
|
+
// Returns true if no explicit disable found, false if disabled.
|
|
101
|
+
function isPluginEnabled(pluginPath, scope, homeDir, cwd) {
|
|
102
|
+
try {
|
|
103
|
+
// Check user settings: ~/.claude/settings.json
|
|
104
|
+
const userSettingsPath = path.join(homeDir, '.claude', 'settings.json');
|
|
105
|
+
if (fs.existsSync(userSettingsPath)) {
|
|
106
|
+
const userSettings = JSON.parse(fs.readFileSync(userSettingsPath, 'utf8'));
|
|
107
|
+
if (userSettings.disablePlugin === true) return false;
|
|
108
|
+
// Check for per-plugin disable (if supported in future)
|
|
109
|
+
if (userSettings.plugins && userSettings.plugins.forge === false) return false;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Check project settings: ./.claude/settings.local.json
|
|
113
|
+
const projectSettingsPath = path.join(cwd, '.claude', 'settings.local.json');
|
|
114
|
+
if (fs.existsSync(projectSettingsPath)) {
|
|
115
|
+
const projectSettings = JSON.parse(fs.readFileSync(projectSettingsPath, 'utf8'));
|
|
116
|
+
if (projectSettings.disablePlugin === true) return false;
|
|
117
|
+
if (projectSettings.plugins && projectSettings.plugins.forge === false) return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return true; // Default: enabled
|
|
121
|
+
} catch (e) {
|
|
122
|
+
return true; // Non-fatal — assume enabled if cannot read settings
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Determine the correct update-check URL for this distribution.
|
|
127
|
+
// Each distribution's plugin.json carries its own updateUrl pointing at the
|
|
128
|
+
// branch it was installed from (main for forge@forge, release for forge@skillforge),
|
|
129
|
+
// so we read it directly — no hardcoded per-distribution URLs needed.
|
|
130
|
+
const FALLBACK_UPDATE_URL = 'https://raw.githubusercontent.com/Entelligentsia/forge/main/forge/.claude-plugin/plugin.json';
|
|
131
|
+
|
|
132
|
+
const ALLOWED_DOMAINS = ['raw.githubusercontent.com'];
|
|
133
|
+
|
|
134
|
+
function validateUpdateUrl(url) {
|
|
135
|
+
try {
|
|
136
|
+
const parsed = new URL(url);
|
|
137
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
138
|
+
if (!ALLOWED_DOMAINS.some(d => hostname === d || hostname.endsWith('.' + d))) {
|
|
139
|
+
process.stderr.write(`forge-update: rejected update URL with disallowed domain '${hostname}', falling back\n`);
|
|
140
|
+
return FALLBACK_UPDATE_URL;
|
|
141
|
+
}
|
|
142
|
+
return url;
|
|
143
|
+
} catch {
|
|
144
|
+
return FALLBACK_UPDATE_URL;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function getUpdateUrl() {
|
|
149
|
+
try {
|
|
150
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(pluginRoot, '.claude-plugin', 'plugin.json'), 'utf8'));
|
|
151
|
+
return validateUpdateUrl(manifest.updateUrl || FALLBACK_UPDATE_URL);
|
|
152
|
+
} catch { return FALLBACK_UPDATE_URL; }
|
|
153
|
+
}
|
|
154
|
+
const remoteUrl = getUpdateUrl();
|
|
155
|
+
const checkInterval = 86400; // 24 hours in seconds
|
|
156
|
+
|
|
157
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
158
|
+
|
|
159
|
+
// --- Forge-awareness context injection ---
|
|
160
|
+
let forgeContext = '';
|
|
161
|
+
if (fs.existsSync('.forge') && fs.existsSync(path.join('.forge', 'config.json'))) {
|
|
162
|
+
forgeContext =
|
|
163
|
+
'This project uses Forge AI-SDLC. Engineering knowledge base: engineering/. ' +
|
|
164
|
+
'Generated workflows: .forge/workflows/. Sprint and task store: .forge/store/. ' +
|
|
165
|
+
'Use the project slash commands (/plan, /implement, /sprint-plan) to drive development. ' +
|
|
166
|
+
'Run /forge:health to check knowledge base currency.';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// --- Update check helpers ---
|
|
170
|
+
function localVersion() {
|
|
171
|
+
try {
|
|
172
|
+
const p = path.join(pluginRoot, '.claude-plugin', 'plugin.json');
|
|
173
|
+
return JSON.parse(fs.readFileSync(p, 'utf8')).version || '0.0.0';
|
|
174
|
+
} catch {
|
|
175
|
+
return '0.0.0';
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function fetchRemoteVersion(cb) {
|
|
180
|
+
https.get(remoteUrl, { timeout: 5000 }, (res) => {
|
|
181
|
+
let body = '';
|
|
182
|
+
res.on('data', chunk => { body += chunk; });
|
|
183
|
+
res.on('end', () => {
|
|
184
|
+
try { cb(JSON.parse(body).version || ''); } catch { cb(''); }
|
|
185
|
+
});
|
|
186
|
+
}).on('error', () => cb(''))
|
|
187
|
+
.on('timeout', function() { this.destroy(); cb(''); });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function buildUpdateMsg(remoteVersion, local) {
|
|
191
|
+
return remoteVersion && remoteVersion !== local
|
|
192
|
+
? `Forge ${remoteVersion} available (you have ${local}). Run /forge:update to review changes and update.`
|
|
193
|
+
: '';
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function emit(forgeCtx, updateMsg) {
|
|
197
|
+
if (!forgeCtx && !updateMsg) return;
|
|
198
|
+
const combined = [forgeCtx, updateMsg].filter(Boolean).join(' ');
|
|
199
|
+
const escaped = combined.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, ' ');
|
|
200
|
+
process.stdout.write(`{"additionalContext":"${escaped}"}\n`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// --- Main logic (only runs when executed as script, not when required as module) ---
|
|
204
|
+
if (require.main === module) {
|
|
205
|
+
const local = localVersion();
|
|
206
|
+
const now = Math.floor(Date.now() / 1000);
|
|
207
|
+
|
|
208
|
+
// Scan for all plugin installations — builds inventory for multi-plugin awareness.
|
|
209
|
+
const allInstallations = scanPluginInstallations();
|
|
210
|
+
|
|
211
|
+
// Plugin-level cache: throttle (lastCheck, remoteVersion) — shared, not migration state.
|
|
212
|
+
let pluginCache = null;
|
|
213
|
+
if (fs.existsSync(pluginCacheFile)) {
|
|
214
|
+
try { pluginCache = JSON.parse(fs.readFileSync(pluginCacheFile, 'utf8')); } catch { pluginCache = null; }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Project-level cache: migration state — per project.
|
|
218
|
+
let projectCache = null;
|
|
219
|
+
if (hasForge && fs.existsSync(projectCacheFile)) {
|
|
220
|
+
try { projectCache = JSON.parse(fs.readFileSync(projectCacheFile, 'utf8')); } catch { projectCache = null; }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// FR-010: Backfill forgeRef from localVersion if missing in existing cache.
|
|
224
|
+
if (projectCache && !projectCache.forgeRef && projectCache.localVersion) {
|
|
225
|
+
projectCache.forgeRef = projectCache.localVersion;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// --- Distribution + forgeRoot/forgeRef sync (always runs before update-check logic) ---
|
|
229
|
+
// Refreshes paths.forgeRoot and paths.forgeRef in config.json and the distribution/
|
|
230
|
+
// forgeRoot/forgeRef fields in the project cache. Handles distribution switches
|
|
231
|
+
// transparently — the user gets a clear message and all path references are
|
|
232
|
+
// corrected before any command runs.
|
|
233
|
+
let distributionSwitchMsg = '';
|
|
234
|
+
if (hasForge && pluginRoot !== '.') {
|
|
235
|
+
// Keep paths.forgeRoot and paths.forgeRef in .forge/config.json in sync.
|
|
236
|
+
// Generated workflows read forgeRoot to invoke tools without needing CLAUDE_PLUGIN_ROOT.
|
|
237
|
+
// forgeRef is the version-based portable field (FR-010).
|
|
238
|
+
try {
|
|
239
|
+
const configPath = path.join(forgeDir, 'config.json');
|
|
240
|
+
const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
241
|
+
if (!cfg.paths) cfg.paths = {};
|
|
242
|
+
let configChanged = false;
|
|
243
|
+
if (cfg.paths.forgeRoot !== pluginRoot) {
|
|
244
|
+
cfg.paths.forgeRoot = pluginRoot;
|
|
245
|
+
configChanged = true;
|
|
246
|
+
}
|
|
247
|
+
// FR-010: Write forgeRef from the local plugin version.
|
|
248
|
+
if (!cfg.paths.forgeRef || cfg.paths.forgeRef !== local) {
|
|
249
|
+
cfg.paths.forgeRef = local;
|
|
250
|
+
configChanged = true;
|
|
251
|
+
}
|
|
252
|
+
if (configChanged) {
|
|
253
|
+
fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2) + '\n');
|
|
254
|
+
}
|
|
255
|
+
} catch { /* non-fatal */ }
|
|
256
|
+
|
|
257
|
+
if (projectCache) {
|
|
258
|
+
const storedRoot = projectCache.forgeRoot;
|
|
259
|
+
const storedDist = projectCache.distribution;
|
|
260
|
+
const switched = storedRoot && storedRoot !== pluginRoot;
|
|
261
|
+
|
|
262
|
+
// Build distribution switch message when the active plugin path changed and
|
|
263
|
+
// the distribution name is different (e.g. forge@skillforge → forge@forge).
|
|
264
|
+
if (switched && storedDist && storedDist !== currentDistribution) {
|
|
265
|
+
const versionNote = projectCache.localVersion && projectCache.localVersion !== local
|
|
266
|
+
? ` Version: ${projectCache.localVersion} → ${local}.`
|
|
267
|
+
: '';
|
|
268
|
+
distributionSwitchMsg =
|
|
269
|
+
`Plugin distribution switched from ${storedDist} to ${currentDistribution}.${versionNote}` +
|
|
270
|
+
` paths.forgeRoot updated. Run /forge:update to verify migration state.`;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Sync distribution + forgeRoot + forgeRef into the project cache whenever they drift.
|
|
274
|
+
// FR-002: Preserve updateStatus, pendingReason, pendingMigrations if present.
|
|
275
|
+
if (storedRoot !== pluginRoot || storedDist !== currentDistribution) {
|
|
276
|
+
try {
|
|
277
|
+
const updated = { ...projectCache, distribution: currentDistribution, forgeRoot: pluginRoot, forgeRef: local };
|
|
278
|
+
fs.writeFileSync(projectCacheFile, JSON.stringify(updated, null, 2) + '\n');
|
|
279
|
+
projectCache = updated; // keep in-memory copy consistent
|
|
280
|
+
} catch { /* non-fatal */ }
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// FR-002: Surface pending-state message to the user if update is incomplete.
|
|
286
|
+
let pendingStateMsg = '';
|
|
287
|
+
if (hasForge && projectCache && projectCache.updateStatus === 'pending') {
|
|
288
|
+
const pendingMigrations = Array.isArray(projectCache.pendingMigrations)
|
|
289
|
+
? projectCache.pendingMigrations.join(', ')
|
|
290
|
+
: '(unknown)';
|
|
291
|
+
pendingStateMsg =
|
|
292
|
+
`Forge update is incomplete — pending migration(s): ${pendingMigrations}.` +
|
|
293
|
+
` Run /forge:update to continue or /forge:migrate to complete.`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const elapsed = pluginCache ? now - (pluginCache.lastCheck || 0) : Infinity;
|
|
297
|
+
|
|
298
|
+
// Build multi-plugin awareness message if multiple installations found.
|
|
299
|
+
let multiPluginMsg = '';
|
|
300
|
+
if (allInstallations.length >= 2) {
|
|
301
|
+
const activeInst = allInstallations.find(i => i.path === pluginRoot) || allInstallations[0];
|
|
302
|
+
const otherInsts = allInstallations.filter(i => i.path !== activeInst.path);
|
|
303
|
+
if (otherInsts.length > 0) {
|
|
304
|
+
const otherDesc = otherInsts.map(i => `${i.version} (${i.distribution}, ${i.scope})`).join(', ');
|
|
305
|
+
multiPluginMsg = `Also installed: ${otherDesc}. `;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (elapsed < checkInterval) {
|
|
310
|
+
// Plugin cache still fresh — use stored remote version.
|
|
311
|
+
// Detect post-install: if the project's recorded localVersion differs from
|
|
312
|
+
// the running plugin version, the plugin was updated since last migration.
|
|
313
|
+
let postInstallMsg = '';
|
|
314
|
+
if (hasForge && projectCache && projectCache.localVersion && projectCache.localVersion !== local) {
|
|
315
|
+
// Record the pre-install version as baseline, update localVersion.
|
|
316
|
+
// FR-010: Include forgeRef. FR-002: updateStatus/pendingReason/pendingMigrations
|
|
317
|
+
// are preserved via spread.
|
|
318
|
+
const updated = {
|
|
319
|
+
...projectCache,
|
|
320
|
+
migratedFrom: projectCache.localVersion,
|
|
321
|
+
localVersion: local,
|
|
322
|
+
distribution: currentDistribution,
|
|
323
|
+
forgeRoot: pluginRoot,
|
|
324
|
+
forgeRef: local,
|
|
325
|
+
};
|
|
326
|
+
try { fs.writeFileSync(projectCacheFile, JSON.stringify(updated, null, 2) + '\n'); } catch { /* non-fatal */ }
|
|
327
|
+
// Reset plugin cache lastCheck so we fetch a fresh remote version next session.
|
|
328
|
+
try { fs.writeFileSync(pluginCacheFile, JSON.stringify({ ...pluginCache, lastCheck: 0 }, null, 2) + '\n'); } catch { /* non-fatal */ }
|
|
329
|
+
// Suppress post-install message when a distribution switch message already covers the event.
|
|
330
|
+
if (!distributionSwitchMsg) {
|
|
331
|
+
postInstallMsg = `Forge was updated to ${local} (was ${projectCache.localVersion}). Run /forge:update to review changes and update.`;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
const baseMsg = distributionSwitchMsg || postInstallMsg || buildUpdateMsg((pluginCache && pluginCache.remoteVersion) || '', local);
|
|
335
|
+
// FR-002: Append pending-state message if present.
|
|
336
|
+
const updateMsg = baseMsg ? multiPluginMsg + baseMsg : baseMsg;
|
|
337
|
+
const pendingMsg = pendingStateMsg ? ' ' + pendingStateMsg : '';
|
|
338
|
+
emit(forgeContext, (updateMsg + pendingMsg).trim());
|
|
339
|
+
} else {
|
|
340
|
+
// Plugin cache expired or missing — fetch fresh remote version.
|
|
341
|
+
fetchRemoteVersion((remoteVersion) => {
|
|
342
|
+
if (remoteVersion) {
|
|
343
|
+
// Update plugin-level throttle cache.
|
|
344
|
+
try { fs.writeFileSync(pluginCacheFile, JSON.stringify({ lastCheck: now, remoteVersion }, null, 2) + '\n'); } catch { /* non-fatal */ }
|
|
345
|
+
// Seed project-level cache on first run if not yet present.
|
|
346
|
+
// FR-010: Include forgeRef alongside forgeRoot.
|
|
347
|
+
if (hasForge && !projectCache) {
|
|
348
|
+
try {
|
|
349
|
+
fs.writeFileSync(projectCacheFile, JSON.stringify({
|
|
350
|
+
migratedFrom: local, localVersion: local,
|
|
351
|
+
distribution: currentDistribution, forgeRoot: pluginRoot,
|
|
352
|
+
forgeRef: local,
|
|
353
|
+
updateStatus: 'complete', pendingReason: null, pendingMigrations: [],
|
|
354
|
+
}, null, 2) + '\n');
|
|
355
|
+
} catch { /* non-fatal */ }
|
|
356
|
+
} else if (hasForge && projectCache && !projectCache.localVersion) {
|
|
357
|
+
// Backfill localVersion (and distribution/forgeRoot/forgeRef) if missing.
|
|
358
|
+
// FR-002: Preserve updateStatus/pendingReason/pendingMigrations via spread.
|
|
359
|
+
try {
|
|
360
|
+
fs.writeFileSync(projectCacheFile, JSON.stringify({
|
|
361
|
+
...projectCache, localVersion: local,
|
|
362
|
+
distribution: currentDistribution, forgeRoot: pluginRoot,
|
|
363
|
+
forgeRef: local,
|
|
364
|
+
}, null, 2) + '\n');
|
|
365
|
+
} catch { /* non-fatal */ }
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
const baseMsg = distributionSwitchMsg || buildUpdateMsg(remoteVersion, local);
|
|
369
|
+
// FR-002: Append pending-state message if present.
|
|
370
|
+
const updateMsg = baseMsg ? multiPluginMsg + baseMsg : baseMsg;
|
|
371
|
+
const pendingMsg = pendingStateMsg ? ' ' + pendingStateMsg : '';
|
|
372
|
+
emit(forgeContext, (updateMsg + pendingMsg).trim());
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Export functions for testing
|
|
378
|
+
module.exports = { scanPluginInstallations, isPluginEnabled, detectDistribution, validateUpdateUrl };
|