@izkac/forgekit 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/init.mjs +70 -19
- package/src/init.test.mjs +53 -0
- package/src/install.mjs +3 -0
- package/src/plan-engine.mjs +22 -4
- package/src/plan-engine.test.mjs +38 -0
package/package.json
CHANGED
package/src/init.mjs
CHANGED
|
@@ -30,6 +30,11 @@ import {
|
|
|
30
30
|
writeProjectPlanConfig,
|
|
31
31
|
} from './plan-engine.mjs';
|
|
32
32
|
import { resolveAsset } from './paths.mjs';
|
|
33
|
+
import { AGENT_IDS, AGENTS, installedManagedPairs } from './install.mjs';
|
|
34
|
+
|
|
35
|
+
// Environments with project-local command/rule/hook templates. Others are
|
|
36
|
+
// driven by the globally-installed skill alone (no per-project wiring).
|
|
37
|
+
const WIRED_AGENTS = Object.freeze(['cursor', 'claude', 'codex']);
|
|
33
38
|
|
|
34
39
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
35
40
|
|
|
@@ -61,6 +66,10 @@ export function parseArgs(argv) {
|
|
|
61
66
|
else if (arg === '--cursor') opts.agents.push('cursor');
|
|
62
67
|
else if (arg === '--claude' || arg === '--claude-code') opts.agents.push('claude');
|
|
63
68
|
else if (arg === '--codex') opts.agents.push('codex');
|
|
69
|
+
else if (arg === '--copilot') opts.agents.push('copilot');
|
|
70
|
+
else if (arg === '--gemini') opts.agents.push('gemini');
|
|
71
|
+
else if (arg === '--windsurf') opts.agents.push('windsurf');
|
|
72
|
+
else if (arg === '--opencode') opts.agents.push('opencode');
|
|
64
73
|
else if (arg === '--adr') opts.adr = true;
|
|
65
74
|
else if (arg === '--no-adr') opts.adr = false;
|
|
66
75
|
else if (arg === '--adr-dir') opts.adrDir = argv[++i];
|
|
@@ -81,7 +90,10 @@ Options:
|
|
|
81
90
|
--cursor Cursor (.cursor/commands, rules, hooks)
|
|
82
91
|
--claude Claude Code (.claude/commands, rules, hooks)
|
|
83
92
|
--codex Codex CLI (.codex/rules)
|
|
84
|
-
--
|
|
93
|
+
--copilot/--gemini/--windsurf/--opencode
|
|
94
|
+
Offered in the picker for parity with \`forgekit install\`;
|
|
95
|
+
driven by the global skill (no per-project wiring yet)
|
|
96
|
+
--all Every offered environment
|
|
85
97
|
--openspec Plan with OpenSpec (offer install + \`openspec init\` if missing)
|
|
86
98
|
--no-openspec Plan with the built-in specs engine (${DEFAULT_SPECS_DIR}/changes/)
|
|
87
99
|
--adr Enable ADRs (scaffold decisions.md + ADR dir + hooks)
|
|
@@ -95,10 +107,12 @@ Options:
|
|
|
95
107
|
Requires the Forge skill already installed (\`forge install\`) for agents
|
|
96
108
|
to load skill content. Init only adds project-local wiring.
|
|
97
109
|
|
|
98
|
-
Interactive (TTY):
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
110
|
+
Interactive (TTY): the environment picker matches \`forgekit install\` and is
|
|
111
|
+
pre-checked with what you installed there (saved in ~/.forgekit/config.json),
|
|
112
|
+
so you don't pick twice. When --openspec/--no-openspec omitted and OpenSpec is
|
|
113
|
+
not already set up, offers to install + set it up (decline = built-in specs
|
|
114
|
+
engine). When --adr/--no-adr omitted, asks whether to use ADRs (default Yes)
|
|
115
|
+
and for the directory inside the repo.
|
|
102
116
|
`);
|
|
103
117
|
}
|
|
104
118
|
|
|
@@ -343,6 +357,10 @@ export function initProject(selected, opts) {
|
|
|
343
357
|
);
|
|
344
358
|
}
|
|
345
359
|
|
|
360
|
+
// Selected environments without project-wiring templates: the globally
|
|
361
|
+
// installed skill is their interface — nothing to scaffold per project.
|
|
362
|
+
report.skillOnly = selected.filter((id) => !WIRED_AGENTS.includes(id));
|
|
363
|
+
|
|
346
364
|
if (opts.planEngine === 'specs') {
|
|
347
365
|
const scaffold = scaffoldSpecs(cwd, { force: opts.force });
|
|
348
366
|
const config = writeProjectPlanConfig(cwd, {
|
|
@@ -407,16 +425,32 @@ function wiredAgents(cwd) {
|
|
|
407
425
|
);
|
|
408
426
|
}
|
|
409
427
|
|
|
428
|
+
/**
|
|
429
|
+
* Environments to pre-check: those chosen during `forgekit install`
|
|
430
|
+
* (saved in ~/.forgekit/config.json), plus what is already installed or wired.
|
|
431
|
+
* @param {string} cwd
|
|
432
|
+
* @param {string} [home]
|
|
433
|
+
* @returns {Set<string>}
|
|
434
|
+
*/
|
|
435
|
+
export function rememberedAgents(cwd, home) {
|
|
436
|
+
const user = loadUserConfig(home);
|
|
437
|
+
return new Set([
|
|
438
|
+
...(Array.isArray(user.agents) ? user.agents : []),
|
|
439
|
+
...installedManagedPairs(home).map((p) => p.agent),
|
|
440
|
+
...wiredAgents(cwd),
|
|
441
|
+
]);
|
|
442
|
+
}
|
|
443
|
+
|
|
410
444
|
/** @param {string} cwd */
|
|
411
445
|
async function promptAgents(cwd) {
|
|
412
|
-
const
|
|
446
|
+
const remembered = rememberedAgents(cwd);
|
|
413
447
|
return checkbox({
|
|
414
448
|
message: 'Init Forge project wiring for which environments?',
|
|
415
|
-
choices:
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
449
|
+
choices: AGENT_IDS.map((id) => ({
|
|
450
|
+
value: id,
|
|
451
|
+
name: AGENTS[id].label,
|
|
452
|
+
checked: remembered.has(id),
|
|
453
|
+
})),
|
|
420
454
|
required: true,
|
|
421
455
|
});
|
|
422
456
|
}
|
|
@@ -435,17 +469,18 @@ async function promptOpenSpecSetup() {
|
|
|
435
469
|
|
|
436
470
|
/**
|
|
437
471
|
* Resolve the planning engine for `forge init`, offering OpenSpec setup when needed.
|
|
438
|
-
* @param {{ cwd: string, openspec: boolean | null }} opts
|
|
472
|
+
* @param {{ cwd: string, openspec: boolean | null, agents?: string[] }} opts
|
|
439
473
|
* @returns {Promise<string>} 'openspec' | 'specs'
|
|
440
474
|
*/
|
|
441
475
|
async function resolveInitPlanEngine(opts) {
|
|
442
476
|
const configured = hasOpenSpecConfig(opts.cwd);
|
|
477
|
+
const tools = opts.agents;
|
|
443
478
|
|
|
444
479
|
if (opts.openspec === false) return 'specs';
|
|
445
480
|
|
|
446
481
|
if (opts.openspec === true) {
|
|
447
482
|
if (!configured && process.stdin.isTTY) {
|
|
448
|
-
const setup = setupOpenSpec(opts.cwd);
|
|
483
|
+
const setup = setupOpenSpec(opts.cwd, { tools });
|
|
449
484
|
for (const s of setup.steps) {
|
|
450
485
|
process.stdout.write(` [${s.ok ? 'ok' : 'FAIL'}] ${s.step}${s.detail ? ` — ${s.detail}` : ''}\n`);
|
|
451
486
|
}
|
|
@@ -471,7 +506,7 @@ async function resolveInitPlanEngine(opts) {
|
|
|
471
506
|
const accepted = await promptOpenSpecSetup();
|
|
472
507
|
if (!accepted) return 'specs';
|
|
473
508
|
|
|
474
|
-
const setup = setupOpenSpec(opts.cwd);
|
|
509
|
+
const setup = setupOpenSpec(opts.cwd, { tools });
|
|
475
510
|
for (const s of setup.steps) {
|
|
476
511
|
process.stdout.write(` [${s.ok ? 'ok' : 'FAIL'}] ${s.step}${s.detail ? ` — ${s.detail}` : ''}\n`);
|
|
477
512
|
}
|
|
@@ -486,12 +521,13 @@ async function resolveInitPlanEngine(opts) {
|
|
|
486
521
|
|
|
487
522
|
/**
|
|
488
523
|
* @param {string} [defaultDir]
|
|
524
|
+
* @param {boolean} [defaultEnabled]
|
|
489
525
|
* @returns {Promise<{ enabled: boolean, dir: string }>}
|
|
490
526
|
*/
|
|
491
|
-
async function promptAdrForInit(defaultDir = DEFAULT_ADR_DIR) {
|
|
527
|
+
async function promptAdrForInit(defaultDir = DEFAULT_ADR_DIR, defaultEnabled = true) {
|
|
492
528
|
const enabled = await confirm({
|
|
493
529
|
message: 'Use Architecture Decision Records (ADRs) in this project?',
|
|
494
|
-
default:
|
|
530
|
+
default: defaultEnabled,
|
|
495
531
|
});
|
|
496
532
|
if (!enabled) return { enabled: false, dir: defaultDir };
|
|
497
533
|
const dir = await input({
|
|
@@ -508,20 +544,28 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
508
544
|
return 0;
|
|
509
545
|
}
|
|
510
546
|
|
|
511
|
-
let selected = opts.all ? [
|
|
547
|
+
let selected = opts.all ? [...AGENT_IDS] : [...new Set(opts.agents)];
|
|
512
548
|
if (selected.length === 0) {
|
|
513
549
|
if (!process.stdin.isTTY) {
|
|
514
550
|
process.stderr.write(
|
|
515
|
-
'No agents specified. Pass --cursor/--claude/--codex/--all, or run in a TTY.\n',
|
|
551
|
+
'No agents specified. Pass --cursor/--claude/--codex/--copilot/--gemini/--windsurf/--opencode/--all, or run in a TTY.\n',
|
|
516
552
|
);
|
|
517
553
|
return 1;
|
|
518
554
|
}
|
|
519
555
|
selected = await promptAgents(opts.cwd);
|
|
520
556
|
}
|
|
521
557
|
|
|
558
|
+
for (const id of selected) {
|
|
559
|
+
if (!AGENTS[id]) {
|
|
560
|
+
process.stderr.write(`Unknown environment: ${id}. Known: ${AGENT_IDS.join(', ')}\n`);
|
|
561
|
+
return 1;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
522
565
|
const planEngine = await resolveInitPlanEngine({
|
|
523
566
|
cwd: opts.cwd,
|
|
524
567
|
openspec: opts.openspec,
|
|
568
|
+
agents: selected,
|
|
525
569
|
});
|
|
526
570
|
|
|
527
571
|
let adr = opts.adr;
|
|
@@ -530,7 +574,8 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
530
574
|
const user = loadUserConfig();
|
|
531
575
|
const defaultDir = user.adr?.dir ?? DEFAULT_ADR_DIR;
|
|
532
576
|
if (process.stdin.isTTY) {
|
|
533
|
-
|
|
577
|
+
// Default Yes, unless the user globally opted out of ADRs.
|
|
578
|
+
const picked = await promptAdrForInit(defaultDir, user.adr?.enabled !== false);
|
|
534
579
|
adr = picked.enabled;
|
|
535
580
|
adrDir = picked.dir;
|
|
536
581
|
} else if (user.adr?.enabled === true) {
|
|
@@ -543,6 +588,12 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
543
588
|
|
|
544
589
|
const report = initProject(selected, { ...opts, adr, adrDir, planEngine });
|
|
545
590
|
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
591
|
+
if (Array.isArray(report.skillOnly) && report.skillOnly.length) {
|
|
592
|
+
const labels = report.skillOnly.map((id) => AGENTS[id].label).join(', ');
|
|
593
|
+
process.stdout.write(
|
|
594
|
+
`\nNo project wiring for: ${labels} — they use the globally installed Forge skill directly (run \`forgekit install\` if not yet installed).\n`,
|
|
595
|
+
);
|
|
596
|
+
}
|
|
546
597
|
process.stdout.write(
|
|
547
598
|
`\nMerge hook snippets into settings if needed, ensure \`forge\` is on PATH, then open the project in your agent.\n`,
|
|
548
599
|
);
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { parseArgs, initProject, rememberedAgents } from './init.mjs';
|
|
7
|
+
import { installSkillsToAgents } from './install.mjs';
|
|
8
|
+
import { saveUserConfig } from './config.mjs';
|
|
9
|
+
|
|
10
|
+
test('init parseArgs accepts the expanded environment shorthands', () => {
|
|
11
|
+
const opts = parseArgs(['--cursor', '--copilot', '--gemini', '--windsurf', '--opencode']);
|
|
12
|
+
assert.deepEqual(opts.agents, ['cursor', 'copilot', 'gemini', 'windsurf', 'opencode']);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('rememberedAgents unions install config, installed skills, and project wiring', () => {
|
|
16
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'forgekit-home-'));
|
|
17
|
+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'forgekit-proj-'));
|
|
18
|
+
try {
|
|
19
|
+
// Chosen during `forgekit install` (saved to user config).
|
|
20
|
+
saveUserConfig({ agents: ['claude', 'gemini'] }, home);
|
|
21
|
+
// Actually installed skill for another env.
|
|
22
|
+
installSkillsToAgents(['forge'], ['copilot'], { home, force: true });
|
|
23
|
+
// Project already wired for cursor.
|
|
24
|
+
fs.mkdirSync(path.join(cwd, '.cursor', 'commands'), { recursive: true });
|
|
25
|
+
|
|
26
|
+
const remembered = rememberedAgents(cwd, home);
|
|
27
|
+
assert.ok(remembered.has('claude'), 'from install config');
|
|
28
|
+
assert.ok(remembered.has('gemini'), 'from install config');
|
|
29
|
+
assert.ok(remembered.has('copilot'), 'from installed skill dir');
|
|
30
|
+
assert.ok(remembered.has('cursor'), 'from project wiring marker');
|
|
31
|
+
assert.ok(!remembered.has('codex'));
|
|
32
|
+
} finally {
|
|
33
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
34
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('initProject wires templated envs and marks the rest skill-only', () => {
|
|
39
|
+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'forgekit-init-'));
|
|
40
|
+
try {
|
|
41
|
+
const report = initProject(['cursor', 'copilot', 'gemini'], {
|
|
42
|
+
cwd,
|
|
43
|
+
force: true,
|
|
44
|
+
adr: false,
|
|
45
|
+
planEngine: null,
|
|
46
|
+
});
|
|
47
|
+
assert.ok(report.files.some((f) => f.file.includes('.cursor')));
|
|
48
|
+
assert.deepEqual(report.skillOnly, ['copilot', 'gemini']);
|
|
49
|
+
assert.ok(!fs.existsSync(path.join(cwd, '.copilot')));
|
|
50
|
+
} finally {
|
|
51
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
52
|
+
}
|
|
53
|
+
});
|
package/src/install.mjs
CHANGED
|
@@ -730,6 +730,9 @@ export async function runInstall(argv = process.argv.slice(2)) {
|
|
|
730
730
|
|
|
731
731
|
saveUserConfig({
|
|
732
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 } : {}),
|
|
733
736
|
});
|
|
734
737
|
|
|
735
738
|
const { results, removed } = prune
|
package/src/plan-engine.mjs
CHANGED
|
@@ -204,12 +204,28 @@ export function checkOpenSpecCliQuick(runCommand) {
|
|
|
204
204
|
return { ok: false, version: null };
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
/**
|
|
208
|
+
* Map forgekit environment ids → OpenSpec tool ids (mostly identity).
|
|
209
|
+
* @type {Record<string, string>}
|
|
210
|
+
*/
|
|
211
|
+
const OPENSPEC_TOOL_ID = { copilot: 'github-copilot' };
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* @param {string[]} agentIds forgekit environment ids
|
|
215
|
+
* @returns {string[]} OpenSpec tool ids
|
|
216
|
+
*/
|
|
217
|
+
export function toOpenSpecToolIds(agentIds) {
|
|
218
|
+
return agentIds.map((id) => OPENSPEC_TOOL_ID[id] ?? id);
|
|
219
|
+
}
|
|
220
|
+
|
|
207
221
|
/**
|
|
208
222
|
* Install the OpenSpec CLI (if missing) and run `openspec init` in the project.
|
|
209
|
-
* Interactive: inherits stdio so `openspec init` can prompt
|
|
223
|
+
* Interactive: inherits stdio so `openspec init` can prompt — unless `tools`
|
|
224
|
+
* is given, in which case those tools are configured non-interactively
|
|
225
|
+
* (`openspec init --tools <ids>`), skipping OpenSpec's own tool picker.
|
|
210
226
|
*
|
|
211
227
|
* @param {string} cwd
|
|
212
|
-
* @param {{ runCommand?: Function, interactive?: boolean }} [opts]
|
|
228
|
+
* @param {{ runCommand?: Function, interactive?: boolean, tools?: string[] }} [opts]
|
|
213
229
|
* @returns {{ ok: boolean, steps: { step: string, ok: boolean, detail?: string }[] }}
|
|
214
230
|
*/
|
|
215
231
|
export function setupOpenSpec(cwd, opts = {}) {
|
|
@@ -249,10 +265,12 @@ export function setupOpenSpec(cwd, opts = {}) {
|
|
|
249
265
|
return { ok: true, steps };
|
|
250
266
|
}
|
|
251
267
|
|
|
252
|
-
const
|
|
268
|
+
const tools = opts.tools?.length ? toOpenSpecToolIds(opts.tools) : null;
|
|
269
|
+
const initArgs = tools ? ['init', '--tools', tools.join(',')] : ['init'];
|
|
270
|
+
const init = runInherit('openspec', initArgs);
|
|
253
271
|
const initOk = init.status === 0;
|
|
254
272
|
steps.push({
|
|
255
|
-
step:
|
|
273
|
+
step: `openspec ${initArgs.join(' ')}`,
|
|
256
274
|
ok: initOk,
|
|
257
275
|
detail: initOk ? undefined : String(init.error ?? `exit ${init.status}`),
|
|
258
276
|
});
|
package/src/plan-engine.test.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
saveUserPlanEngine,
|
|
13
13
|
scaffoldSpecs,
|
|
14
14
|
setupOpenSpec,
|
|
15
|
+
toOpenSpecToolIds,
|
|
15
16
|
writeProjectPlanConfig,
|
|
16
17
|
} from './plan-engine.mjs';
|
|
17
18
|
import { loadProjectConfig, loadUserConfig, saveUserConfig } from './adr.mjs';
|
|
@@ -148,6 +149,43 @@ test('setupOpenSpec runs install + init via injected runner', () => {
|
|
|
148
149
|
}
|
|
149
150
|
});
|
|
150
151
|
|
|
152
|
+
test('toOpenSpecToolIds maps copilot → github-copilot, else identity', () => {
|
|
153
|
+
assert.deepEqual(
|
|
154
|
+
toOpenSpecToolIds(['claude', 'cursor', 'codex', 'opencode', 'copilot', 'gemini', 'windsurf']),
|
|
155
|
+
['claude', 'cursor', 'codex', 'opencode', 'github-copilot', 'gemini', 'windsurf'],
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('setupOpenSpec passes selected tools to `openspec init --tools`', () => {
|
|
160
|
+
const cwd = tmpdir('forgekit-openspec-tools-');
|
|
161
|
+
try {
|
|
162
|
+
/** @type {string[]} */
|
|
163
|
+
const calls = [];
|
|
164
|
+
const runCommand = (cmd, args) => {
|
|
165
|
+
const line = [cmd, ...args].join(' ');
|
|
166
|
+
calls.push(line);
|
|
167
|
+
if (line === 'openspec --version') return { status: 0, stdout: '1.0.0' };
|
|
168
|
+
if (line.startsWith('openspec init')) {
|
|
169
|
+
fs.mkdirSync(path.join(cwd, 'openspec'), { recursive: true });
|
|
170
|
+
fs.writeFileSync(path.join(cwd, 'openspec', 'config.yaml'), 'x: 1\n', 'utf8');
|
|
171
|
+
return { status: 0, stdout: '' };
|
|
172
|
+
}
|
|
173
|
+
return { status: 1, stdout: '' };
|
|
174
|
+
};
|
|
175
|
+
const result = setupOpenSpec(cwd, {
|
|
176
|
+
runCommand,
|
|
177
|
+
tools: ['claude', 'cursor', 'codex', 'copilot'],
|
|
178
|
+
});
|
|
179
|
+
assert.equal(result.ok, true);
|
|
180
|
+
assert.ok(
|
|
181
|
+
calls.includes('openspec init --tools claude,cursor,codex,github-copilot'),
|
|
182
|
+
`expected --tools call, got: ${calls.join(' | ')}`,
|
|
183
|
+
);
|
|
184
|
+
} finally {
|
|
185
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
151
189
|
test('setupOpenSpec reports failure when install fails', () => {
|
|
152
190
|
const cwd = tmpdir('forgekit-openspec-fail-');
|
|
153
191
|
try {
|