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