@izkac/forgekit 0.3.12 → 0.3.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/bin/forge.mjs +119 -107
  2. package/bin/forgekit.mjs +83 -83
  3. package/bin/review.mjs +81 -81
  4. package/package.json +2 -1
  5. package/scripts/prepack.mjs +78 -78
  6. package/scripts/run-tests.mjs +50 -50
  7. package/src/adr.mjs +236 -236
  8. package/src/adr.test.mjs +170 -170
  9. package/src/change.mjs +327 -234
  10. package/src/change.test.mjs +145 -83
  11. package/src/cleanup-sessions.mjs +84 -84
  12. package/src/config.mjs +103 -103
  13. package/src/defer.mjs +75 -75
  14. package/src/doctor.mjs +350 -341
  15. package/src/doctor.test.mjs +114 -114
  16. package/src/fleet.test.mjs +50 -0
  17. package/src/init.mjs +680 -621
  18. package/src/install.mjs +815 -815
  19. package/src/install.test.mjs +180 -180
  20. package/src/integrity-check.mjs +60 -60
  21. package/src/integrity.mjs +688 -682
  22. package/src/integrity.test.mjs +612 -566
  23. package/src/lib/fleet.mjs +61 -4
  24. package/src/lib.mjs +160 -143
  25. package/src/lib.test.mjs +128 -0
  26. package/src/models.defaults.json +41 -41
  27. package/src/new-session.mjs +99 -99
  28. package/src/openspec-overlays/README.md +19 -19
  29. package/src/openspec-overlays/openspec-apply-change-footer.md +14 -14
  30. package/src/openspec-overlays/opsx-apply-completion-step.md +1 -1
  31. package/src/openspec-overlays/opsx-apply-implement-step.md +11 -11
  32. package/src/paths.mjs +92 -92
  33. package/src/plan-engine.mjs +321 -278
  34. package/src/plan-engine.test.mjs +447 -283
  35. package/src/preferences.defaults.json +78 -78
  36. package/src/preferences.mjs +438 -438
  37. package/src/preferences.test.mjs +174 -174
  38. package/src/record-evidence.mjs +204 -204
  39. package/src/repo-root.mjs +33 -0
  40. package/src/resolve-model.mjs +312 -312
  41. package/src/resolve-model.test.mjs +194 -194
  42. package/src/review/cli.test.mjs +117 -117
  43. package/src/review/export.mjs +172 -172
  44. package/src/review/export.test.mjs +197 -197
  45. package/src/review/fixtures/valid-review.json +42 -42
  46. package/src/review/lib.mjs +894 -894
  47. package/src/review/lib.test.mjs +266 -266
  48. package/src/review/schema.json +196 -196
  49. package/src/review/signals.test.mjs +62 -62
  50. package/src/score-cli.mjs +68 -68
  51. package/src/score.mjs +568 -568
  52. package/src/score.test.mjs +366 -366
  53. package/src/session-reminder.mjs +207 -207
  54. package/src/session-status.mjs +70 -70
  55. package/src/set-models.mjs +186 -186
  56. package/src/set-phase.mjs +205 -205
  57. package/src/set-prefs.mjs +294 -294
  58. package/src/specs-sync.mjs +232 -0
  59. package/src/specs-sync.test.mjs +114 -0
  60. package/src/spine.mjs +93 -93
  61. package/src/triage-prompt.mjs +175 -175
  62. package/src/triage-prompt.test.mjs +50 -50
  63. package/src/vendor-openspec-overlays.mjs +176 -176
  64. package/src/vendor-openspec-overlays.test.mjs +62 -62
  65. package/vendor/skills/archive-to-adr/SKILL.md +149 -149
  66. package/vendor/skills/forge/SKILL.md +136 -136
  67. package/vendor/skills/forge/docs/forge.md +650 -647
  68. package/vendor/skills/forge/phases/brainstorm.md +23 -23
  69. package/vendor/skills/forge/phases/finish.md +90 -87
  70. package/vendor/skills/forge/phases/implement.md +77 -77
  71. package/vendor/skills/forge/phases/plan-openspec.md +60 -60
  72. package/vendor/skills/forge/phases/plan-specs.md +163 -117
  73. package/vendor/skills/forge/phases/review.md +25 -25
  74. package/vendor/skills/forge/phases/verify.md +124 -124
  75. package/vendor/skills/forge/references/forge-layout.md +85 -85
  76. package/vendor/skills/forge/references/pace.md +115 -115
  77. package/vendor/skills/forge/references/plan-routing.md +52 -51
  78. package/vendor/skills/forge/references/runtime-integrity.md +232 -232
  79. package/vendor/skills/forge/references/substantial-work.md +37 -37
  80. package/vendor/skills/forge/references/test-evidence.md +30 -30
  81. package/vendor/skills/forge/references/test-strategy.md +68 -68
  82. package/vendor/skills/forge/skills/subagent-driven-development/SKILL.md +87 -87
  83. package/vendor/skills/forge/subagents/final-reviewer-prompt.md +56 -56
  84. package/vendor/skills/forge/subagents/implementer-prompt.md +38 -38
  85. package/vendor/skills/git-resolve-adr-conflict/SKILL.md +132 -132
  86. package/vendor/skills/thorough-code-review/SKILL.md +290 -290
  87. package/vendor/skills/thorough-code-review/examples.md +133 -133
  88. package/vendor/skills/thorough-code-review/reference/accepted-risks.md +26 -26
  89. package/vendor/skills/thorough-code-review/reference/lenses.md +96 -96
  90. package/vendor/skills/thorough-code-review/reference/phase1-scout.md +62 -62
  91. package/vendor/skills/thorough-code-review/reference/phase2-skeptic.md +105 -105
  92. package/vendor/skills/thorough-code-review/reference/report-schema.json +222 -222
  93. package/vendor/skills/thorough-code-review/reference/report-template.md +115 -115
  94. package/vendor/skills/thorough-code-review/reference/severity-rubric.md +49 -49
  95. package/vendor/skills/thorough-code-review/reference/signals-preflight.md +55 -55
  96. package/vendor/templates/adr/README.md +7 -7
  97. package/vendor/templates/adr/decisions.md +141 -141
  98. package/vendor/templates/adr/hooks/check-pending-adrs.mjs +74 -74
  99. package/vendor/templates/adr/hooks/check-pending-adrs.sh +3 -3
  100. package/vendor/templates/adr/hooks/openspec-archive-agent-message.mjs +52 -52
  101. package/vendor/templates/adr/hooks/openspec-archive-agent-message.sh +3 -3
  102. package/vendor/templates/project/claude/commands/forge-apply.md +75 -75
  103. package/vendor/templates/project/claude/commands/forge-brainstorm.md +7 -7
  104. package/vendor/templates/project/claude/commands/forge-build.md +17 -17
  105. package/vendor/templates/project/claude/commands/forge-plan.md +12 -12
  106. package/vendor/templates/project/claude/commands/forge-skip.md +14 -14
  107. package/vendor/templates/project/claude/commands/forge-status.md +16 -16
  108. package/vendor/templates/project/claude/commands/forge.md +16 -16
  109. package/vendor/templates/project/claude/hooks/forge-prompt-hook.mjs +73 -73
  110. package/vendor/templates/project/claude/hooks/forge-session-start.mjs +19 -19
  111. package/vendor/templates/project/claude/hooks/forge-triage-hook.mjs +77 -77
  112. package/vendor/templates/project/cursor/commands/forge-apply.md +75 -75
  113. package/vendor/templates/project/cursor/commands/forge-brainstorm.md +10 -10
  114. package/vendor/templates/project/cursor/commands/forge-build.md +17 -17
  115. package/vendor/templates/project/cursor/commands/forge-plan.md +15 -15
  116. package/vendor/templates/project/cursor/commands/forge-skip.md +14 -14
  117. package/vendor/templates/project/cursor/commands/forge-status.md +16 -16
  118. package/vendor/templates/project/cursor/commands/forge.md +16 -16
  119. package/vendor/templates/project/cursor/hooks/forge-session-start.mjs +30 -30
  120. package/vendor/templates/project/cursor/hooks/forge-session-start.sh +3 -3
