@izkac/forgekit 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/package.json +49 -49
- package/src/init.mjs +86 -17
- package/src/init.test.mjs +53 -0
- package/src/install.mjs +138 -56
- package/src/install.test.mjs +76 -0
package/README.md
CHANGED
|
@@ -15,17 +15,19 @@ npm i -g @izkac/forgekit
|
|
|
15
15
|
forgekit install
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
Interactive install
|
|
18
|
+
Interactive install uses arrow-key checkboxes — space to toggle, **`a` to select all**, enter to confirm. Pickers come pre-checked with what you already have installed; choosing the full set reconciles (new picks install, deselected ones are removed).
|
|
19
|
+
|
|
20
|
+
Supported environments: Claude Code, Cursor, Codex CLI, GitHub Copilot, Gemini CLI, Windsurf, opencode — each into its global Agent-Skills directory.
|
|
19
21
|
|
|
20
22
|
Non-interactive:
|
|
21
23
|
|
|
22
24
|
```bash
|
|
23
|
-
forgekit install --skills forge,thorough-code-review --agents cursor,claude --force
|
|
25
|
+
forgekit install --skills forge,thorough-code-review --agents cursor,claude,copilot --force
|
|
24
26
|
```
|
|
25
27
|
|
|
26
28
|
## Docs
|
|
27
29
|
|
|
28
|
-
- After install, full Forge reference: `~/.
|
|
30
|
+
- After install, full Forge reference: `~/.claude/skills/forge/docs/forge.md` (or the matching dir for your environment)
|
|
29
31
|
- [How to use Forgekit](https://github.com/izkac/forgekit/blob/main/docs/usage.md)
|
|
30
32
|
- [Repository README](https://github.com/izkac/forgekit#readme)
|
|
31
33
|
|
package/package.json
CHANGED
|
@@ -1,49 +1,49 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@izkac/forgekit",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Forgekit CLIs — forgekit (install), forge (workflow), review (code review)",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"bin": {
|
|
7
|
-
"forgekit": "./bin/forgekit.mjs",
|
|
8
|
-
"forge": "./bin/forge.mjs",
|
|
9
|
-
"review": "./bin/review.mjs"
|
|
10
|
-
},
|
|
11
|
-
"files": [
|
|
12
|
-
"bin",
|
|
13
|
-
"src",
|
|
14
|
-
"vendor",
|
|
15
|
-
"scripts"
|
|
16
|
-
],
|
|
17
|
-
"scripts": {
|
|
18
|
-
"prepack": "node scripts/prepack.mjs",
|
|
19
|
-
"test": "node scripts/run-tests.mjs",
|
|
20
|
-
"lint": "eslint src bin scripts"
|
|
21
|
-
},
|
|
22
|
-
"publishConfig": {
|
|
23
|
-
"access": "public"
|
|
24
|
-
},
|
|
25
|
-
"engines": {
|
|
26
|
-
"node": ">=20"
|
|
27
|
-
},
|
|
28
|
-
"repository": {
|
|
29
|
-
"type": "git",
|
|
30
|
-
"url": "git+https://github.com/izkac/forgekit.git",
|
|
31
|
-
"directory": "packages/cli"
|
|
32
|
-
},
|
|
33
|
-
"homepage": "https://github.com/izkac/forgekit#readme",
|
|
34
|
-
"bugs": {
|
|
35
|
-
"url": "https://github.com/izkac/forgekit/issues"
|
|
36
|
-
},
|
|
37
|
-
"keywords": [
|
|
38
|
-
"forge",
|
|
39
|
-
"openspec",
|
|
40
|
-
"agent-skills",
|
|
41
|
-
"cursor",
|
|
42
|
-
"claude",
|
|
43
|
-
"codex"
|
|
44
|
-
],
|
|
45
|
-
"license": "MIT",
|
|
46
|
-
"dependencies": {
|
|
47
|
-
"@inquirer/prompts": "^8.5.2"
|
|
48
|
-
}
|
|
49
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@izkac/forgekit",
|
|
3
|
+
"version": "0.1.5",
|
|
4
|
+
"description": "Forgekit CLIs — forgekit (install), forge (workflow), review (code review)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"forgekit": "./bin/forgekit.mjs",
|
|
8
|
+
"forge": "./bin/forge.mjs",
|
|
9
|
+
"review": "./bin/review.mjs"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"bin",
|
|
13
|
+
"src",
|
|
14
|
+
"vendor",
|
|
15
|
+
"scripts"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"prepack": "node scripts/prepack.mjs",
|
|
19
|
+
"test": "node scripts/run-tests.mjs",
|
|
20
|
+
"lint": "eslint src bin scripts"
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=20"
|
|
27
|
+
},
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/izkac/forgekit.git",
|
|
31
|
+
"directory": "packages/cli"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/izkac/forgekit#readme",
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/izkac/forgekit/issues"
|
|
36
|
+
},
|
|
37
|
+
"keywords": [
|
|
38
|
+
"forge",
|
|
39
|
+
"openspec",
|
|
40
|
+
"agent-skills",
|
|
41
|
+
"cursor",
|
|
42
|
+
"claude",
|
|
43
|
+
"codex"
|
|
44
|
+
],
|
|
45
|
+
"license": "MIT",
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"@inquirer/prompts": "^8.5.2"
|
|
48
|
+
}
|
|
49
|
+
}
|
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, {
|
|
@@ -389,14 +407,50 @@ export function initProject(selected, opts) {
|
|
|
389
407
|
return report;
|
|
390
408
|
}
|
|
391
409
|
|
|
392
|
-
|
|
410
|
+
/**
|
|
411
|
+
* Environments this project already has Forge wiring for (marker dir present).
|
|
412
|
+
* @param {string} cwd
|
|
413
|
+
* @returns {Set<string>}
|
|
414
|
+
*/
|
|
415
|
+
function wiredAgents(cwd) {
|
|
416
|
+
const markers = {
|
|
417
|
+
cursor: path.join(cwd, '.cursor', 'commands'),
|
|
418
|
+
claude: path.join(cwd, '.claude', 'commands'),
|
|
419
|
+
codex: path.join(cwd, '.codex', 'rules'),
|
|
420
|
+
};
|
|
421
|
+
return new Set(
|
|
422
|
+
Object.entries(markers)
|
|
423
|
+
.filter(([, dir]) => fs.existsSync(dir))
|
|
424
|
+
.map(([id]) => id),
|
|
425
|
+
);
|
|
426
|
+
}
|
|
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
|
+
|
|
444
|
+
/** @param {string} cwd */
|
|
445
|
+
async function promptAgents(cwd) {
|
|
446
|
+
const remembered = rememberedAgents(cwd);
|
|
393
447
|
return checkbox({
|
|
394
448
|
message: 'Init Forge project wiring for which environments?',
|
|
395
|
-
choices:
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
449
|
+
choices: AGENT_IDS.map((id) => ({
|
|
450
|
+
value: id,
|
|
451
|
+
name: AGENTS[id].label,
|
|
452
|
+
checked: remembered.has(id),
|
|
453
|
+
})),
|
|
400
454
|
required: true,
|
|
401
455
|
});
|
|
402
456
|
}
|
|
@@ -466,12 +520,13 @@ async function resolveInitPlanEngine(opts) {
|
|
|
466
520
|
|
|
467
521
|
/**
|
|
468
522
|
* @param {string} [defaultDir]
|
|
523
|
+
* @param {boolean} [defaultEnabled]
|
|
469
524
|
* @returns {Promise<{ enabled: boolean, dir: string }>}
|
|
470
525
|
*/
|
|
471
|
-
async function promptAdrForInit(defaultDir = DEFAULT_ADR_DIR) {
|
|
526
|
+
async function promptAdrForInit(defaultDir = DEFAULT_ADR_DIR, defaultEnabled = true) {
|
|
472
527
|
const enabled = await confirm({
|
|
473
528
|
message: 'Use Architecture Decision Records (ADRs) in this project?',
|
|
474
|
-
default:
|
|
529
|
+
default: defaultEnabled,
|
|
475
530
|
});
|
|
476
531
|
if (!enabled) return { enabled: false, dir: defaultDir };
|
|
477
532
|
const dir = await input({
|
|
@@ -488,15 +543,22 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
488
543
|
return 0;
|
|
489
544
|
}
|
|
490
545
|
|
|
491
|
-
let selected = opts.all ? [
|
|
546
|
+
let selected = opts.all ? [...AGENT_IDS] : [...new Set(opts.agents)];
|
|
492
547
|
if (selected.length === 0) {
|
|
493
548
|
if (!process.stdin.isTTY) {
|
|
494
549
|
process.stderr.write(
|
|
495
|
-
'No agents specified. Pass --cursor/--claude/--codex/--all, or run in a TTY.\n',
|
|
550
|
+
'No agents specified. Pass --cursor/--claude/--codex/--copilot/--gemini/--windsurf/--opencode/--all, or run in a TTY.\n',
|
|
496
551
|
);
|
|
497
552
|
return 1;
|
|
498
553
|
}
|
|
499
|
-
selected = await promptAgents();
|
|
554
|
+
selected = await promptAgents(opts.cwd);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
for (const id of selected) {
|
|
558
|
+
if (!AGENTS[id]) {
|
|
559
|
+
process.stderr.write(`Unknown environment: ${id}. Known: ${AGENT_IDS.join(', ')}\n`);
|
|
560
|
+
return 1;
|
|
561
|
+
}
|
|
500
562
|
}
|
|
501
563
|
|
|
502
564
|
const planEngine = await resolveInitPlanEngine({
|
|
@@ -510,7 +572,8 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
510
572
|
const user = loadUserConfig();
|
|
511
573
|
const defaultDir = user.adr?.dir ?? DEFAULT_ADR_DIR;
|
|
512
574
|
if (process.stdin.isTTY) {
|
|
513
|
-
|
|
575
|
+
// Default Yes, unless the user globally opted out of ADRs.
|
|
576
|
+
const picked = await promptAdrForInit(defaultDir, user.adr?.enabled !== false);
|
|
514
577
|
adr = picked.enabled;
|
|
515
578
|
adrDir = picked.dir;
|
|
516
579
|
} else if (user.adr?.enabled === true) {
|
|
@@ -523,6 +586,12 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
523
586
|
|
|
524
587
|
const report = initProject(selected, { ...opts, adr, adrDir, planEngine });
|
|
525
588
|
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
589
|
+
if (Array.isArray(report.skillOnly) && report.skillOnly.length) {
|
|
590
|
+
const labels = report.skillOnly.map((id) => AGENTS[id].label).join(', ');
|
|
591
|
+
process.stdout.write(
|
|
592
|
+
`\nNo project wiring for: ${labels} — they use the globally installed Forge skill directly (run \`forgekit install\` if not yet installed).\n`,
|
|
593
|
+
);
|
|
594
|
+
}
|
|
526
595
|
process.stdout.write(
|
|
527
596
|
`\nMerge hook snippets into settings if needed, ensure \`forge\` is on PATH, then open the project in your agent.\n`,
|
|
528
597
|
);
|
|
@@ -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
|
@@ -17,7 +17,7 @@ import fs from 'node:fs';
|
|
|
17
17
|
import os from 'node:os';
|
|
18
18
|
import path from 'node:path';
|
|
19
19
|
import { pathToFileURL } from 'node:url';
|
|
20
|
-
import { checkbox,
|
|
20
|
+
import { checkbox, input, select } from '@inquirer/prompts';
|
|
21
21
|
import {
|
|
22
22
|
ADR_SKILLS,
|
|
23
23
|
DEFAULT_ADR_DIR,
|
|
@@ -58,20 +58,42 @@ export const SKILLS = {
|
|
|
58
58
|
|
|
59
59
|
export const SKILL_IDS = Object.freeze(Object.keys(SKILLS));
|
|
60
60
|
|
|
61
|
-
/**
|
|
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
|
+
*/
|
|
62
66
|
export const AGENTS = {
|
|
63
|
-
cursor: {
|
|
64
|
-
label: 'Cursor',
|
|
65
|
-
skillDir: (home, skillId) => path.join(home, '.cursor', 'skills', skillId),
|
|
66
|
-
},
|
|
67
67
|
claude: {
|
|
68
68
|
label: 'Claude Code',
|
|
69
69
|
skillDir: (home, skillId) => path.join(home, '.claude', 'skills', skillId),
|
|
70
70
|
},
|
|
71
|
+
cursor: {
|
|
72
|
+
label: 'Cursor',
|
|
73
|
+
skillDir: (home, skillId) => path.join(home, '.cursor', 'skills', skillId),
|
|
74
|
+
},
|
|
71
75
|
codex: {
|
|
72
76
|
label: 'Codex CLI',
|
|
73
77
|
skillDir: (home, skillId) => path.join(home, '.codex', 'skills', skillId),
|
|
74
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
|
+
},
|
|
75
97
|
};
|
|
76
98
|
|
|
77
99
|
export const AGENT_IDS = Object.freeze(Object.keys(AGENTS));
|
|
@@ -101,6 +123,7 @@ export function parseArgs(argv) {
|
|
|
101
123
|
list: false,
|
|
102
124
|
help: false,
|
|
103
125
|
force: false,
|
|
126
|
+
prune: false,
|
|
104
127
|
update: false,
|
|
105
128
|
uninstall: false,
|
|
106
129
|
/** @type {boolean | null} null = unset (prompt / infer) */
|
|
@@ -127,10 +150,15 @@ export function parseArgs(argv) {
|
|
|
127
150
|
else if (arg === '--update') opts.update = true;
|
|
128
151
|
else if (arg === '--uninstall') opts.uninstall = true;
|
|
129
152
|
else if (arg === '--force' || arg === '-f') opts.force = true;
|
|
153
|
+
else if (arg === '--prune') opts.prune = true;
|
|
130
154
|
else if (arg === '--help' || arg === '-h') opts.help = true;
|
|
131
155
|
else if (arg === '--cursor') opts.agents.push('cursor');
|
|
132
156
|
else if (arg === '--claude' || arg === '--claude-code') opts.agents.push('claude');
|
|
133
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');
|
|
134
162
|
else if (arg === '--adr') opts.adr = true;
|
|
135
163
|
else if (arg === '--no-adr') opts.adr = false;
|
|
136
164
|
else if (arg === '--adr-dir') opts.adrDir = argv[++i];
|
|
@@ -164,8 +192,10 @@ Options:
|
|
|
164
192
|
--agents <ids> Comma list: ${AGENT_IDS.join(', ')}
|
|
165
193
|
--all-skills Install every known skill
|
|
166
194
|
--all-agents Install for every agent environment
|
|
167
|
-
--cursor/--claude/--codex
|
|
195
|
+
--cursor/--claude/--codex/--copilot/--gemini/--windsurf/--opencode
|
|
168
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)
|
|
169
199
|
--openspec Prefer OpenSpec as the planning engine (save user default)
|
|
170
200
|
--no-openspec Prefer the built-in specs engine (save user default)
|
|
171
201
|
--adr Enable ADRs (install ADR skills; save user default)
|
|
@@ -180,9 +210,12 @@ Options:
|
|
|
180
210
|
--force, -f Overwrite existing skill directories
|
|
181
211
|
--help
|
|
182
212
|
|
|
183
|
-
Interactive (TTY) when skills and/or agents are omitted
|
|
184
|
-
|
|
185
|
-
|
|
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.
|
|
186
219
|
|
|
187
220
|
Aliases:
|
|
188
221
|
forge install […] → forgekit install --skills forge […]
|
|
@@ -355,6 +388,54 @@ export function uninstallSkillsFromAgents(skillIds, agentIds, opts = {}) {
|
|
|
355
388
|
return results;
|
|
356
389
|
}
|
|
357
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
|
+
|
|
358
439
|
/**
|
|
359
440
|
* Reinstall skills that are outdated or unversioned for agents that already have them.
|
|
360
441
|
* @param {{ home?: string, skills?: string[], agents?: string[] }} [opts]
|
|
@@ -415,25 +496,32 @@ export function listInstallStatus(opts = {}) {
|
|
|
415
496
|
/**
|
|
416
497
|
* @param {string} message
|
|
417
498
|
* @param {string[]} ids
|
|
499
|
+
* @param {string[]} [checkedIds] pre-selected (remembered from prior install)
|
|
418
500
|
* @returns {Promise<string[]>}
|
|
419
501
|
*/
|
|
420
|
-
async function promptMulti(message, ids) {
|
|
502
|
+
async function promptMulti(message, ids, checkedIds = []) {
|
|
503
|
+
const checked = new Set(checkedIds);
|
|
421
504
|
return checkbox({
|
|
422
505
|
message,
|
|
423
506
|
choices: ids.map((id) => ({
|
|
424
507
|
value: id,
|
|
425
508
|
name: SKILLS[id]?.label ?? AGENTS[id]?.label ?? id,
|
|
509
|
+
checked: checked.has(id),
|
|
426
510
|
})),
|
|
427
511
|
required: true,
|
|
428
512
|
});
|
|
429
513
|
}
|
|
430
514
|
|
|
431
|
-
|
|
432
|
-
|
|
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);
|
|
433
520
|
}
|
|
434
521
|
|
|
435
|
-
|
|
436
|
-
|
|
522
|
+
/** @param {string[]} [checkedIds] */
|
|
523
|
+
async function promptAgents(checkedIds) {
|
|
524
|
+
return promptMulti('Install for which environments?', AGENT_IDS, checkedIds ?? []);
|
|
437
525
|
}
|
|
438
526
|
|
|
439
527
|
/**
|
|
@@ -448,19 +536,6 @@ export async function promptAdrDir(defaultDir = DEFAULT_ADR_DIR) {
|
|
|
448
536
|
return normalizeAdrDir(dir.trim() || defaultDir);
|
|
449
537
|
}
|
|
450
538
|
|
|
451
|
-
/**
|
|
452
|
-
* @returns {Promise<{ enabled: boolean, dir: string }>}
|
|
453
|
-
*/
|
|
454
|
-
export async function promptAdrOptions() {
|
|
455
|
-
const enabled = await confirm({
|
|
456
|
-
message: 'Use Architecture Decision Records (ADRs) after OpenSpec archive?',
|
|
457
|
-
default: false,
|
|
458
|
-
});
|
|
459
|
-
if (!enabled) return { enabled: false, dir: DEFAULT_ADR_DIR };
|
|
460
|
-
const dir = await promptAdrDir(DEFAULT_ADR_DIR);
|
|
461
|
-
return { enabled: true, dir };
|
|
462
|
-
}
|
|
463
|
-
|
|
464
539
|
/**
|
|
465
540
|
* @returns {Promise<boolean>} true = OpenSpec, false = built-in specs engine
|
|
466
541
|
*/
|
|
@@ -504,45 +579,38 @@ export function inferAdrFromSkills(skills, adrFlag) {
|
|
|
504
579
|
}
|
|
505
580
|
|
|
506
581
|
/**
|
|
507
|
-
* Resolve ADR enablement + directory
|
|
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.
|
|
508
584
|
* @param {{ adr: boolean | null, adrDir: string | null, skills: string[] }} opts
|
|
509
585
|
* @returns {Promise<{ enabled: boolean, dir: string }>}
|
|
510
586
|
*/
|
|
511
587
|
export async function resolveAdrInstallOptions(opts) {
|
|
512
|
-
const
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
if (adrDecision === null && process.stdin.isTTY) {
|
|
517
|
-
adrOpts = await promptAdrOptions();
|
|
518
|
-
} else if (adrDecision === true) {
|
|
519
|
-
adrOpts.enabled = true;
|
|
520
|
-
adrOpts.dir = opts.adrDir
|
|
521
|
-
? normalizeAdrDir(opts.adrDir)
|
|
522
|
-
: process.stdin.isTTY
|
|
523
|
-
? await promptAdrDir(DEFAULT_ADR_DIR)
|
|
524
|
-
: DEFAULT_ADR_DIR;
|
|
525
|
-
} else if (adrDecision === false) {
|
|
526
|
-
adrOpts = {
|
|
588
|
+
const enabled = inferAdrFromSkills(opts.skills, opts.adr) === true;
|
|
589
|
+
if (!enabled) {
|
|
590
|
+
return {
|
|
527
591
|
enabled: false,
|
|
528
592
|
dir: opts.adrDir ? normalizeAdrDir(opts.adrDir) : DEFAULT_ADR_DIR,
|
|
529
593
|
};
|
|
530
594
|
}
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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 };
|
|
536
601
|
}
|
|
537
602
|
|
|
538
603
|
/**
|
|
539
|
-
* @param {string[]}
|
|
540
|
-
* @param {string[]}
|
|
541
|
-
* @returns {Promise<{ skills: string[], agents: string[] } | number>}
|
|
604
|
+
* @param {string[]} skillsIn
|
|
605
|
+
* @param {string[]} agentsIn
|
|
606
|
+
* @returns {Promise<{ skills: string[], agents: string[], skillsPrompted: boolean, agentsPrompted: boolean } | number>}
|
|
542
607
|
*/
|
|
543
608
|
async function resolveSkillsAndAgents(skillsIn, agentsIn) {
|
|
544
609
|
let skills = [...skillsIn];
|
|
545
610
|
let agents = [...agentsIn];
|
|
611
|
+
let skillsPrompted = false;
|
|
612
|
+
let agentsPrompted = false;
|
|
613
|
+
const installed = installedManagedPairs();
|
|
546
614
|
|
|
547
615
|
if (skills.length === 0) {
|
|
548
616
|
if (!process.stdin.isTTY) {
|
|
@@ -551,7 +619,8 @@ async function resolveSkillsAndAgents(skillsIn, agentsIn) {
|
|
|
551
619
|
);
|
|
552
620
|
return 1;
|
|
553
621
|
}
|
|
554
|
-
skills = await promptSkills();
|
|
622
|
+
skills = await promptSkills([...new Set(installed.map((p) => p.skill))]);
|
|
623
|
+
skillsPrompted = true;
|
|
555
624
|
}
|
|
556
625
|
|
|
557
626
|
for (const id of skills) {
|
|
@@ -568,7 +637,8 @@ async function resolveSkillsAndAgents(skillsIn, agentsIn) {
|
|
|
568
637
|
);
|
|
569
638
|
return 1;
|
|
570
639
|
}
|
|
571
|
-
agents = await promptAgents();
|
|
640
|
+
agents = await promptAgents([...new Set(installed.map((p) => p.agent))]);
|
|
641
|
+
agentsPrompted = true;
|
|
572
642
|
}
|
|
573
643
|
|
|
574
644
|
for (const id of agents) {
|
|
@@ -578,7 +648,7 @@ async function resolveSkillsAndAgents(skillsIn, agentsIn) {
|
|
|
578
648
|
}
|
|
579
649
|
}
|
|
580
650
|
|
|
581
|
-
return { skills, agents };
|
|
651
|
+
return { skills, agents, skillsPrompted, agentsPrompted };
|
|
582
652
|
}
|
|
583
653
|
|
|
584
654
|
/**
|
|
@@ -628,6 +698,10 @@ export async function runInstall(argv = process.argv.slice(2)) {
|
|
|
628
698
|
if (typeof resolved === 'number') return resolved;
|
|
629
699
|
skills = resolved.skills;
|
|
630
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);
|
|
631
705
|
|
|
632
706
|
if (opts.uninstall) {
|
|
633
707
|
const results = uninstallSkillsFromAgents(skills, agents);
|
|
@@ -656,9 +730,14 @@ export async function runInstall(argv = process.argv.slice(2)) {
|
|
|
656
730
|
|
|
657
731
|
saveUserConfig({
|
|
658
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 } : {}),
|
|
659
736
|
});
|
|
660
737
|
|
|
661
|
-
const results
|
|
738
|
+
const { results, removed } = prune
|
|
739
|
+
? reconcileInstall(skills, agents, { force: true, prune: true })
|
|
740
|
+
: { results: installSkillsToAgents(skills, agents, { force: opts.force }), removed: [] };
|
|
662
741
|
const sources = new Map();
|
|
663
742
|
for (const r of results) {
|
|
664
743
|
if (r.skillSource) sources.set(r.skill, r.skillSource);
|
|
@@ -666,6 +745,9 @@ export async function runInstall(argv = process.argv.slice(2)) {
|
|
|
666
745
|
for (const [skill, src] of sources) {
|
|
667
746
|
process.stdout.write(`Skill ${skill}: ${src}\n`);
|
|
668
747
|
}
|
|
748
|
+
for (const r of removed) {
|
|
749
|
+
process.stdout.write(`${r.skill} × ${r.agent}: removed (deselected) → ${r.dest}\n`);
|
|
750
|
+
}
|
|
669
751
|
for (const r of results) {
|
|
670
752
|
process.stdout.write(
|
|
671
753
|
`${r.skill} × ${r.agent}: ${r.status}${r.message ? ` — ${r.message}` : ''} → ${r.dest}\n`,
|
package/src/install.test.mjs
CHANGED
|
@@ -6,13 +6,17 @@ import path from 'node:path';
|
|
|
6
6
|
import {
|
|
7
7
|
parseArgs,
|
|
8
8
|
installSkillsToAgents,
|
|
9
|
+
reconcileInstall,
|
|
10
|
+
installedManagedPairs,
|
|
9
11
|
listInstallStatus,
|
|
10
12
|
uninstallSkillsFromAgents,
|
|
11
13
|
updateOutdatedSkills,
|
|
12
14
|
readInstallStamp,
|
|
15
|
+
resolveAdrInstallOptions,
|
|
13
16
|
FORGEKIT_STAMP,
|
|
14
17
|
SKILL_IDS,
|
|
15
18
|
AGENT_IDS,
|
|
19
|
+
AGENTS,
|
|
16
20
|
} from './install.mjs';
|
|
17
21
|
|
|
18
22
|
test('parseArgs supports multi skills and agents', () => {
|
|
@@ -89,6 +93,78 @@ test('uninstallSkillsFromAgents removes installed dirs', () => {
|
|
|
89
93
|
}
|
|
90
94
|
});
|
|
91
95
|
|
|
96
|
+
test('expanded environments resolve to their global skills dirs', () => {
|
|
97
|
+
const home = '/home/u';
|
|
98
|
+
assert.ok(AGENT_IDS.includes('copilot'));
|
|
99
|
+
assert.ok(AGENT_IDS.includes('windsurf'));
|
|
100
|
+
assert.equal(
|
|
101
|
+
AGENTS.copilot.skillDir(home, 'forge'),
|
|
102
|
+
path.join(home, '.copilot', 'skills', 'forge'),
|
|
103
|
+
);
|
|
104
|
+
assert.equal(
|
|
105
|
+
AGENTS.windsurf.skillDir(home, 'forge'),
|
|
106
|
+
path.join(home, '.codeium', 'windsurf', 'skills', 'forge'),
|
|
107
|
+
);
|
|
108
|
+
assert.equal(
|
|
109
|
+
AGENTS.opencode.skillDir(home, 'forge'),
|
|
110
|
+
path.join(home, '.config', 'opencode', 'skills', 'forge'),
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('reconcileInstall prunes deselected pairs and remembers installs', () => {
|
|
115
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'forgekit-recon-'));
|
|
116
|
+
try {
|
|
117
|
+
// Start: forge on cursor + claude.
|
|
118
|
+
reconcileInstall(['forge'], ['cursor', 'claude'], { home, prune: true });
|
|
119
|
+
let managed = installedManagedPairs(home);
|
|
120
|
+
assert.equal(managed.length, 2);
|
|
121
|
+
|
|
122
|
+
// Re-select: forge on cursor only → claude pair pruned.
|
|
123
|
+
const { removed } = reconcileInstall(['forge'], ['cursor'], {
|
|
124
|
+
home,
|
|
125
|
+
prune: true,
|
|
126
|
+
});
|
|
127
|
+
assert.equal(removed.length, 1);
|
|
128
|
+
assert.equal(removed[0].agent, 'claude');
|
|
129
|
+
managed = installedManagedPairs(home);
|
|
130
|
+
assert.deepEqual(
|
|
131
|
+
managed.map((p) => `${p.skill}:${p.agent}`),
|
|
132
|
+
['forge:cursor'],
|
|
133
|
+
);
|
|
134
|
+
assert.ok(!fs.existsSync(path.join(home, '.claude', 'skills', 'forge')));
|
|
135
|
+
} finally {
|
|
136
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('reconcileInstall without prune is additive (no removals)', () => {
|
|
141
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'forgekit-recon2-'));
|
|
142
|
+
try {
|
|
143
|
+
reconcileInstall(['forge'], ['cursor', 'claude'], { home, prune: true });
|
|
144
|
+
const { removed } = reconcileInstall(['forge'], ['cursor'], { home });
|
|
145
|
+
assert.equal(removed.length, 0);
|
|
146
|
+
assert.equal(installedManagedPairs(home).length, 2);
|
|
147
|
+
} finally {
|
|
148
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('resolveAdrInstallOptions: no ADR skill selected → disabled, no path prompt', async () => {
|
|
153
|
+
const off = await resolveAdrInstallOptions({
|
|
154
|
+
adr: null,
|
|
155
|
+
adrDir: null,
|
|
156
|
+
skills: ['forge'],
|
|
157
|
+
});
|
|
158
|
+
assert.equal(off.enabled, false);
|
|
159
|
+
const on = await resolveAdrInstallOptions({
|
|
160
|
+
adr: null,
|
|
161
|
+
adrDir: 'docs/decisions',
|
|
162
|
+
skills: ['forge', 'archive-to-adr'],
|
|
163
|
+
});
|
|
164
|
+
assert.equal(on.enabled, true);
|
|
165
|
+
assert.equal(on.dir, 'docs/decisions');
|
|
166
|
+
});
|
|
167
|
+
|
|
92
168
|
test('updateOutdatedSkills refreshes unversioned installs', () => {
|
|
93
169
|
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'forgekit-upd-'));
|
|
94
170
|
try {
|