package/src/install.mjs CHANGED
@@ -1,815 +1,815 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Install forgekit skills into user-level agent skill directories.
4
- *
5
- * Canonical entry: `forgekit install`
6
- * Aliases: `forge install` → --skills forge
7
- * `review install` → --skills thorough-code-review
8
- *
9
- * Usage:
10
- * forgekit install
11
- * forgekit install --skills forge,thorough-code-review --agents cursor,claude
12
- * forgekit install --all-skills --all-agents --force
13
- * forgekit list
14
- */
15
-
16
- import fs from 'node:fs';
17
- import os from 'node:os';
18
- import path from 'node:path';
19
- import { pathToFileURL } from 'node:url';
20
- import { checkbox, input, select } from '@inquirer/prompts';
21
- import {
22
- ADR_SKILLS,
23
- DEFAULT_ADR_DIR,
24
- isGitRepo,
25
- normalizeAdrDir,
26
- saveUserConfig,
27
- scaffoldAdr,
28
- disableProjectAdr,
29
- } from './adr.mjs';
30
- import { saveUserPlanEngine } from './plan-engine.mjs';
31
- import { hashDirectory, packageVersion, resolveAsset } from './paths.mjs';
32
-
33
- export const FORGEKIT_STAMP = '.forgekit.json';
34
-
35
- /** @type {Record<string, { label: string, nextHint: string }>} */
36
- export const SKILLS = {
37
- forge: {
38
- label: 'Forge',
39
- nextHint:
40
- 'Forge: in each project, run `forge init` to add /forge commands, rules, and hooks.',
41
- },
42
- 'thorough-code-review': {
43
- label: 'Thorough Code Review',
44
- nextHint:
45
- 'Thorough Code Review: invoke the skill explicitly (no auto-load). CLI: `review new|render|export|…`.',
46
- },
47
- 'archive-to-adr': {
48
- label: 'Archive → ADR',
49
- nextHint:
50
- 'ADRs: after OpenSpec archive, run archive-to-adr (or stamp “No ADR”). Project path from `.forge/config.json`.',
51
- },
52
- 'git-resolve-adr-conflict': {
53
- label: 'Git: resolve ADR number conflict',
54
- nextHint:
55
- 'ADR conflicts: invoke git-resolve-adr-conflict when two authors collide on the same NNNN.',
56
- },
57
- };
58
-
59
- export const SKILL_IDS = Object.freeze(Object.keys(SKILLS));
60
-
61
- /**
62
- * Supported environments and their user-level skills directory.
63
- * Paths follow each tool's global Agent-Skills (SKILL.md) convention.
64
- * @type {Record<string, { label: string, skillDir: (home: string, skillId: string) => string }>}
65
- */
66
- export const AGENTS = {
67
- claude: {
68
- label: 'Claude Code',
69
- skillDir: (home, skillId) => path.join(home, '.claude', 'skills', skillId),
70
- },
71
- cursor: {
72
- label: 'Cursor',
73
- skillDir: (home, skillId) => path.join(home, '.cursor', 'skills', skillId),
74
- },
75
- codex: {
76
- label: 'Codex CLI',
77
- skillDir: (home, skillId) => path.join(home, '.codex', 'skills', skillId),
78
- },
79
- copilot: {
80
- label: 'GitHub Copilot',
81
- skillDir: (home, skillId) => path.join(home, '.copilot', 'skills', skillId),
82
- },
83
- gemini: {
84
- label: 'Gemini CLI',
85
- skillDir: (home, skillId) => path.join(home, '.gemini', 'skills', skillId),
86
- },
87
- windsurf: {
88
- label: 'Windsurf',
89
- skillDir: (home, skillId) =>
90
- path.join(home, '.codeium', 'windsurf', 'skills', skillId),
91
- },
92
- opencode: {
93
- label: 'opencode',
94
- skillDir: (home, skillId) =>
95
- path.join(home, '.config', 'opencode', 'skills', skillId),
96
- },
97
- };
98
-
99
- export const AGENT_IDS = Object.freeze(Object.keys(AGENTS));
100
-
101
- /**
102
- * @param {string} value
103
- * @returns {string[]}
104
- */
105
- function splitList(value) {
106
- return String(value ?? '')
107
- .split(/[,\s]+/)
108
- .map((s) => s.trim())
109
- .filter(Boolean);
110
- }
111
-
112
- /**
113
- * @param {string[]} argv
114
- */
115
- export function parseArgs(argv) {
116
- const opts = {
117
- skills: /** @type {string[]} */ ([]),
118
- agents: /** @type {string[]} */ ([]),
119
- allSkills: false,
120
- allAgents: false,
121
- /** @deprecated use --all-agents; kept for forge/review install aliases */
122
- all: false,
123
- list: false,
124
- help: false,
125
- force: false,
126
- prune: false,
127
- update: false,
128
- uninstall: false,
129
- /** @type {boolean | null} null = unset (prompt / infer) */
130
- adr: /** @type {boolean | null} */ (null),
131
- adrDir: /** @type {string | null} */ (null),
132
- /** @type {boolean | null} null = unset (prompt on TTY) */
133
- openspec: /** @type {boolean | null} */ (null),
134
- /** When true, also scaffold ADR files into cwd if it looks like a project */
135
- adrProject: false,
136
- noAdrProject: false,
137
- cwd: process.cwd(),
138
- };
139
-
140
- for (let i = 0; i < argv.length; i += 1) {
141
- const arg = argv[i];
142
- if (arg === '--skills' || arg === '--skill') {
143
- opts.skills.push(...splitList(argv[++i]));
144
- } else if (arg === '--agents' || arg === '--agent') {
145
- opts.agents.push(...splitList(argv[++i]));
146
- } else if (arg === '--all-skills') opts.allSkills = true;
147
- else if (arg === '--all-agents') opts.allAgents = true;
148
- else if (arg === '--all') opts.all = true;
149
- else if (arg === '--list') opts.list = true;
150
- else if (arg === '--update') opts.update = true;
151
- else if (arg === '--uninstall') opts.uninstall = true;
152
- else if (arg === '--force' || arg === '-f') opts.force = true;
153
- else if (arg === '--prune') opts.prune = true;
154
- else if (arg === '--help' || arg === '-h') opts.help = true;
155
- else if (arg === '--cursor') opts.agents.push('cursor');
156
- else if (arg === '--claude' || arg === '--claude-code') opts.agents.push('claude');
157
- else if (arg === '--codex') opts.agents.push('codex');
158
- else if (arg === '--copilot') opts.agents.push('copilot');
159
- else if (arg === '--gemini') opts.agents.push('gemini');
160
- else if (arg === '--windsurf') opts.agents.push('windsurf');
161
- else if (arg === '--opencode') opts.agents.push('opencode');
162
- else if (arg === '--adr') opts.adr = true;
163
- else if (arg === '--no-adr') opts.adr = false;
164
- else if (arg === '--adr-dir') opts.adrDir = argv[++i];
165
- else if (arg === '--openspec') opts.openspec = true;
166
- else if (arg === '--no-openspec') opts.openspec = false;
167
- else if (arg === '--adr-project') opts.adrProject = true;
168
- else if (arg === '--no-adr-project') opts.noAdrProject = true;
169
- else if (arg === '--cwd') opts.cwd = argv[++i];
170
- else if (
171
- arg === 'forge' ||
172
- arg === 'thorough-code-review' ||
173
- arg === 'archive-to-adr' ||
174
- arg === 'git-resolve-adr-conflict'
175
- ) {
176
- opts.skills.push(arg);
177
- } else throw new Error(`Unknown argument: ${arg}`);
178
- }
179
-
180
- opts.skills = [...new Set(opts.skills)];
181
- opts.agents = [...new Set(opts.agents)];
182
- return opts;
183
- }
184
-
185
- function printHelp() {
186
- process.stdout.write(`Usage: forgekit install [options]
187
-
188
- Install one or more skills into user-level agent skill directories.
189
-
190
- Options:
191
- --skills <ids> Comma list: ${SKILL_IDS.join(', ')}
192
- --agents <ids> Comma list: ${AGENT_IDS.join(', ')}
193
- --all-skills Install every known skill
194
- --all-agents Install for every agent environment
195
- --cursor/--claude/--codex/--copilot/--gemini/--windsurf/--opencode
196
- Shorthand agent flags (same as --agents)
197
- --prune Reconcile: also remove installed skill×env pairs
198
- outside the selection (implied by the full picker)
199
- --openspec Prefer OpenSpec as the planning engine (save user default)
200
- --no-openspec Prefer the built-in specs engine (save user default)
201
- --adr Enable ADRs (install ADR skills; save user default)
202
- --no-adr Disable ADRs (skip ADR skills; save user default)
203
- --adr-dir <path> Default ADR directory inside repos (default: ${DEFAULT_ADR_DIR})
204
- --adr-project Also scaffold ADR docs into --cwd when it is a git repo
205
- --no-adr-project Never scaffold into cwd
206
- --cwd <path> Project root for optional ADR scaffold (default: cwd)
207
- --list Show installed vs missing (and outdated) for all skill×agent pairs
208
- --update Reinstall outdated installed skills (same agents as present)
209
- --uninstall Remove installed skill dirs for selected skills×agents
210
- --force, -f Overwrite existing skill directories
211
- --help
212
-
213
- Interactive (TTY) when skills and/or agents are omitted: arrow-key pickers
214
- (space to toggle, <a> for all) pre-checked with what you already have
215
- installed. Choosing the full set reconciles — newly picked pairs install,
216
- deselected ones are removed. You are also asked whether to plan with OpenSpec
217
- (vs the built-in specs engine). ADRs are enabled by picking an ADR skill; the
218
- ADR path (default ${DEFAULT_ADR_DIR}) is only asked then.
219
-
220
- Aliases:
221
- forge install […] → forgekit install --skills forge […]
222
- review install […] → forgekit install --skills thorough-code-review […]
223
-
224
- Examples:
225
- forgekit install
226
- forgekit install --skills forge,thorough-code-review --agents cursor --adr
227
- forgekit install --all-skills --all-agents --force
228
- forgekit list
229
- forgekit update
230
- forgekit uninstall --skills forge --agents cursor
231
- `);
232
- }
233
-
234
- /**
235
- * @param {string} skillId
236
- * @returns {string}
237
- */
238
- export function resolveSkillSource(skillId) {
239
- if (!SKILLS[skillId]) {
240
- throw new Error(`Unknown skill: ${skillId}. Known: ${SKILL_IDS.join(', ')}`);
241
- }
242
- return resolveAsset(path.join('skills', skillId), { requireFile: 'SKILL.md' });
243
- }
244
-
245
- /**
246
- * Write version + content hash stamp into an installed skill directory.
247
- * @param {string} dest
248
- * @param {string} skillId
249
- * @param {string} skillSource
250
- */
251
- export function writeInstallStamp(dest, skillId, skillSource) {
252
- const stamp = {
253
- skill: skillId,
254
- version: packageVersion(),
255
- contentHash: hashDirectory(skillSource),
256
- installedAt: new Date().toISOString(),
257
- };
258
- fs.writeFileSync(
259
- path.join(dest, FORGEKIT_STAMP),
260
- `${JSON.stringify(stamp, null, 2)}\n`,
261
- 'utf8',
262
- );
263
- return stamp;
264
- }
265
-
266
- /**
267
- * @param {string} dest
268
- * @returns {{ skill?: string, version?: string, contentHash?: string } | null}
269
- */
270
- export function readInstallStamp(dest) {
271
- const p = path.join(dest, FORGEKIT_STAMP);
272
- if (!fs.existsSync(p)) return null;
273
- try {
274
- return JSON.parse(fs.readFileSync(p, 'utf8'));
275
- } catch {
276
- return null;
277
- }
278
- }
279
-
280
- /**
281
- * @param {string} skillId
282
- * @param {string} dest
283
- * @returns {'missing' | 'present' | 'outdated' | 'unversioned'}
284
- */
285
- export function skillInstallStatus(skillId, dest) {
286
- if (!fs.existsSync(dest)) return 'missing';
287
- const stamp = readInstallStamp(dest);
288
- if (!stamp?.contentHash) return 'unversioned';
289
- try {
290
- const source = resolveSkillSource(skillId);
291
- const current = hashDirectory(source);
292
- if (stamp.contentHash !== current || stamp.version !== packageVersion()) {
293
- return 'outdated';
294
- }
295
- } catch {
296
- return 'present';
297
- }
298
- return 'present';
299
- }
300
-
301
- /**
302
- * @param {string} src
303
- * @param {string} dest
304
- */
305
- export function copyDirRecursive(src, dest) {
306
- fs.mkdirSync(dest, { recursive: true });
307
- for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
308
- const from = path.join(src, entry.name);
309
- const to = path.join(dest, entry.name);
310
- if (entry.isDirectory()) copyDirRecursive(from, to);
311
- else fs.copyFileSync(from, to);
312
- }
313
- }
314
-
315
- /**
316
- * @param {string} dir
317
- */
318
- function removeDirRecursive(dir) {
319
- fs.rmSync(dir, { recursive: true, force: true });
320
- }
321
-
322
- /**
323
- * @param {string[]} skillIds
324
- * @param {string[]} agentIds
325
- * @param {{ force?: boolean, home?: string }} [opts]
326
- */
327
- export function installSkillsToAgents(skillIds, agentIds, opts = {}) {
328
- const home = opts.home ?? os.homedir();
329
- /** @type {{ skill: string, agent: string, dest: string, status: string, message?: string, skillSource?: string }[]} */
330
- const results = [];
331
-
332
- for (const skillId of skillIds) {
333
- const skillSource = resolveSkillSource(skillId);
334
- for (const agentId of agentIds) {
335
- const agent = AGENTS[agentId];
336
- if (!agent) throw new Error(`Unknown agent: ${agentId}`);
337
- const dest = agent.skillDir(home, skillId);
338
- const exists = fs.existsSync(dest);
339
- if (exists && !opts.force) {
340
- results.push({
341
- skill: skillId,
342
- agent: agentId,
343
- dest,
344
- status: 'exists',
345
- message: 'already installed (use --force)',
346
- skillSource,
347
- });
348
- continue;
349
- }
350
- if (exists) removeDirRecursive(dest);
351
- copyDirRecursive(skillSource, dest);
352
- writeInstallStamp(dest, skillId, skillSource);
353
- results.push({
354
- skill: skillId,
355
- agent: agentId,
356
- dest,
357
- status: 'installed',
358
- skillSource,
359
- });
360
- }
361
- }
362
-
363
- return results;
364
- }
365
-
366
- /**
367
- * @param {string[]} skillIds
368
- * @param {string[]} agentIds
369
- * @param {{ home?: string }} [opts]
370
- */
371
- export function uninstallSkillsFromAgents(skillIds, agentIds, opts = {}) {
372
- const home = opts.home ?? os.homedir();
373
- /** @type {{ skill: string, agent: string, dest: string, status: string }[]} */
374
- const results = [];
375
- for (const skillId of skillIds) {
376
- for (const agentId of agentIds) {
377
- const agent = AGENTS[agentId];
378
- if (!agent) throw new Error(`Unknown agent: ${agentId}`);
379
- const dest = agent.skillDir(home, skillId);
380
- if (!fs.existsSync(dest)) {
381
- results.push({ skill: skillId, agent: agentId, dest, status: 'missing' });
382
- continue;
383
- }
384
- removeDirRecursive(dest);
385
- results.push({ skill: skillId, agent: agentId, dest, status: 'removed' });
386
- }
387
- }
388
- return results;
389
- }
390
-
391
- /**
392
- * Every forgekit-managed skill×agent install currently on disk (has our stamp).
393
- * This is the "memory" of what was installed — no separate state file needed.
394
- * @param {string} [home]
395
- * @returns {{ skill: string, agent: string, dest: string }[]}
396
- */
397
- export function installedManagedPairs(home = os.homedir()) {
398
- /** @type {{ skill: string, agent: string, dest: string }[]} */
399
- const pairs = [];
400
- for (const skill of SKILL_IDS) {
401
- for (const agent of AGENT_IDS) {
402
- const dest = AGENTS[agent].skillDir(home, skill);
403
- if (fs.existsSync(dest) && readInstallStamp(dest)) {
404
- pairs.push({ skill, agent, dest });
405
- }
406
- }
407
- }
408
- return pairs;
409
- }
410
-
411
- /**
412
- * Install the selected skills×agents and, when pruning, remove any managed
413
- * install that falls outside the new selection.
414
- * @param {string[]} skillIds
415
- * @param {string[]} agentIds
416
- * @param {{ home?: string, force?: boolean, prune?: boolean }} [opts]
417
- */
418
- export function reconcileInstall(skillIds, agentIds, opts = {}) {
419
- const home = opts.home ?? os.homedir();
420
- const desired = new Set();
421
- for (const s of skillIds) for (const a of agentIds) desired.add(`${s}::${a}`);
422
- /** @type {{ skill: string, agent: string, dest: string, status: string }[]} */
423
- const removed = [];
424
- if (opts.prune) {
425
- for (const p of installedManagedPairs(home)) {
426
- if (!desired.has(`${p.skill}::${p.agent}`)) {
427
- removeDirRecursive(p.dest);
428
- removed.push({ ...p, status: 'removed' });
429
- }
430
- }
431
- }
432
- const results = installSkillsToAgents(skillIds, agentIds, {
433
- home,
434
- force: opts.force ?? true,
435
- });
436
- return { results, removed };
437
- }
438
-
439
- /**
440
- * Reinstall skills that are outdated or unversioned for agents that already have them.
441
- * @param {{ home?: string, skills?: string[], agents?: string[] }} [opts]
442
- */
443
- export function updateOutdatedSkills(opts = {}) {
444
- const home = opts.home ?? os.homedir();
445
- const skillFilter = opts.skills?.length ? new Set(opts.skills) : null;
446
- const agentFilter = opts.agents?.length ? new Set(opts.agents) : null;
447
- /** @type {string[]} */
448
- const skills = [];
449
- /** @type {string[]} */
450
- const agents = [];
451
- /** @type {Set<string>} */
452
- const skillSet = new Set();
453
- /** @type {Set<string>} */
454
- const agentSet = new Set();
455
-
456
- for (const skillId of SKILL_IDS) {
457
- if (skillFilter && !skillFilter.has(skillId)) continue;
458
- for (const agentId of AGENT_IDS) {
459
- if (agentFilter && !agentFilter.has(agentId)) continue;
460
- const dest = AGENTS[agentId].skillDir(home, skillId);
461
- const status = skillInstallStatus(skillId, dest);
462
- if (status === 'outdated' || status === 'unversioned') {
463
- skillSet.add(skillId);
464
- agentSet.add(agentId);
465
- }
466
- }
467
- }
468
- skills.push(...skillSet);
469
- agents.push(...agentSet);
470
- if (skills.length === 0) return { results: [], skills, agents };
471
- const results = installSkillsToAgents(skills, agents, { home, force: true });
472
- return { results, skills, agents };
473
- }
474
-
475
- /**
476
- * @param {{ home?: string }} [opts]
477
- */
478
- export function listInstallStatus(opts = {}) {
479
- const home = opts.home ?? os.homedir();
480
- /** @type {{ skill: string, agent: string, dest: string, status: string }[]} */
481
- const rows = [];
482
- for (const skillId of SKILL_IDS) {
483
- for (const agentId of AGENT_IDS) {
484
- const dest = AGENTS[agentId].skillDir(home, skillId);
485
- rows.push({
486
- skill: skillId,
487
- agent: agentId,
488
- dest,
489
- status: skillInstallStatus(skillId, dest),
490
- });
491
- }
492
- }
493
- return rows;
494
- }
495
-
496
- /**
497
- * @param {string} message
498
- * @param {string[]} ids
499
- * @param {string[]} [checkedIds] pre-selected (remembered from prior install)
500
- * @returns {Promise<string[]>}
501
- */
502
- async function promptMulti(message, ids, checkedIds = []) {
503
- const checked = new Set(checkedIds);
504
- return checkbox({
505
- message,
506
- choices: ids.map((id) => ({
507
- value: id,
508
- name: SKILLS[id]?.label ?? AGENTS[id]?.label ?? id,
509
- checked: checked.has(id),
510
- })),
511
- required: true,
512
- });
513
- }
514
-
515
- /** @param {string[]} [checkedIds] */
516
- async function promptSkills(checkedIds) {
517
- // First run (nothing installed): default to all skills, so <enter> = install everything.
518
- const defaults = checkedIds?.length ? checkedIds : [...SKILL_IDS];
519
- return promptMulti('Install which skills?', SKILL_IDS, defaults);
520
- }
521
-
522
- /** @param {string[]} [checkedIds] */
523
- async function promptAgents(checkedIds) {
524
- return promptMulti('Install for which environments?', AGENT_IDS, checkedIds ?? []);
525
- }
526
-
527
- /**
528
- * @param {string} [defaultDir]
529
- * @returns {Promise<string>}
530
- */
531
- export async function promptAdrDir(defaultDir = DEFAULT_ADR_DIR) {
532
- const dir = await input({
533
- message: 'ADR directory inside each repo',
534
- default: defaultDir,
535
- });
536
- return normalizeAdrDir(dir.trim() || defaultDir);
537
- }
538
-
539
- /**
540
- * @returns {Promise<boolean>} true = OpenSpec, false = built-in specs engine
541
- */
542
- export async function promptOpenSpec() {
543
- return select({
544
- message: 'Planning engine?',
545
- choices: [
546
- { value: true, name: 'OpenSpec (vendor CLI)' },
547
- { value: false, name: 'Built-in specs engine' },
548
- ],
549
- });
550
- }
551
-
552
- /**
553
- * Merge ADR skills into the skill list when ADRs are enabled.
554
- * @param {string[]} skills
555
- * @param {boolean} adrEnabled
556
- * @returns {string[]}
557
- */
558
- export function applyAdrSkills(skills, adrEnabled) {
559
- const next = [...skills];
560
- if (adrEnabled) {
561
- for (const id of ADR_SKILLS) {
562
- if (!next.includes(id)) next.push(id);
563
- }
564
- return next;
565
- }
566
- return next.filter((id) => !ADR_SKILLS.includes(id));
567
- }
568
-
569
- /**
570
- * Infer ADR enablement from explicit skill picks when --adr/--no-adr omitted.
571
- * @param {string[]} skills
572
- * @param {boolean | null} adrFlag
573
- * @returns {boolean | null} null = still unknown
574
- */
575
- export function inferAdrFromSkills(skills, adrFlag) {
576
- if (adrFlag !== null) return adrFlag;
577
- if (skills.some((id) => ADR_SKILLS.includes(id))) return true;
578
- return null;
579
- }
580
-
581
- /**
582
- * Resolve ADR enablement + directory. ADRs turn on when an ADR skill is picked
583
- * (or --adr); the path is only asked when enabled — never a standalone prompt.
584
- * @param {{ adr: boolean | null, adrDir: string | null, skills: string[] }} opts
585
- * @returns {Promise<{ enabled: boolean, dir: string }>}
586
- */
587
- export async function resolveAdrInstallOptions(opts) {
588
- const enabled = inferAdrFromSkills(opts.skills, opts.adr) === true;
589
- if (!enabled) {
590
- return {
591
- enabled: false,
592
- dir: opts.adrDir ? normalizeAdrDir(opts.adrDir) : DEFAULT_ADR_DIR,
593
- };
594
- }
595
- const dir = opts.adrDir
596
- ? normalizeAdrDir(opts.adrDir)
597
- : process.stdin.isTTY
598
- ? await promptAdrDir(DEFAULT_ADR_DIR)
599
- : DEFAULT_ADR_DIR;
600
- return { enabled: true, dir };
601
- }
602
-
603
- /**
604
- * @param {string[]} skillsIn
605
- * @param {string[]} agentsIn
606
- * @returns {Promise<{ skills: string[], agents: string[], skillsPrompted: boolean, agentsPrompted: boolean } | number>}
607
- */
608
- async function resolveSkillsAndAgents(skillsIn, agentsIn) {
609
- let skills = [...skillsIn];
610
- let agents = [...agentsIn];
611
- let skillsPrompted = false;
612
- let agentsPrompted = false;
613
- const installed = installedManagedPairs();
614
-
615
- if (skills.length === 0) {
616
- if (!process.stdin.isTTY) {
617
- process.stderr.write(
618
- 'No skills specified. Pass --skills / --all-skills, or run in a TTY.\n',
619
- );
620
- return 1;
621
- }
622
- skills = await promptSkills([...new Set(installed.map((p) => p.skill))]);
623
- skillsPrompted = true;
624
- }
625
-
626
- for (const id of skills) {
627
- if (!SKILLS[id]) {
628
- process.stderr.write(`Unknown skill: ${id}. Known: ${SKILL_IDS.join(', ')}\n`);
629
- return 1;
630
- }
631
- }
632
-
633
- if (agents.length === 0) {
634
- if (!process.stdin.isTTY) {
635
- process.stderr.write(
636
- 'No agents specified. Pass --agents / --all-agents / --cursor…, or run in a TTY.\n',
637
- );
638
- return 1;
639
- }
640
- agents = await promptAgents([...new Set(installed.map((p) => p.agent))]);
641
- agentsPrompted = true;
642
- }
643
-
644
- for (const id of agents) {
645
- if (!AGENTS[id]) {
646
- process.stderr.write(`Unknown agent: ${id}. Known: ${AGENT_IDS.join(', ')}\n`);
647
- return 1;
648
- }
649
- }
650
-
651
- return { skills, agents, skillsPrompted, agentsPrompted };
652
- }
653
-
654
- /**
655
- * @param {string[]} argv
656
- * @returns {Promise<number>}
657
- */
658
- export async function runInstall(argv = process.argv.slice(2)) {
659
- const opts = parseArgs(argv);
660
- if (opts.help) {
661
- printHelp();
662
- return 0;
663
- }
664
-
665
- if (opts.list) {
666
- for (const row of listInstallStatus()) {
667
- process.stdout.write(
668
- `${row.skill.padEnd(28)} ${row.agent.padEnd(8)} ${row.status.padEnd(12)} ${row.dest}\n`,
669
- );
670
- }
671
- return 0;
672
- }
673
-
674
- if (opts.update) {
675
- const updated = updateOutdatedSkills({
676
- skills: opts.skills.length ? opts.skills : undefined,
677
- agents: opts.agents.length || opts.allAgents || opts.all ? (
678
- opts.allAgents || opts.all ? [...AGENT_IDS] : opts.agents
679
- ) : undefined,
680
- });
681
- if (updated.results.length === 0) {
682
- process.stdout.write('All installed skills are up to date.\n');
683
- return 0;
684
- }
685
- for (const r of updated.results) {
686
- process.stdout.write(
687
- `${r.skill} × ${r.agent}: ${r.status} → ${r.dest}\n`,
688
- );
689
- }
690
- return 0;
691
- }
692
-
693
- let skills = opts.allSkills ? [...SKILL_IDS] : [...opts.skills];
694
- let agents =
695
- opts.allAgents || opts.all ? [...AGENT_IDS] : [...opts.agents];
696
-
697
- const resolved = await resolveSkillsAndAgents(skills, agents);
698
- if (typeof resolved === 'number') return resolved;
699
- skills = resolved.skills;
700
- agents = resolved.agents;
701
- // Reconcile (add new, drop deselected) only when the user chose the full set
702
- // via the pickers — flag-scoped runs (e.g. `forge install`) stay additive.
703
- const prune =
704
- opts.prune || (resolved.skillsPrompted && resolved.agentsPrompted);
705
-
706
- if (opts.uninstall) {
707
- const results = uninstallSkillsFromAgents(skills, agents);
708
- for (const r of results) {
709
- process.stdout.write(`${r.skill} × ${r.agent}: ${r.status} → ${r.dest}\n`);
710
- }
711
- return 0;
712
- }
713
-
714
- /** @type {boolean | null} */
715
- let useOpenSpec = opts.openspec;
716
- if (useOpenSpec === null && skills.includes('forge') && process.stdin.isTTY) {
717
- useOpenSpec = await promptOpenSpec();
718
- }
719
- if (useOpenSpec !== null) {
720
- saveUserPlanEngine(useOpenSpec ? 'openspec' : 'specs');
721
- }
722
-
723
- const adrOpts = await resolveAdrInstallOptions({
724
- adr: opts.adr,
725
- adrDir: opts.adrDir,
726
- skills,
727
- });
728
-
729
- skills = applyAdrSkills(skills, adrOpts.enabled);
730
-
731
- saveUserConfig({
732
- adr: { enabled: adrOpts.enabled, dir: adrOpts.dir },
733
- // Remember the environment set so `forge init` can pre-check it. Only when
734
- // deliberately chosen (picker or --all-agents) — narrow flag runs don't clobber it.
735
- ...(resolved.agentsPrompted || opts.allAgents || opts.all ? { agents } : {}),
736
- });
737
-
738
- const { results, removed } = prune
739
- ? reconcileInstall(skills, agents, { force: true, prune: true })
740
- : { results: installSkillsToAgents(skills, agents, { force: opts.force }), removed: [] };
741
- const sources = new Map();
742
- for (const r of results) {
743
- if (r.skillSource) sources.set(r.skill, r.skillSource);
744
- }
745
- for (const [skill, src] of sources) {
746
- process.stdout.write(`Skill ${skill}: ${src}\n`);
747
- }
748
- for (const r of removed) {
749
- process.stdout.write(`${r.skill} × ${r.agent}: removed (deselected) → ${r.dest}\n`);
750
- }
751
- for (const r of results) {
752
- process.stdout.write(
753
- `${r.skill} × ${r.agent}: ${r.status}${r.message ? ` — ${r.message}` : ''} → ${r.dest}\n`,
754
- );
755
- }
756
-
757
- if (useOpenSpec !== null) {
758
- process.stdout.write(
759
- `\nPlanning engine saved (~/.forgekit/config.json): ${
760
- useOpenSpec ? 'openspec' : 'specs (built-in)'
761
- } — per-project setup happens at \`forge init\`.\n`,
762
- );
763
- }
764
- process.stdout.write(
765
- `${useOpenSpec !== null ? '' : '\n'}ADR preference saved (~/.forgekit/config.json): ${
766
- adrOpts.enabled ? `enabled, dir=${adrOpts.dir}` : 'disabled'
767
- }\n`,
768
- );
769
-
770
- const inRepo = isGitRepo(opts.cwd);
771
- const shouldScaffold =
772
- !opts.noAdrProject &&
773
- adrOpts.enabled &&
774
- (opts.adrProject || (inRepo && process.stdin.isTTY));
775
-
776
- if (shouldScaffold) {
777
- const scaffold = scaffoldAdr(opts.cwd, {
778
- dir: adrOpts.dir,
779
- force: opts.force,
780
- hooks: true,
781
- });
782
- process.stdout.write(
783
- `ADR project scaffold in ${opts.cwd}: ${scaffold.decisionsDoc}, ${scaffold.dir}/README.md, .forge/config.json\n`,
784
- );
785
- for (const f of scaffold.files) {
786
- process.stdout.write(` ${f.status.padEnd(8)} ${f.file}\n`);
787
- }
788
- } else if (inRepo && !adrOpts.enabled && opts.adr === false) {
789
- disableProjectAdr(opts.cwd);
790
- process.stdout.write(
791
- `ADRs disabled in project (.forge/config.json) under ${opts.cwd}\n`,
792
- );
793
- } else if (adrOpts.enabled) {
794
- process.stdout.write(
795
- `Tip: in each repo run \`forge init --adr\` (or \`forgekit install --adr --adr-project\`) to scaffold ${adrOpts.dir}/ and decisions.md.\n`,
796
- );
797
- }
798
-
799
- const hints = [...new Set(skills.map((id) => SKILLS[id].nextHint))];
800
- process.stdout.write(`\n${hints.join('\n')}\n`);
801
- return 0;
802
- }
803
-
804
- const isDirect =
805
- process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url;
806
-
807
- if (isDirect) {
808
- runInstall()
809
- .then((code) => process.exit(code))
810
- .catch((err) => {
811
- if (err?.name === 'ExitPromptError') process.exit(130);
812
- process.stderr.write(`${err.message || err}\n`);
813
- process.exit(1);
814
- });
815
- }
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Install forgekit skills into user-level agent skill directories.
4
+ *
5
+ * Canonical entry: `forgekit install`
6
+ * Aliases: `forge install` → --skills forge
7
+ * `review install` → --skills thorough-code-review
8
+ *
9
+ * Usage:
10
+ * forgekit install
11
+ * forgekit install --skills forge,thorough-code-review --agents cursor,claude
12
+ * forgekit install --all-skills --all-agents --force
13
+ * forgekit list
14
+ */
15
+
16
+ import fs from 'node:fs';
17
+ import os from 'node:os';
18
+ import path from 'node:path';
19
+ import { pathToFileURL } from 'node:url';
20
+ import { checkbox, input, select } from '@inquirer/prompts';
21
+ import {
22
+ ADR_SKILLS,
23
+ DEFAULT_ADR_DIR,
24
+ isGitRepo,
25
+ normalizeAdrDir,
26
+ saveUserConfig,
27
+ scaffoldAdr,
28
+ disableProjectAdr,
29
+ } from './adr.mjs';
30
+ import { saveUserPlanEngine } from './plan-engine.mjs';
31
+ import { hashDirectory, packageVersion, resolveAsset } from './paths.mjs';
32
+
33
+ export const FORGEKIT_STAMP = '.forgekit.json';
34
+
35
+ /** @type {Record<string, { label: string, nextHint: string }>} */
36
+ export const SKILLS = {
37
+ forge: {
38
+ label: 'Forge',
39
+ nextHint:
40
+ 'Forge: in each project, run `forge init` to add /forge commands, rules, and hooks.',
41
+ },
42
+ 'thorough-code-review': {
43
+ label: 'Thorough Code Review',
44
+ nextHint:
45
+ 'Thorough Code Review: invoke the skill explicitly (no auto-load). CLI: `review new|render|export|…`.',
46
+ },
47
+ 'archive-to-adr': {
48
+ label: 'Archive → ADR',
49
+ nextHint:
50
+ 'ADRs: after OpenSpec archive, run archive-to-adr (or stamp “No ADR”). Project path from `.forge/config.json`.',
51
+ },
52
+ 'git-resolve-adr-conflict': {
53
+ label: 'Git: resolve ADR number conflict',
54
+ nextHint:
55
+ 'ADR conflicts: invoke git-resolve-adr-conflict when two authors collide on the same NNNN.',
56
+ },
57
+ };
58
+
59
+ export const SKILL_IDS = Object.freeze(Object.keys(SKILLS));
60
+
61
+ /**
62
+ * Supported environments and their user-level skills directory.
63
+ * Paths follow each tool's global Agent-Skills (SKILL.md) convention.
64
+ * @type {Record<string, { label: string, skillDir: (home: string, skillId: string) => string }>}
65
+ */
66
+ export const AGENTS = {
67
+ claude: {
68
+ label: 'Claude Code',
69
+ skillDir: (home, skillId) => path.join(home, '.claude', 'skills', skillId),
70
+ },
71
+ cursor: {
72
+ label: 'Cursor',
73
+ skillDir: (home, skillId) => path.join(home, '.cursor', 'skills', skillId),
74
+ },
75
+ codex: {
76
+ label: 'Codex CLI',
77
+ skillDir: (home, skillId) => path.join(home, '.codex', 'skills', skillId),
78
+ },
79
+ copilot: {
80
+ label: 'GitHub Copilot',
81
+ skillDir: (home, skillId) => path.join(home, '.copilot', 'skills', skillId),
82
+ },
83
+ gemini: {
84
+ label: 'Gemini CLI',
85
+ skillDir: (home, skillId) => path.join(home, '.gemini', 'skills', skillId),
86
+ },
87
+ windsurf: {
88
+ label: 'Windsurf',
89
+ skillDir: (home, skillId) =>
90
+ path.join(home, '.codeium', 'windsurf', 'skills', skillId),
91
+ },
92
+ opencode: {
93
+ label: 'opencode',
94
+ skillDir: (home, skillId) =>
95
+ path.join(home, '.config', 'opencode', 'skills', skillId),
96
+ },
97
+ };
98
+
99
+ export const AGENT_IDS = Object.freeze(Object.keys(AGENTS));
100
+
101
+ /**
102
+ * @param {string} value
103
+ * @returns {string[]}
104
+ */
105
+ function splitList(value) {
106
+ return String(value ?? '')
107
+ .split(/[,\s]+/)
108
+ .map((s) => s.trim())
109
+ .filter(Boolean);
110
+ }
111
+
112
+ /**
113
+ * @param {string[]} argv
114
+ */
115
+ export function parseArgs(argv) {
116
+ const opts = {
117
+ skills: /** @type {string[]} */ ([]),
118
+ agents: /** @type {string[]} */ ([]),
119
+ allSkills: false,
120
+ allAgents: false,
121
+ /** @deprecated use --all-agents; kept for forge/review install aliases */
122
+ all: false,
123
+ list: false,
124
+ help: false,
125
+ force: false,
126
+ prune: false,
127
+ update: false,
128
+ uninstall: false,
129
+ /** @type {boolean | null} null = unset (prompt / infer) */
130
+ adr: /** @type {boolean | null} */ (null),
131
+ adrDir: /** @type {string | null} */ (null),
132
+ /** @type {boolean | null} null = unset (prompt on TTY) */
133
+ openspec: /** @type {boolean | null} */ (null),
134
+ /** When true, also scaffold ADR files into cwd if it looks like a project */
135
+ adrProject: false,
136
+ noAdrProject: false,
137
+ cwd: process.cwd(),
138
+ };
139
+
140
+ for (let i = 0; i < argv.length; i += 1) {
141
+ const arg = argv[i];
142
+ if (arg === '--skills' || arg === '--skill') {
143
+ opts.skills.push(...splitList(argv[++i]));
144
+ } else if (arg === '--agents' || arg === '--agent') {
145
+ opts.agents.push(...splitList(argv[++i]));
146
+ } else if (arg === '--all-skills') opts.allSkills = true;
147
+ else if (arg === '--all-agents') opts.allAgents = true;
148
+ else if (arg === '--all') opts.all = true;
149
+ else if (arg === '--list') opts.list = true;
150
+ else if (arg === '--update') opts.update = true;
151
+ else if (arg === '--uninstall') opts.uninstall = true;
152
+ else if (arg === '--force' || arg === '-f') opts.force = true;
153
+ else if (arg === '--prune') opts.prune = true;
154
+ else if (arg === '--help' || arg === '-h') opts.help = true;
155
+ else if (arg === '--cursor') opts.agents.push('cursor');
156
+ else if (arg === '--claude' || arg === '--claude-code') opts.agents.push('claude');
157
+ else if (arg === '--codex') opts.agents.push('codex');
158
+ else if (arg === '--copilot') opts.agents.push('copilot');
159
+ else if (arg === '--gemini') opts.agents.push('gemini');
160
+ else if (arg === '--windsurf') opts.agents.push('windsurf');
161
+ else if (arg === '--opencode') opts.agents.push('opencode');
162
+ else if (arg === '--adr') opts.adr = true;
163
+ else if (arg === '--no-adr') opts.adr = false;
164
+ else if (arg === '--adr-dir') opts.adrDir = argv[++i];
165
+ else if (arg === '--openspec') opts.openspec = true;
166
+ else if (arg === '--no-openspec') opts.openspec = false;
167
+ else if (arg === '--adr-project') opts.adrProject = true;
168
+ else if (arg === '--no-adr-project') opts.noAdrProject = true;
169
+ else if (arg === '--cwd') opts.cwd = argv[++i];
170
+ else if (
171
+ arg === 'forge' ||
172
+ arg === 'thorough-code-review' ||
173
+ arg === 'archive-to-adr' ||
174
+ arg === 'git-resolve-adr-conflict'
175
+ ) {
176
+ opts.skills.push(arg);
177
+ } else throw new Error(`Unknown argument: ${arg}`);
178
+ }
179
+
180
+ opts.skills = [...new Set(opts.skills)];
181
+ opts.agents = [...new Set(opts.agents)];
182
+ return opts;
183
+ }
184
+
185
+ function printHelp() {
186
+ process.stdout.write(`Usage: forgekit install [options]
187
+
188
+ Install one or more skills into user-level agent skill directories.
189
+
190
+ Options:
191
+ --skills <ids> Comma list: ${SKILL_IDS.join(', ')}
192
+ --agents <ids> Comma list: ${AGENT_IDS.join(', ')}
193
+ --all-skills Install every known skill
194
+ --all-agents Install for every agent environment
195
+ --cursor/--claude/--codex/--copilot/--gemini/--windsurf/--opencode
196
+ Shorthand agent flags (same as --agents)
197
+ --prune Reconcile: also remove installed skill×env pairs
198
+ outside the selection (implied by the full picker)
199
+ --openspec Prefer OpenSpec as the planning engine (save user default)
200
+ --no-openspec Prefer the built-in specs engine (save user default)
201
+ --adr Enable ADRs (install ADR skills; save user default)
202
+ --no-adr Disable ADRs (skip ADR skills; save user default)
203
+ --adr-dir <path> Default ADR directory inside repos (default: ${DEFAULT_ADR_DIR})
204
+ --adr-project Also scaffold ADR docs into --cwd when it is a git repo
205
+ --no-adr-project Never scaffold into cwd
206
+ --cwd <path> Project root for optional ADR scaffold (default: cwd)
207
+ --list Show installed vs missing (and outdated) for all skill×agent pairs
208
+ --update Reinstall outdated installed skills (same agents as present)
209
+ --uninstall Remove installed skill dirs for selected skills×agents
210
+ --force, -f Overwrite existing skill directories
211
+ --help
212
+
213
+ Interactive (TTY) when skills and/or agents are omitted: arrow-key pickers
214
+ (space to toggle, <a> for all) pre-checked with what you already have
215
+ installed. Choosing the full set reconciles — newly picked pairs install,
216
+ deselected ones are removed. You are also asked whether to plan with OpenSpec
217
+ (vs the built-in specs engine). ADRs are enabled by picking an ADR skill; the
218
+ ADR path (default ${DEFAULT_ADR_DIR}) is only asked then.
219
+
220
+ Aliases:
221
+ forge install […] → forgekit install --skills forge […]
222
+ review install […] → forgekit install --skills thorough-code-review […]
223
+
224
+ Examples:
225
+ forgekit install
226
+ forgekit install --skills forge,thorough-code-review --agents cursor --adr
227
+ forgekit install --all-skills --all-agents --force
228
+ forgekit list
229
+ forgekit update
230
+ forgekit uninstall --skills forge --agents cursor
231
+ `);
232
+ }
233
+
234
+ /**
235
+ * @param {string} skillId
236
+ * @returns {string}
237
+ */
238
+ export function resolveSkillSource(skillId) {
239
+ if (!SKILLS[skillId]) {
240
+ throw new Error(`Unknown skill: ${skillId}. Known: ${SKILL_IDS.join(', ')}`);
241
+ }
242
+ return resolveAsset(path.join('skills', skillId), { requireFile: 'SKILL.md' });
243
+ }
244
+
245
+ /**
246
+ * Write version + content hash stamp into an installed skill directory.
247
+ * @param {string} dest
248
+ * @param {string} skillId
249
+ * @param {string} skillSource
250
+ */
251
+ export function writeInstallStamp(dest, skillId, skillSource) {
252
+ const stamp = {
253
+ skill: skillId,
254
+ version: packageVersion(),
255
+ contentHash: hashDirectory(skillSource),
256
+ installedAt: new Date().toISOString(),
257
+ };
258
+ fs.writeFileSync(
259
+ path.join(dest, FORGEKIT_STAMP),
260
+ `${JSON.stringify(stamp, null, 2)}\n`,
261
+ 'utf8',
262
+ );
263
+ return stamp;
264
+ }
265
+
266
+ /**
267
+ * @param {string} dest
268
+ * @returns {{ skill?: string, version?: string, contentHash?: string } | null}
269
+ */
270
+ export function readInstallStamp(dest) {
271
+ const p = path.join(dest, FORGEKIT_STAMP);
272
+ if (!fs.existsSync(p)) return null;
273
+ try {
274
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
275
+ } catch {
276
+ return null;
277
+ }
278
+ }
279
+
280
+ /**
281
+ * @param {string} skillId
282
+ * @param {string} dest
283
+ * @returns {'missing' | 'present' | 'outdated' | 'unversioned'}
284
+ */
285
+ export function skillInstallStatus(skillId, dest) {
286
+ if (!fs.existsSync(dest)) return 'missing';
287
+ const stamp = readInstallStamp(dest);
288
+ if (!stamp?.contentHash) return 'unversioned';
289
+ try {
290
+ const source = resolveSkillSource(skillId);
291
+ const current = hashDirectory(source);
292
+ if (stamp.contentHash !== current || stamp.version !== packageVersion()) {
293
+ return 'outdated';
294
+ }
295
+ } catch {
296
+ return 'present';
297
+ }
298
+ return 'present';
299
+ }
300
+
301
+ /**
302
+ * @param {string} src
303
+ * @param {string} dest
304
+ */
305
+ export function copyDirRecursive(src, dest) {
306
+ fs.mkdirSync(dest, { recursive: true });
307
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
308
+ const from = path.join(src, entry.name);
309
+ const to = path.join(dest, entry.name);
310
+ if (entry.isDirectory()) copyDirRecursive(from, to);
311
+ else fs.copyFileSync(from, to);
312
+ }
313
+ }
314
+
315
+ /**
316
+ * @param {string} dir
317
+ */
318
+ function removeDirRecursive(dir) {
319
+ fs.rmSync(dir, { recursive: true, force: true });
320
+ }
321
+
322
+ /**
323
+ * @param {string[]} skillIds
324
+ * @param {string[]} agentIds
325
+ * @param {{ force?: boolean, home?: string }} [opts]
326
+ */
327
+ export function installSkillsToAgents(skillIds, agentIds, opts = {}) {
328
+ const home = opts.home ?? os.homedir();
329
+ /** @type {{ skill: string, agent: string, dest: string, status: string, message?: string, skillSource?: string }[]} */
330
+ const results = [];
331
+
332
+ for (const skillId of skillIds) {
333
+ const skillSource = resolveSkillSource(skillId);
334
+ for (const agentId of agentIds) {
335
+ const agent = AGENTS[agentId];
336
+ if (!agent) throw new Error(`Unknown agent: ${agentId}`);
337
+ const dest = agent.skillDir(home, skillId);
338
+ const exists = fs.existsSync(dest);
339
+ if (exists && !opts.force) {
340
+ results.push({
341
+ skill: skillId,
342
+ agent: agentId,
343
+ dest,
344
+ status: 'exists',
345
+ message: 'already installed (use --force)',
346
+ skillSource,
347
+ });
348
+ continue;
349
+ }
350
+ if (exists) removeDirRecursive(dest);
351
+ copyDirRecursive(skillSource, dest);
352
+ writeInstallStamp(dest, skillId, skillSource);
353
+ results.push({
354
+ skill: skillId,
355
+ agent: agentId,
356
+ dest,
357
+ status: 'installed',
358
+ skillSource,
359
+ });
360
+ }
361
+ }
362
+
363
+ return results;
364
+ }
365
+
366
+ /**
367
+ * @param {string[]} skillIds
368
+ * @param {string[]} agentIds
369
+ * @param {{ home?: string }} [opts]
370
+ */
371
+ export function uninstallSkillsFromAgents(skillIds, agentIds, opts = {}) {
372
+ const home = opts.home ?? os.homedir();
373
+ /** @type {{ skill: string, agent: string, dest: string, status: string }[]} */
374
+ const results = [];
375
+ for (const skillId of skillIds) {
376
+ for (const agentId of agentIds) {
377
+ const agent = AGENTS[agentId];
378
+ if (!agent) throw new Error(`Unknown agent: ${agentId}`);
379
+ const dest = agent.skillDir(home, skillId);
380
+ if (!fs.existsSync(dest)) {
381
+ results.push({ skill: skillId, agent: agentId, dest, status: 'missing' });
382
+ continue;
383
+ }
384
+ removeDirRecursive(dest);
385
+ results.push({ skill: skillId, agent: agentId, dest, status: 'removed' });
386
+ }
387
+ }
388
+ return results;
389
+ }
390
+
391
+ /**
392
+ * Every forgekit-managed skill×agent install currently on disk (has our stamp).
393
+ * This is the "memory" of what was installed — no separate state file needed.
394
+ * @param {string} [home]
395
+ * @returns {{ skill: string, agent: string, dest: string }[]}
396
+ */
397
+ export function installedManagedPairs(home = os.homedir()) {
398
+ /** @type {{ skill: string, agent: string, dest: string }[]} */
399
+ const pairs = [];
400
+ for (const skill of SKILL_IDS) {
401
+ for (const agent of AGENT_IDS) {
402
+ const dest = AGENTS[agent].skillDir(home, skill);
403
+ if (fs.existsSync(dest) && readInstallStamp(dest)) {
404
+ pairs.push({ skill, agent, dest });
405
+ }
406
+ }
407
+ }
408
+ return pairs;
409
+ }
410
+
411
+ /**
412
+ * Install the selected skills×agents and, when pruning, remove any managed
413
+ * install that falls outside the new selection.
414
+ * @param {string[]} skillIds
415
+ * @param {string[]} agentIds
416
+ * @param {{ home?: string, force?: boolean, prune?: boolean }} [opts]
417
+ */
418
+ export function reconcileInstall(skillIds, agentIds, opts = {}) {
419
+ const home = opts.home ?? os.homedir();
420
+ const desired = new Set();
421
+ for (const s of skillIds) for (const a of agentIds) desired.add(`${s}::${a}`);
422
+ /** @type {{ skill: string, agent: string, dest: string, status: string }[]} */
423
+ const removed = [];
424
+ if (opts.prune) {
425
+ for (const p of installedManagedPairs(home)) {
426
+ if (!desired.has(`${p.skill}::${p.agent}`)) {
427
+ removeDirRecursive(p.dest);
428
+ removed.push({ ...p, status: 'removed' });
429
+ }
430
+ }
431
+ }
432
+ const results = installSkillsToAgents(skillIds, agentIds, {
433
+ home,
434
+ force: opts.force ?? true,
435
+ });
436
+ return { results, removed };
437
+ }
438
+
439
+ /**
440
+ * Reinstall skills that are outdated or unversioned for agents that already have them.
441
+ * @param {{ home?: string, skills?: string[], agents?: string[] }} [opts]
442
+ */
443
+ export function updateOutdatedSkills(opts = {}) {
444
+ const home = opts.home ?? os.homedir();
445
+ const skillFilter = opts.skills?.length ? new Set(opts.skills) : null;
446
+ const agentFilter = opts.agents?.length ? new Set(opts.agents) : null;
447
+ /** @type {string[]} */
448
+ const skills = [];
449
+ /** @type {string[]} */
450
+ const agents = [];
451
+ /** @type {Set<string>} */
452
+ const skillSet = new Set();
453
+ /** @type {Set<string>} */
454
+ const agentSet = new Set();
455
+
456
+ for (const skillId of SKILL_IDS) {
457
+ if (skillFilter && !skillFilter.has(skillId)) continue;
458
+ for (const agentId of AGENT_IDS) {
459
+ if (agentFilter && !agentFilter.has(agentId)) continue;
460
+ const dest = AGENTS[agentId].skillDir(home, skillId);
461
+ const status = skillInstallStatus(skillId, dest);
462
+ if (status === 'outdated' || status === 'unversioned') {
463
+ skillSet.add(skillId);
464
+ agentSet.add(agentId);
465
+ }
466
+ }
467
+ }
468
+ skills.push(...skillSet);
469
+ agents.push(...agentSet);
470
+ if (skills.length === 0) return { results: [], skills, agents };
471
+ const results = installSkillsToAgents(skills, agents, { home, force: true });
472
+ return { results, skills, agents };
473
+ }
474
+
475
+ /**
476
+ * @param {{ home?: string }} [opts]
477
+ */
478
+ export function listInstallStatus(opts = {}) {
479
+ const home = opts.home ?? os.homedir();
480
+ /** @type {{ skill: string, agent: string, dest: string, status: string }[]} */
481
+ const rows = [];
482
+ for (const skillId of SKILL_IDS) {
483
+ for (const agentId of AGENT_IDS) {
484
+ const dest = AGENTS[agentId].skillDir(home, skillId);
485
+ rows.push({
486
+ skill: skillId,
487
+ agent: agentId,
488
+ dest,
489
+ status: skillInstallStatus(skillId, dest),
490
+ });
491
+ }
492
+ }
493
+ return rows;
494
+ }
495
+
496
+ /**
497
+ * @param {string} message
498
+ * @param {string[]} ids
499
+ * @param {string[]} [checkedIds] pre-selected (remembered from prior install)
500
+ * @returns {Promise<string[]>}
501
+ */
502
+ async function promptMulti(message, ids, checkedIds = []) {
503
+ const checked = new Set(checkedIds);
504
+ return checkbox({
505
+ message,
506
+ choices: ids.map((id) => ({
507
+ value: id,
508
+ name: SKILLS[id]?.label ?? AGENTS[id]?.label ?? id,
509
+ checked: checked.has(id),
510
+ })),
511
+ required: true,
512
+ });
513
+ }
514
+
515
+ /** @param {string[]} [checkedIds] */
516
+ async function promptSkills(checkedIds) {
517
+ // First run (nothing installed): default to all skills, so <enter> = install everything.
518
+ const defaults = checkedIds?.length ? checkedIds : [...SKILL_IDS];
519
+ return promptMulti('Install which skills?', SKILL_IDS, defaults);
520
+ }
521
+
522
+ /** @param {string[]} [checkedIds] */
523
+ async function promptAgents(checkedIds) {
524
+ return promptMulti('Install for which environments?', AGENT_IDS, checkedIds ?? []);
525
+ }
526
+
527
+ /**
528
+ * @param {string} [defaultDir]
529
+ * @returns {Promise<string>}
530
+ */
531
+ export async function promptAdrDir(defaultDir = DEFAULT_ADR_DIR) {
532
+ const dir = await input({
533
+ message: 'ADR directory inside each repo',
534
+ default: defaultDir,
535
+ });
536
+ return normalizeAdrDir(dir.trim() || defaultDir);
537
+ }
538
+
539
+ /**
540
+ * @returns {Promise<boolean>} true = OpenSpec, false = built-in specs engine
541
+ */
542
+ export async function promptOpenSpec() {
543
+ return select({
544
+ message: 'Planning engine?',
545
+ choices: [
546
+ { value: true, name: 'OpenSpec (vendor CLI)' },
547
+ { value: false, name: 'Built-in specs engine' },
548
+ ],
549
+ });
550
+ }
551
+
552
+ /**
553
+ * Merge ADR skills into the skill list when ADRs are enabled.
554
+ * @param {string[]} skills
555
+ * @param {boolean} adrEnabled
556
+ * @returns {string[]}
557
+ */
558
+ export function applyAdrSkills(skills, adrEnabled) {
559
+ const next = [...skills];
560
+ if (adrEnabled) {
561
+ for (const id of ADR_SKILLS) {
562
+ if (!next.includes(id)) next.push(id);
563
+ }
564
+ return next;
565
+ }
566
+ return next.filter((id) => !ADR_SKILLS.includes(id));
567
+ }
568
+
569
+ /**
570
+ * Infer ADR enablement from explicit skill picks when --adr/--no-adr omitted.
571
+ * @param {string[]} skills
572
+ * @param {boolean | null} adrFlag
573
+ * @returns {boolean | null} null = still unknown
574
+ */
575
+ export function inferAdrFromSkills(skills, adrFlag) {
576
+ if (adrFlag !== null) return adrFlag;
577
+ if (skills.some((id) => ADR_SKILLS.includes(id))) return true;
578
+ return null;
579
+ }
580
+
581
+ /**
582
+ * Resolve ADR enablement + directory. ADRs turn on when an ADR skill is picked
583
+ * (or --adr); the path is only asked when enabled — never a standalone prompt.
584
+ * @param {{ adr: boolean | null, adrDir: string | null, skills: string[] }} opts
585
+ * @returns {Promise<{ enabled: boolean, dir: string }>}
586
+ */
587
+ export async function resolveAdrInstallOptions(opts) {
588
+ const enabled = inferAdrFromSkills(opts.skills, opts.adr) === true;
589
+ if (!enabled) {
590
+ return {
591
+ enabled: false,
592
+ dir: opts.adrDir ? normalizeAdrDir(opts.adrDir) : DEFAULT_ADR_DIR,
593
+ };
594
+ }
595
+ const dir = opts.adrDir
596
+ ? normalizeAdrDir(opts.adrDir)
597
+ : process.stdin.isTTY
598
+ ? await promptAdrDir(DEFAULT_ADR_DIR)
599
+ : DEFAULT_ADR_DIR;
600
+ return { enabled: true, dir };
601
+ }
602
+
603
+ /**
604
+ * @param {string[]} skillsIn
605
+ * @param {string[]} agentsIn
606
+ * @returns {Promise<{ skills: string[], agents: string[], skillsPrompted: boolean, agentsPrompted: boolean } | number>}
607
+ */
608
+ async function resolveSkillsAndAgents(skillsIn, agentsIn) {
609
+ let skills = [...skillsIn];
610
+ let agents = [...agentsIn];
611
+ let skillsPrompted = false;
612
+ let agentsPrompted = false;
613
+ const installed = installedManagedPairs();
614
+
615
+ if (skills.length === 0) {
616
+ if (!process.stdin.isTTY) {
617
+ process.stderr.write(
618
+ 'No skills specified. Pass --skills / --all-skills, or run in a TTY.\n',
619
+ );
620
+ return 1;
621
+ }
622
+ skills = await promptSkills([...new Set(installed.map((p) => p.skill))]);
623
+ skillsPrompted = true;
624
+ }
625
+
626
+ for (const id of skills) {
627
+ if (!SKILLS[id]) {
628
+ process.stderr.write(`Unknown skill: ${id}. Known: ${SKILL_IDS.join(', ')}\n`);
629
+ return 1;
630
+ }
631
+ }
632
+
633
+ if (agents.length === 0) {
634
+ if (!process.stdin.isTTY) {
635
+ process.stderr.write(
636
+ 'No agents specified. Pass --agents / --all-agents / --cursor…, or run in a TTY.\n',
637
+ );
638
+ return 1;
639
+ }
640
+ agents = await promptAgents([...new Set(installed.map((p) => p.agent))]);
641
+ agentsPrompted = true;
642
+ }
643
+
644
+ for (const id of agents) {
645
+ if (!AGENTS[id]) {
646
+ process.stderr.write(`Unknown agent: ${id}. Known: ${AGENT_IDS.join(', ')}\n`);
647
+ return 1;
648
+ }
649
+ }
650
+
651
+ return { skills, agents, skillsPrompted, agentsPrompted };
652
+ }
653
+
654
+ /**
655
+ * @param {string[]} argv
656
+ * @returns {Promise<number>}
657
+ */
658
+ export async function runInstall(argv = process.argv.slice(2)) {
659
+ const opts = parseArgs(argv);
660
+ if (opts.help) {
661
+ printHelp();
662
+ return 0;
663
+ }
664
+
665
+ if (opts.list) {
666
+ for (const row of listInstallStatus()) {
667
+ process.stdout.write(
668
+ `${row.skill.padEnd(28)} ${row.agent.padEnd(8)} ${row.status.padEnd(12)} ${row.dest}\n`,
669
+ );
670
+ }
671
+ return 0;
672
+ }
673
+
674
+ if (opts.update) {
675
+ const updated = updateOutdatedSkills({
676
+ skills: opts.skills.length ? opts.skills : undefined,
677
+ agents: opts.agents.length || opts.allAgents || opts.all ? (
678
+ opts.allAgents || opts.all ? [...AGENT_IDS] : opts.agents
679
+ ) : undefined,
680
+ });
681
+ if (updated.results.length === 0) {
682
+ process.stdout.write('All installed skills are up to date.\n');
683
+ return 0;
684
+ }
685
+ for (const r of updated.results) {
686
+ process.stdout.write(
687
+ `${r.skill} × ${r.agent}: ${r.status} → ${r.dest}\n`,
688
+ );
689
+ }
690
+ return 0;
691
+ }
692
+
693
+ let skills = opts.allSkills ? [...SKILL_IDS] : [...opts.skills];
694
+ let agents =
695
+ opts.allAgents || opts.all ? [...AGENT_IDS] : [...opts.agents];
696
+
697
+ const resolved = await resolveSkillsAndAgents(skills, agents);
698
+ if (typeof resolved === 'number') return resolved;
699
+ skills = resolved.skills;
700
+ agents = resolved.agents;
701
+ // Reconcile (add new, drop deselected) only when the user chose the full set
702
+ // via the pickers — flag-scoped runs (e.g. `forge install`) stay additive.
703
+ const prune =
704
+ opts.prune || (resolved.skillsPrompted && resolved.agentsPrompted);
705
+
706
+ if (opts.uninstall) {
707
+ const results = uninstallSkillsFromAgents(skills, agents);
708
+ for (const r of results) {
709
+ process.stdout.write(`${r.skill} × ${r.agent}: ${r.status} → ${r.dest}\n`);
710
+ }
711
+ return 0;
712
+ }
713
+
714
+ /** @type {boolean | null} */
715
+ let useOpenSpec = opts.openspec;
716
+ if (useOpenSpec === null && skills.includes('forge') && process.stdin.isTTY) {
717
+ useOpenSpec = await promptOpenSpec();
718
+ }
719
+ if (useOpenSpec !== null) {
720
+ saveUserPlanEngine(useOpenSpec ? 'openspec' : 'specs');
721
+ }
722
+
723
+ const adrOpts = await resolveAdrInstallOptions({
724
+ adr: opts.adr,
725
+ adrDir: opts.adrDir,
726
+ skills,
727
+ });
728
+
729
+ skills = applyAdrSkills(skills, adrOpts.enabled);
730
+
731
+ saveUserConfig({
732
+ adr: { enabled: adrOpts.enabled, dir: adrOpts.dir },
733
+ // Remember the environment set so `forge init` can pre-check it. Only when
734
+ // deliberately chosen (picker or --all-agents) — narrow flag runs don't clobber it.
735
+ ...(resolved.agentsPrompted || opts.allAgents || opts.all ? { agents } : {}),
736
+ });
737
+
738
+ const { results, removed } = prune
739
+ ? reconcileInstall(skills, agents, { force: true, prune: true })
740
+ : { results: installSkillsToAgents(skills, agents, { force: opts.force }), removed: [] };
741
+ const sources = new Map();
742
+ for (const r of results) {
743
+ if (r.skillSource) sources.set(r.skill, r.skillSource);
744
+ }
745
+ for (const [skill, src] of sources) {
746
+ process.stdout.write(`Skill ${skill}: ${src}\n`);
747
+ }
748
+ for (const r of removed) {
749
+ process.stdout.write(`${r.skill} × ${r.agent}: removed (deselected) → ${r.dest}\n`);
750
+ }
751
+ for (const r of results) {
752
+ process.stdout.write(
753
+ `${r.skill} × ${r.agent}: ${r.status}${r.message ? ` — ${r.message}` : ''} → ${r.dest}\n`,
754
+ );
755
+ }
756
+
757
+ if (useOpenSpec !== null) {
758
+ process.stdout.write(
759
+ `\nPlanning engine saved (~/.forgekit/config.json): ${
760
+ useOpenSpec ? 'openspec' : 'specs (built-in)'
761
+ } — per-project setup happens at \`forge init\`.\n`,
762
+ );
763
+ }
764
+ process.stdout.write(
765
+ `${useOpenSpec !== null ? '' : '\n'}ADR preference saved (~/.forgekit/config.json): ${
766
+ adrOpts.enabled ? `enabled, dir=${adrOpts.dir}` : 'disabled'
767
+ }\n`,
768
+ );
769
+
770
+ const inRepo = isGitRepo(opts.cwd);
771
+ const shouldScaffold =
772
+ !opts.noAdrProject &&
773
+ adrOpts.enabled &&
774
+ (opts.adrProject || (inRepo && process.stdin.isTTY));
775
+
776
+ if (shouldScaffold) {
777
+ const scaffold = scaffoldAdr(opts.cwd, {
778
+ dir: adrOpts.dir,
779
+ force: opts.force,
780
+ hooks: true,
781
+ });
782
+ process.stdout.write(
783
+ `ADR project scaffold in ${opts.cwd}: ${scaffold.decisionsDoc}, ${scaffold.dir}/README.md, .forge/config.json\n`,
784
+ );
785
+ for (const f of scaffold.files) {
786
+ process.stdout.write(` ${f.status.padEnd(8)} ${f.file}\n`);
787
+ }
788
+ } else if (inRepo && !adrOpts.enabled && opts.adr === false) {
789
+ disableProjectAdr(opts.cwd);
790
+ process.stdout.write(
791
+ `ADRs disabled in project (.forge/config.json) under ${opts.cwd}\n`,
792
+ );
793
+ } else if (adrOpts.enabled) {
794
+ process.stdout.write(
795
+ `Tip: in each repo run \`forge init --adr\` (or \`forgekit install --adr --adr-project\`) to scaffold ${adrOpts.dir}/ and decisions.md.\n`,
796
+ );
797
+ }
798
+
799
+ const hints = [...new Set(skills.map((id) => SKILLS[id].nextHint))];
800
+ process.stdout.write(`\n${hints.join('\n')}\n`);
801
+ return 0;
802
+ }
803
+
804
+ const isDirect =
805
+ process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url;
806
+
807
+ if (isDirect) {
808
+ runInstall()
809
+ .then((code) => process.exit(code))
810
+ .catch((err) => {
811
+ if (err?.name === 'ExitPromptError') process.exit(130);
812
+ process.stderr.write(`${err.message || err}\n`);
813
+ process.exit(1);
814
+ });
815
+ }