@opengsd/gsd-core 1.6.1 → 1.7.0-rc.2
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/.claude-plugin/marketplace.json +20 -0
- package/.claude-plugin/plugin.json +1 -1
- package/.opencode/plugins/gsd-core.js +711 -0
- package/agents/gsd-advisor-researcher.md +2 -0
- package/agents/gsd-ai-researcher.md +1 -1
- package/agents/gsd-assumptions-analyzer.md +2 -0
- package/agents/gsd-code-fixer.md +2 -0
- package/agents/gsd-code-reviewer.md +2 -0
- package/agents/gsd-codebase-mapper.md +2 -0
- package/agents/gsd-debugger.md +2 -0
- package/agents/gsd-doc-writer.md +2 -0
- package/agents/gsd-eval-auditor.md +2 -0
- package/agents/gsd-executor.md +9 -6
- package/agents/gsd-integration-checker.md +2 -0
- package/agents/gsd-nyquist-auditor.md +2 -0
- package/agents/gsd-phase-researcher.md +2 -0
- package/agents/gsd-plan-checker.md +2 -0
- package/agents/gsd-planner.md +2 -0
- package/agents/gsd-project-researcher.md +2 -0
- package/agents/gsd-research-synthesizer.md +2 -0
- package/agents/gsd-roadmapper.md +2 -0
- package/agents/gsd-security-auditor.md +2 -0
- package/agents/gsd-ui-auditor.md +2 -0
- package/agents/gsd-ui-checker.md +2 -0
- package/agents/gsd-ui-researcher.md +2 -0
- package/agents/gsd-verifier.md +5 -2
- package/bin/gsd-mcp-server.js +31 -0
- package/bin/install.js +411 -1146
- package/commands/gsd/review.md +6 -0
- package/gemini-extension.json +1 -1
- package/gsd-core/bin/gsd-tools.cjs +134 -8
- package/gsd-core/bin/lib/adapter-declarative.cjs +35 -0
- package/gsd-core/bin/lib/adapter-imperative.cjs +52 -0
- package/gsd-core/bin/lib/assumption-delta.cjs +231 -0
- package/gsd-core/bin/lib/capability-lifecycle.cjs +7 -7
- package/gsd-core/bin/lib/capability-loader.cjs +45 -9
- package/gsd-core/bin/lib/capability-lock.cjs +2 -2
- package/gsd-core/bin/lib/capability-registry.cjs +891 -82
- package/gsd-core/bin/lib/capability-source.cjs +26 -11
- package/gsd-core/bin/lib/capability-validator.cjs +222 -2
- package/gsd-core/bin/lib/cli-skew-check.cjs +44 -0
- package/gsd-core/bin/lib/command-aliases.cjs +8 -0
- package/gsd-core/bin/lib/commands.cjs +2 -1
- package/gsd-core/bin/lib/config.cjs +27 -0
- package/gsd-core/bin/lib/embedding-adapter.cjs +27 -0
- package/gsd-core/bin/lib/external-descriptor-trust.cjs +70 -0
- package/gsd-core/bin/lib/frontmatter.cjs +53 -6
- package/gsd-core/bin/lib/handshake-serialized.cjs +70 -0
- package/gsd-core/bin/lib/hook-bus.cjs +81 -0
- package/gsd-core/bin/lib/host-integration-sdk.cjs +53 -0
- package/gsd-core/bin/lib/host-integration.cjs +469 -0
- package/gsd-core/bin/lib/init.cjs +35 -7
- package/gsd-core/bin/lib/install-engine.cjs +755 -0
- package/gsd-core/bin/lib/install-profiles.cjs +35 -4
- package/gsd-core/bin/lib/installer-migrations.cjs +1 -1
- package/gsd-core/bin/lib/mcp-server.cjs +194 -0
- package/gsd-core/bin/lib/milestone.cjs +68 -40
- package/gsd-core/bin/lib/model-adapter.cjs +50 -0
- package/gsd-core/bin/lib/phase-id.cjs +18 -0
- package/gsd-core/bin/lib/phase.cjs +57 -90
- package/gsd-core/bin/lib/phases-command-router.cjs +4 -3
- package/gsd-core/bin/lib/planning-workspace.cjs +1 -1
- package/gsd-core/bin/lib/probe-core.cjs +132 -2
- package/gsd-core/bin/lib/review-reviewer-selection.cjs +129 -13
- package/gsd-core/bin/lib/roadmap-command-router.cjs +3 -2
- package/gsd-core/bin/lib/roadmap-parser.cjs +21 -11
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +3 -2
- package/gsd-core/bin/lib/roadmap.cjs +33 -22
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +65 -9
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +54 -4
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +5 -2
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +1 -1
- package/gsd-core/bin/lib/runtime-name-policy.cjs +160 -30
- package/gsd-core/bin/lib/shell-command-projection.cjs +16 -0
- package/gsd-core/bin/lib/stale-bake-guard.cjs +254 -0
- package/gsd-core/bin/lib/state-command-router.cjs +4 -0
- package/gsd-core/bin/lib/state-io.cjs +55 -0
- package/gsd-core/bin/lib/state-transition.cjs +1603 -0
- package/gsd-core/bin/lib/state.cjs +327 -683
- package/gsd-core/bin/lib/surface.cjs +4 -1
- package/gsd-core/bin/lib/validate.cjs +2 -1
- package/gsd-core/bin/lib/verify.cjs +6 -4
- package/gsd-core/bin/lib/workstream-inventory-builder.cjs +12 -2
- package/gsd-core/bin/lib/workstream-inventory.cjs +28 -0
- package/gsd-core/bin/lib/workstream.cjs +4 -4
- package/gsd-core/bin/shared/config-schema.manifest.json +9 -0
- package/gsd-core/references/agent-skills-bootstrap.md +60 -0
- package/gsd-core/references/honest-verifier.md +105 -0
- package/gsd-core/references/model-profiles.md +27 -0
- package/gsd-core/references/reviewer-instances.md +99 -0
- package/gsd-core/workflows/autonomous.md +30 -32
- package/gsd-core/workflows/complete-milestone.md +6 -10
- package/gsd-core/workflows/execute-phase.md +1 -1
- package/gsd-core/workflows/forensics.md +3 -3
- package/gsd-core/workflows/help/modes/full.md +1 -1
- package/gsd-core/workflows/manager.md +15 -15
- package/gsd-core/workflows/milestone-summary.md +3 -3
- package/gsd-core/workflows/new-milestone.md +6 -0
- package/gsd-core/workflows/plan-phase/steps/closed-phase-gate.md +42 -0
- package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +102 -0
- package/gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md +23 -0
- package/gsd-core/workflows/plan-phase.md +4 -159
- package/gsd-core/workflows/review.md +33 -2
- package/gsd-core/workflows/thread.md +4 -4
- package/gsd-core/workflows/verify-phase.md +11 -4
- package/gsd-core/workflows/verify-work.md +1 -2
- package/hooks/dist/gsd-graphify-update.sh +7 -1
- package/hooks/gsd-graphify-update.sh +7 -1
- package/package.json +6 -4
- package/scripts/ci-test-scope.cjs +38 -9
- package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
- package/scripts/lint-regression-test-names.allowlist.json +3 -0
- package/scripts/lint-test-file-count.allowlist.json +19 -5
- package/scripts/mutation-matrix.cjs +45 -3
- package/scripts/prompt-injection-scan.sh +8 -0
- package/scripts/run-tests.cjs +51 -1
- package/scripts/sync-manifest-versions.cjs +66 -14
- package/skills/gsd-review/SKILL.md +6 -0
- package/scripts/lint-windows-test-portability.cjs +0 -178
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any,
|
|
2
|
+
@typescript-eslint/no-unsafe-assignment,
|
|
3
|
+
@typescript-eslint/no-unsafe-member-access,
|
|
4
|
+
@typescript-eslint/no-unsafe-return,
|
|
5
|
+
@typescript-eslint/no-unsafe-call,
|
|
6
|
+
@typescript-eslint/no-unsafe-argument,
|
|
7
|
+
@typescript-eslint/no-require-imports */
|
|
8
|
+
// Mechanical extraction from bin/install.js; keep behavior parity before typing.
|
|
9
|
+
'use strict';
|
|
10
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
11
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Install Engine Module — ADR-1239 Phase B.
|
|
15
|
+
*
|
|
16
|
+
* Runtime-artifact install/uninstall cluster extracted from bin/install.js.
|
|
17
|
+
* bin/install.js imports this module for the layout-driven install/uninstall
|
|
18
|
+
* orchestrators and their private helpers. getCommitAttribution STAYS in
|
|
19
|
+
* bin/install.js (impure install-time config I/O); it is injected via the
|
|
20
|
+
* `resolveAttribution` parameter at each call site.
|
|
21
|
+
*/
|
|
22
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
23
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
24
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
25
|
+
const runtimeArtifactConversion = require("./runtime-artifact-conversion.cjs");
|
|
26
|
+
const runtimeArtifactLayout = require("./runtime-artifact-layout.cjs");
|
|
27
|
+
const runtimeArtifactInstallPlan = require("./runtime-artifact-install-plan.cjs");
|
|
28
|
+
const runtimeNamePolicy = require("./runtime-name-policy.cjs");
|
|
29
|
+
const { processAttribution } = runtimeArtifactConversion;
|
|
30
|
+
// resolveRuntimeArtifactLayout: accessed via module ref (not destructured) so
|
|
31
|
+
// test stubs that monkeypatch the module's exports are seen at call time.
|
|
32
|
+
const { getDirName } = runtimeNamePolicy;
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// USER_OWNED_ARTIFACTS
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
/**
|
|
37
|
+
* Single source of truth for user-owned artifacts inside gsd-core/.
|
|
38
|
+
*
|
|
39
|
+
* These files are created/refreshed by user-facing workflows (e.g.
|
|
40
|
+
* /gsd-profile-user) and must be preserved across reinstalls. Critically, they
|
|
41
|
+
* MUST be excluded from gsd-file-manifest.json — otherwise saveLocalPatches()
|
|
42
|
+
* will compare a refreshed file against a stale manifest hash and emit a
|
|
43
|
+
* spurious "locally modified GSD file" warning (bug #2771).
|
|
44
|
+
*
|
|
45
|
+
* Invariant: a file is either distribution (manifest-tracked, diff'd against
|
|
46
|
+
* manifest) or user artifact (preserved across installs, never diff'd). Never
|
|
47
|
+
* both. Both preserveUserArtifacts call sites and writeManifest must agree on
|
|
48
|
+
* this list, which is why it lives here as a single constant.
|
|
49
|
+
*
|
|
50
|
+
* Paths are relative to the gsd-core/ directory.
|
|
51
|
+
*/
|
|
52
|
+
const USER_OWNED_ARTIFACTS = ['USER-PROFILE.md'];
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Conversion helpers
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
/**
|
|
57
|
+
* Apply per-runtime path-prefix rewrites for OpenCode-family skill bodies.
|
|
58
|
+
* Replaces ~/.claude/, $HOME/.claude/, ./.claude/ and OpenCode-variant paths
|
|
59
|
+
* with the computed pathPrefix for the install.
|
|
60
|
+
*/
|
|
61
|
+
function applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix) {
|
|
62
|
+
content = content.replace(/~\/\.claude\//g, pathPrefix);
|
|
63
|
+
content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
|
|
64
|
+
content = content.replace(/\.\/\.claude\//g, `./${getDirName(runtime)}/`);
|
|
65
|
+
content = content.replace(/~\/\.opencode\//g, pathPrefix);
|
|
66
|
+
content = content.replace(/~\/\.kilo\//g, pathPrefix);
|
|
67
|
+
return content;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Convert a Claude command (.md) to an OpenCode skill (SKILL.md).
|
|
71
|
+
* The canonical OpenCode-family writer lives in runtime-artifact-conversion.cjs
|
|
72
|
+
* (single source of truth — avoids a duplicate writer drifting per
|
|
73
|
+
* DEFECT.GENERATIVE-FIX); this thin wrapper delegates to it.
|
|
74
|
+
*/
|
|
75
|
+
function convertClaudeCommandToOpencodeSkill(content, skillName) {
|
|
76
|
+
return runtimeArtifactConversion.convertClaudeCommandToOpencodeSkill(content, skillName);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Convert a Claude command (.md) to a Kilo skill (SKILL.md).
|
|
80
|
+
* Thin wrapper over the shared OpenCode-family writer (Kilo shares the schema).
|
|
81
|
+
*/
|
|
82
|
+
function convertClaudeCommandToKiloSkill(content, skillName) {
|
|
83
|
+
return runtimeArtifactConversion.convertClaudeCommandToKiloSkill(content, skillName);
|
|
84
|
+
}
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// User-artifact preservation helpers
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
/**
|
|
89
|
+
* Save user-generated files from destDir to an in-memory map before a wipe.
|
|
90
|
+
*
|
|
91
|
+
* @param destDir - Directory that is about to be wiped
|
|
92
|
+
* @param fileNames - Relative file names (e.g. ['USER-PROFILE.md']) to preserve
|
|
93
|
+
* @returns Map of fileName → file content (only entries that existed)
|
|
94
|
+
*/
|
|
95
|
+
function preserveUserArtifacts(destDir, fileNames) {
|
|
96
|
+
const saved = new Map();
|
|
97
|
+
for (const name of fileNames) {
|
|
98
|
+
const fullPath = node_path_1.default.join(destDir, name);
|
|
99
|
+
if (node_fs_1.default.existsSync(fullPath)) {
|
|
100
|
+
try {
|
|
101
|
+
saved.set(name, node_fs_1.default.readFileSync(fullPath, 'utf8'));
|
|
102
|
+
}
|
|
103
|
+
catch { /* skip unreadable files */ }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return saved;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Restore user-generated files saved by preserveUserArtifacts after a wipe.
|
|
110
|
+
*
|
|
111
|
+
* @param destDir - Directory that was wiped and recreated
|
|
112
|
+
* @param saved - Map returned by preserveUserArtifacts
|
|
113
|
+
*/
|
|
114
|
+
function restoreUserArtifacts(destDir, saved) {
|
|
115
|
+
for (const [name, content] of saved) {
|
|
116
|
+
const fullPath = node_path_1.default.join(destDir, name);
|
|
117
|
+
try {
|
|
118
|
+
node_fs_1.default.mkdirSync(node_path_1.default.dirname(fullPath), { recursive: true });
|
|
119
|
+
node_fs_1.default.writeFileSync(fullPath, content, 'utf8');
|
|
120
|
+
}
|
|
121
|
+
catch { /* skip unwritable paths */ }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// Symlink-escape guard
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
/**
|
|
128
|
+
* Returns true if any path component between `root` and `fullPath` is a
|
|
129
|
+
* symbolic link (which could redirect writes outside the install root).
|
|
130
|
+
*/
|
|
131
|
+
function hasExistingSymlinkBetween(root, fullPath) {
|
|
132
|
+
const resolvedRoot = node_path_1.default.resolve(root);
|
|
133
|
+
const resolvedFullPath = node_path_1.default.resolve(fullPath);
|
|
134
|
+
if (resolvedFullPath !== resolvedRoot && !resolvedFullPath.startsWith(resolvedRoot + node_path_1.default.sep)) {
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
let cursor = resolvedRoot;
|
|
138
|
+
if (node_fs_1.default.existsSync(cursor) && node_fs_1.default.lstatSync(cursor).isSymbolicLink()) {
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
const relative = node_path_1.default.relative(resolvedRoot, resolvedFullPath);
|
|
142
|
+
for (const segment of relative.split(node_path_1.default.sep)) {
|
|
143
|
+
if (!segment)
|
|
144
|
+
continue;
|
|
145
|
+
cursor = node_path_1.default.join(cursor, segment);
|
|
146
|
+
if (!node_fs_1.default.existsSync(cursor))
|
|
147
|
+
return false;
|
|
148
|
+
if (node_fs_1.default.lstatSync(cursor).isSymbolicLink())
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// migrateLegacyDevPreferencesToSkill
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
/**
|
|
157
|
+
* Migrate a legacy dev-preferences.md (saved from commands/gsd/) into the
|
|
158
|
+
* runtime-aware SKILL.md location used by the writer after #2973.
|
|
159
|
+
*
|
|
160
|
+
* For runtimes with a nested skills layout (e.g. Hermes: skills/gsd/<stem>/),
|
|
161
|
+
* the target is <configDir>/skills/gsd/dev-preferences/SKILL.md.
|
|
162
|
+
* For runtimes with a flat skills layout (prefix='gsd-'), the target is
|
|
163
|
+
* <configDir>/skills/gsd-dev-preferences/SKILL.md.
|
|
164
|
+
*
|
|
165
|
+
* Skips silently if no legacy file was preserved, or if a SKILL.md already
|
|
166
|
+
* exists at the new location (don't clobber user-customized skill content
|
|
167
|
+
* — they may have edited the new file directly). Returns true on actual
|
|
168
|
+
* migration so callers can log a one-line confirmation.
|
|
169
|
+
*
|
|
170
|
+
* @param targetDir - Resolved runtime config directory (e.g. ~/.claude)
|
|
171
|
+
* @param saved - Map returned by preserveUserArtifacts
|
|
172
|
+
* @param runtime - canonical runtime ID (e.g. 'hermes', 'qwen', 'claude')
|
|
173
|
+
* @param scope - install scope
|
|
174
|
+
* @returns true if a file was migrated, false otherwise
|
|
175
|
+
*/
|
|
176
|
+
function migrateLegacyDevPreferencesToSkill(targetDir, saved, runtime, scope = 'global') {
|
|
177
|
+
if (!saved || !saved.has('dev-preferences.md'))
|
|
178
|
+
return false;
|
|
179
|
+
let skillDir;
|
|
180
|
+
if (runtime) {
|
|
181
|
+
const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, targetDir, scope);
|
|
182
|
+
const skillsKindEntry = layout.kinds.find((k) => k.kind === 'skills');
|
|
183
|
+
if (!skillsKindEntry)
|
|
184
|
+
return false; // runtime has no skills layout at this scope (e.g. cline local)
|
|
185
|
+
const stemName = skillsKindEntry.prefix === '' ? 'dev-preferences' : 'gsd-dev-preferences';
|
|
186
|
+
skillDir = node_path_1.default.join(runtimeArtifactInstallPlan.assertDestWithinConfigHome(targetDir, skillsKindEntry.destSubpath), stemName);
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
// Legacy fallback for callers that have not yet been updated to pass runtime
|
|
190
|
+
skillDir = node_path_1.default.join(runtimeArtifactInstallPlan.assertDestWithinConfigHome(targetDir, 'skills'), 'gsd-dev-preferences');
|
|
191
|
+
}
|
|
192
|
+
const skillFile = node_path_1.default.join(skillDir, 'SKILL.md');
|
|
193
|
+
if (node_fs_1.default.existsSync(skillFile))
|
|
194
|
+
return false;
|
|
195
|
+
// Symlink-escape guard: reject if any path component between targetDir and
|
|
196
|
+
// skillDir is a symlink that would redirect writes outside the config root.
|
|
197
|
+
if (hasExistingSymlinkBetween(node_path_1.default.resolve(targetDir), skillDir)) {
|
|
198
|
+
throw new Error(`migrateLegacyDevPreferencesToSkill: skillDir "${skillDir}" contains a symlink escaping the install root "${targetDir}" — refusing to write`);
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
node_fs_1.default.mkdirSync(skillDir, { recursive: true });
|
|
202
|
+
node_fs_1.default.writeFileSync(skillFile, saved.get('dev-preferences.md'), 'utf8');
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
// _copyStaged
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
/**
|
|
213
|
+
* Copy a staged directory's contents into destDir.
|
|
214
|
+
* Additive — does not prune (surface.cjs handles pruning).
|
|
215
|
+
*
|
|
216
|
+
* For skills kind: each child of stagedDir is a `${prefix}${stem}/` dir; copy
|
|
217
|
+
* the whole dir into destDir.
|
|
218
|
+
* For commands/agents kind: iterate .md files and write them into destDir.
|
|
219
|
+
* - commands: write as `${prefix}${stem}.md` unless destSubpath already
|
|
220
|
+
* encodes the GSD namespace as its last segment (e.g. `commands/gsd`), in
|
|
221
|
+
* which case write as `${stem}.md` (directory IS the namespace).
|
|
222
|
+
* - agents: write as-is (files already carry their own `gsd-` prefix).
|
|
223
|
+
* For kimi-agents kind: recursively copy generated YAML/prompt files.
|
|
224
|
+
*/
|
|
225
|
+
function _copyStaged(stagedDir, destDir, kind, configDir) {
|
|
226
|
+
// Defense-in-depth: verify destDir is within the install root even if the
|
|
227
|
+
// upstream assertDestWithinConfigHome check was somehow bypassed. This guards
|
|
228
|
+
// the actual write site against any future call-site drift.
|
|
229
|
+
// Fail-closed: every _copyStaged write must declare its install root so the gate
|
|
230
|
+
// can confine it. All callers pass configDir; an omitted root is a bug, not a copy.
|
|
231
|
+
if (configDir === undefined) {
|
|
232
|
+
throw new Error('_copyStaged: configDir (install root) is required to confine writes — refusing to write');
|
|
233
|
+
}
|
|
234
|
+
// Strict-subpath + NUL containment via the canonical gate (shared with the
|
|
235
|
+
// layout-driven install plan); throws if destDir escapes the install root.
|
|
236
|
+
// destDir here is an absolute path; path.resolve(configDir, absoluteDest) returns it unchanged, so the gate's strict-subpath check still correctly confines it to configDir.
|
|
237
|
+
const resolvedDest = runtimeArtifactInstallPlan.assertDestWithinConfigHome(configDir, destDir);
|
|
238
|
+
// Symlink-escape guard: reject if any path component between configDir and
|
|
239
|
+
// destDir is a symlink that would redirect writes outside configDir.
|
|
240
|
+
if (hasExistingSymlinkBetween(node_path_1.default.resolve(configDir), resolvedDest)) {
|
|
241
|
+
throw new Error(`_copyStaged: destDir "${destDir}" contains a symlink escaping the install root "${configDir}" — refusing to write`);
|
|
242
|
+
}
|
|
243
|
+
// Use the validated absolute path for the actual writes below.
|
|
244
|
+
destDir = resolvedDest;
|
|
245
|
+
if (!node_fs_1.default.existsSync(stagedDir))
|
|
246
|
+
return;
|
|
247
|
+
node_fs_1.default.mkdirSync(destDir, { recursive: true });
|
|
248
|
+
if (kind.kind === 'skills') {
|
|
249
|
+
// Each child of stagedDir is a prefixed skill directory: gsd-help/, etc.
|
|
250
|
+
for (const entry of node_fs_1.default.readdirSync(stagedDir, { withFileTypes: true })) {
|
|
251
|
+
if (!entry.isDirectory())
|
|
252
|
+
continue;
|
|
253
|
+
const src = node_path_1.default.join(stagedDir, entry.name);
|
|
254
|
+
const dest = node_path_1.default.join(destDir, entry.name);
|
|
255
|
+
node_fs_1.default.cpSync(src, dest, { recursive: true });
|
|
256
|
+
}
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (kind.kind === 'kimi-agents') {
|
|
260
|
+
node_fs_1.default.cpSync(stagedDir, destDir, { recursive: true });
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
// commands or agents
|
|
264
|
+
const entries = node_fs_1.default.readdirSync(stagedDir, { withFileTypes: true });
|
|
265
|
+
// For commands: apply prefix unless the destSubpath's last segment already
|
|
266
|
+
// represents the GSD namespace (e.g. 'commands/gsd' → last segment 'gsd').
|
|
267
|
+
const destLast = node_path_1.default.basename(kind.destSubpath);
|
|
268
|
+
const prefixStem = kind.prefix ? kind.prefix.replace(/-$/, '') : '';
|
|
269
|
+
const namespacedByDir = kind.kind === 'commands' && destLast === prefixStem;
|
|
270
|
+
for (const entry of entries) {
|
|
271
|
+
if (!entry.isFile())
|
|
272
|
+
continue;
|
|
273
|
+
if (!entry.name.endsWith('.md'))
|
|
274
|
+
continue;
|
|
275
|
+
const stem = entry.name.slice(0, -3); // strip .md
|
|
276
|
+
let destName;
|
|
277
|
+
if (kind.kind === 'agents') {
|
|
278
|
+
// Agent files already carry the gsd- prefix in the source dir
|
|
279
|
+
destName = entry.name;
|
|
280
|
+
}
|
|
281
|
+
else if (namespacedByDir) {
|
|
282
|
+
// Directory is the namespace; don't double-prefix the filename
|
|
283
|
+
destName = entry.name;
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
// Flat commands directory (e.g. command/ for opencode/kilo)
|
|
287
|
+
destName = `${kind.prefix}${stem}.md`;
|
|
288
|
+
}
|
|
289
|
+
node_fs_1.default.copyFileSync(node_path_1.default.join(stagedDir, entry.name), node_path_1.default.join(destDir, destName));
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
// _removeGsdEntries
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
/**
|
|
296
|
+
* Remove GSD-prefixed entries from destDir matching kind.prefix.
|
|
297
|
+
* For the prefix='' case: the destSubpath IS the namespace — remove the entire
|
|
298
|
+
* destDir. (No current runtime uses prefix='' after #947 reversed Hermes; kept
|
|
299
|
+
* as a defensive guard for future runtimes.)
|
|
300
|
+
*/
|
|
301
|
+
function _removeGsdEntries(destDir, kind) {
|
|
302
|
+
if (!node_fs_1.default.existsSync(destDir))
|
|
303
|
+
return;
|
|
304
|
+
if (kind.kind === 'kimi-agents') {
|
|
305
|
+
for (const fileName of ['gsd.yaml', 'gsd.md']) {
|
|
306
|
+
node_fs_1.default.rmSync(node_path_1.default.join(destDir, fileName), { force: true });
|
|
307
|
+
}
|
|
308
|
+
const subagentsDir = node_path_1.default.join(destDir, 'subagents');
|
|
309
|
+
if (node_fs_1.default.existsSync(subagentsDir)) {
|
|
310
|
+
for (const entry of node_fs_1.default.readdirSync(subagentsDir, { withFileTypes: true })) {
|
|
311
|
+
if (!entry.isFile())
|
|
312
|
+
continue;
|
|
313
|
+
if (!entry.name.startsWith('gsd-'))
|
|
314
|
+
continue;
|
|
315
|
+
if (!entry.name.endsWith('.yaml') && !entry.name.endsWith('.md'))
|
|
316
|
+
continue;
|
|
317
|
+
node_fs_1.default.rmSync(node_path_1.default.join(subagentsDir, entry.name), { force: true });
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (kind.prefix === '') {
|
|
323
|
+
// Whole-namespace removal (Hermes nested case — destSubpath is skills/gsd)
|
|
324
|
+
// The directory itself is the GSD namespace, so remove it entirely.
|
|
325
|
+
node_fs_1.default.rmSync(destDir, { recursive: true, force: true });
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
for (const entry of node_fs_1.default.readdirSync(destDir, { withFileTypes: true })) {
|
|
329
|
+
if (!entry.name.startsWith(kind.prefix))
|
|
330
|
+
continue;
|
|
331
|
+
node_fs_1.default.rmSync(node_path_1.default.join(destDir, entry.name), { recursive: true, force: true });
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
// _snapshotDir / _restoreDir
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
/**
|
|
338
|
+
* Deep-snapshot a directory tree into a Map<relPath, Buffer>.
|
|
339
|
+
* Returns an empty Map if the directory doesn't exist.
|
|
340
|
+
*/
|
|
341
|
+
function _snapshotDir(dir) {
|
|
342
|
+
const files = new Map();
|
|
343
|
+
if (!node_fs_1.default.existsSync(dir))
|
|
344
|
+
return files;
|
|
345
|
+
const walk = (relPath, absPath) => {
|
|
346
|
+
for (const e of node_fs_1.default.readdirSync(absPath, { withFileTypes: true })) {
|
|
347
|
+
const childRel = relPath ? node_path_1.default.join(relPath, e.name) : e.name;
|
|
348
|
+
const childAbs = node_path_1.default.join(absPath, e.name);
|
|
349
|
+
if (e.isDirectory())
|
|
350
|
+
walk(childRel, childAbs);
|
|
351
|
+
else if (e.isFile())
|
|
352
|
+
files.set(childRel, node_fs_1.default.readFileSync(childAbs));
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
walk('', dir);
|
|
356
|
+
return files;
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Restore a directory tree from a Map<relPath, Buffer> produced by _snapshotDir.
|
|
360
|
+
*/
|
|
361
|
+
function _restoreDir(dir, snapshot) {
|
|
362
|
+
for (const [relPath, buf] of snapshot) {
|
|
363
|
+
const absPath = node_path_1.default.join(dir, relPath);
|
|
364
|
+
node_fs_1.default.mkdirSync(node_path_1.default.dirname(absPath), { recursive: true });
|
|
365
|
+
node_fs_1.default.writeFileSync(absPath, buf);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
// ---------------------------------------------------------------------------
|
|
369
|
+
// _removeHermesBareStemDirs
|
|
370
|
+
// ---------------------------------------------------------------------------
|
|
371
|
+
/**
|
|
372
|
+
* After the layout-driven install loop writes new gsd-<stem>/ dirs to
|
|
373
|
+
* skills/gsd/, remove any pre-existing bare-stem dirs (skills/gsd/<stem>/)
|
|
374
|
+
* that correspond to the newly installed gsd-<stem> entries.
|
|
375
|
+
*
|
|
376
|
+
* @param nestedGsdDir absolute path to skills/gsd/ category dir
|
|
377
|
+
*/
|
|
378
|
+
function _removeHermesBareStemDirs(nestedGsdDir) {
|
|
379
|
+
if (!node_fs_1.default.existsSync(nestedGsdDir))
|
|
380
|
+
return;
|
|
381
|
+
const entries = node_fs_1.default.readdirSync(nestedGsdDir, { withFileTypes: true });
|
|
382
|
+
// Collect the set of stems that were installed as gsd-<stem>/ this run.
|
|
383
|
+
const installedStems = new Set();
|
|
384
|
+
for (const entry of entries) {
|
|
385
|
+
if (entry.isDirectory() && entry.name.startsWith('gsd-')) {
|
|
386
|
+
installedStems.add(entry.name.slice('gsd-'.length)); // e.g. 'quick', 'dev-preferences'
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
// Remove any bare <stem>/ dir for which gsd-<stem>/ was just installed.
|
|
390
|
+
for (const entry of entries) {
|
|
391
|
+
if (entry.isDirectory() && !entry.name.startsWith('gsd-') && installedStems.has(entry.name)) {
|
|
392
|
+
node_fs_1.default.rmSync(node_path_1.default.join(nestedGsdDir, entry.name), { recursive: true });
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
// ---------------------------------------------------------------------------
|
|
397
|
+
// Legacy migration helpers
|
|
398
|
+
// ---------------------------------------------------------------------------
|
|
399
|
+
/**
|
|
400
|
+
* Run legacy install migrations that must execute BEFORE the layout-driven
|
|
401
|
+
* copy so stale artifacts are cleaned up before new ones are written.
|
|
402
|
+
*
|
|
403
|
+
* @param runtime
|
|
404
|
+
* @param configDir resolved runtime config directory
|
|
405
|
+
* @param scope
|
|
406
|
+
*/
|
|
407
|
+
function _runLegacyInstallMigrations(runtime, configDir, scope = 'global') {
|
|
408
|
+
const legacyCommandsGsd = node_path_1.default.join(configDir, 'commands', 'gsd');
|
|
409
|
+
// Claude / Qwen / Hermes: clean up legacy commands/gsd/ and preserve dev-preferences
|
|
410
|
+
// for migration. The actual migration call is deferred to after all layout cleanup so
|
|
411
|
+
// that for Hermes the flat skills/gsd-*/ removal (below) does not delete the freshly
|
|
412
|
+
// created skills/gsd-dev-preferences/ skill dir.
|
|
413
|
+
let savedLegacyArtifacts = null;
|
|
414
|
+
if (runtime === 'claude' || runtime === 'qwen' || runtime === 'hermes') {
|
|
415
|
+
if (node_fs_1.default.existsSync(legacyCommandsGsd)) {
|
|
416
|
+
savedLegacyArtifacts = preserveUserArtifacts(legacyCommandsGsd, ['dev-preferences.md']);
|
|
417
|
+
node_fs_1.default.rmSync(legacyCommandsGsd, { recursive: true });
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
// Hermes: remove pre-#2841 flat skills/gsd-*/ entries that lived alongside
|
|
421
|
+
// the new skills/gsd/ nested layout.
|
|
422
|
+
if (runtime === 'hermes') {
|
|
423
|
+
const flatSkillsDir = node_path_1.default.join(configDir, 'skills');
|
|
424
|
+
if (node_fs_1.default.existsSync(flatSkillsDir)) {
|
|
425
|
+
for (const entry of node_fs_1.default.readdirSync(flatSkillsDir, { withFileTypes: true })) {
|
|
426
|
+
if (entry.isDirectory() && entry.name.startsWith('gsd-')) {
|
|
427
|
+
node_fs_1.default.rmSync(node_path_1.default.join(flatSkillsDir, entry.name), { recursive: true });
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
// Hermes: bare-stem skills/gsd/<stem>/ cleanup is deferred to AFTER the
|
|
432
|
+
// layout-driven install loop in installRuntimeArtifacts, where the exact set
|
|
433
|
+
// of staged gsd-<stem>/ dirs is known. Removing here (before staging) would
|
|
434
|
+
// require readGsdCommandNames() which misses skills like 'dev-preferences'
|
|
435
|
+
// that are not in the commands directory. See _removeHermesBareStemDirs().
|
|
436
|
+
}
|
|
437
|
+
// Migrate dev-preferences.md content → runtime-aware SKILL.md location (#2973).
|
|
438
|
+
// Done after all layout cleanup so Hermes flat-dir removal does not delete the
|
|
439
|
+
// newly created skill dir. No-op if skill file already exists.
|
|
440
|
+
if (savedLegacyArtifacts) {
|
|
441
|
+
migrateLegacyDevPreferencesToSkill(configDir, savedLegacyArtifacts, runtime, scope);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Run legacy uninstall cleanup that must execute BEFORE the layout-driven
|
|
446
|
+
* removal so old-format entries are also cleaned up.
|
|
447
|
+
*
|
|
448
|
+
* @param runtime
|
|
449
|
+
* @param configDir resolved runtime config directory
|
|
450
|
+
* @param scope
|
|
451
|
+
* @returns saved legacy artifacts for post-removal migration, or null
|
|
452
|
+
*/
|
|
453
|
+
function _runLegacyUninstallCleanup(runtime, configDir, scope = 'global') {
|
|
454
|
+
// commands/gsd/ is a legacy location for Qwen, Hermes, and all Claude installs.
|
|
455
|
+
// Prior to #1367 fix, Claude-local used commands/gsd/<cmd>.md (colon-namespaced).
|
|
456
|
+
// After #1367, Claude-local uses flat commands/gsd-<cmd>.md. The inline uninstall
|
|
457
|
+
// block (1c) handles removal of flat files; this function handles the legacy
|
|
458
|
+
// commands/gsd/ directory for all Claude scopes (global was already included,
|
|
459
|
+
// local is now added since that layout is also legacy post-#1367).
|
|
460
|
+
// #2973 / Codex review (bd1f06c9): preserve user-owned dev-preferences.md
|
|
461
|
+
// before destructive wipe. Migration to skills/gsd-dev-preferences/SKILL.md
|
|
462
|
+
// is deferred and returned so the caller can apply it AFTER layout-driven
|
|
463
|
+
// removal — this prevents the layout's gsd-* prefix removal from wiping the
|
|
464
|
+
// freshly created skill dir (same pattern as _runLegacyInstallMigrations).
|
|
465
|
+
let savedLegacyArtifacts = null;
|
|
466
|
+
// commands/gsd/ is a legacy location for Qwen, Hermes, and Claude global.
|
|
467
|
+
// Claude local is intentionally excluded: the inline uninstall block (1c) handles
|
|
468
|
+
// commands/gsd/ for claude local, preserving dev-preferences.md by restoring it
|
|
469
|
+
// to the same location (#1423). Using migrateLegacyDevPreferencesToSkill here
|
|
470
|
+
// (which would redirect to skills/) conflicts with the test contract for local installs.
|
|
471
|
+
const isLegacyCommandsGsd = runtime === 'qwen' || runtime === 'hermes' || (runtime === 'claude' && scope === 'global');
|
|
472
|
+
if (isLegacyCommandsGsd) {
|
|
473
|
+
const legacyCommandsGsd = node_path_1.default.join(configDir, 'commands', 'gsd');
|
|
474
|
+
if (node_fs_1.default.existsSync(legacyCommandsGsd)) {
|
|
475
|
+
savedLegacyArtifacts = preserveUserArtifacts(legacyCommandsGsd, ['dev-preferences.md']);
|
|
476
|
+
node_fs_1.default.rmSync(legacyCommandsGsd, { recursive: true });
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
// Hermes: pre-#2841 flat skills/gsd-*/ entries
|
|
480
|
+
if (runtime === 'hermes') {
|
|
481
|
+
const flatSkillsDir = node_path_1.default.join(configDir, 'skills');
|
|
482
|
+
if (node_fs_1.default.existsSync(flatSkillsDir)) {
|
|
483
|
+
for (const entry of node_fs_1.default.readdirSync(flatSkillsDir, { withFileTypes: true })) {
|
|
484
|
+
if (entry.isDirectory() && entry.name.startsWith('gsd-')) {
|
|
485
|
+
node_fs_1.default.rmSync(node_path_1.default.join(flatSkillsDir, entry.name), { recursive: true });
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
// Hermes: pre-#947 bare-stem skills/gsd/<stem>/ entries (dirs that do NOT
|
|
490
|
+
// start with 'gsd-') — the #3664 layout used prefix='' so GSD-owned skills
|
|
491
|
+
// had bare names (e.g. skills/gsd/help/). These are stale on uninstall.
|
|
492
|
+
const nestedGsdDirForUninstall = node_path_1.default.join(configDir, 'skills', 'gsd');
|
|
493
|
+
if (node_fs_1.default.existsSync(nestedGsdDirForUninstall)) {
|
|
494
|
+
for (const entry of node_fs_1.default.readdirSync(nestedGsdDirForUninstall, { withFileTypes: true })) {
|
|
495
|
+
if (entry.isDirectory() && !entry.name.startsWith('gsd-')) {
|
|
496
|
+
node_fs_1.default.rmSync(node_path_1.default.join(nestedGsdDirForUninstall, entry.name), { recursive: true });
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
// Return saved artifacts so the caller can migrate after layout-driven removal.
|
|
502
|
+
return savedLegacyArtifacts;
|
|
503
|
+
}
|
|
504
|
+
// ---------------------------------------------------------------------------
|
|
505
|
+
// installRuntimeArtifacts
|
|
506
|
+
// ---------------------------------------------------------------------------
|
|
507
|
+
/**
|
|
508
|
+
* Layout-driven install orchestrator.
|
|
509
|
+
* Runs legacy migrations first, then uses resolveRuntimeArtifactLayout to
|
|
510
|
+
* determine what artifact kinds to write and where.
|
|
511
|
+
*
|
|
512
|
+
* @param runtime canonical runtime ID
|
|
513
|
+
* @param configDir resolved runtime config directory
|
|
514
|
+
* @param scope
|
|
515
|
+
* @param resolvedProfile from resolveProfile() / resolveEffectiveProfile()
|
|
516
|
+
* @param resolveAttribution injection: (runtime) => attribution string | undefined
|
|
517
|
+
*/
|
|
518
|
+
function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile, resolveAttribution = () => undefined) {
|
|
519
|
+
// Legacy cleanup before layout-driven writes
|
|
520
|
+
_runLegacyInstallMigrations(runtime, configDir, scope);
|
|
521
|
+
const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, configDir, scope);
|
|
522
|
+
const planResult = runtimeArtifactInstallPlan.createRuntimeArtifactInstallPlan({
|
|
523
|
+
// `Layout` is structurally identical across the layout/install-plan .cjs
|
|
524
|
+
// modules but nominally distinct to tsc (untyped .cjs boundary) — bridge it.
|
|
525
|
+
layout: layout,
|
|
526
|
+
resolvedProfile,
|
|
527
|
+
homedir: () => node_os_1.default.homedir(),
|
|
528
|
+
platform: process.platform,
|
|
529
|
+
resolveAttribution,
|
|
530
|
+
});
|
|
531
|
+
const cleanupDirs = planResult.ok ? planResult.plan.cleanupDirs : planResult.cleanupDirs;
|
|
532
|
+
try {
|
|
533
|
+
if (!planResult.ok) {
|
|
534
|
+
throw new Error(planResult.message);
|
|
535
|
+
}
|
|
536
|
+
const kindsByName = new Map(layout.kinds.map((kind) => [kind.kind, kind]));
|
|
537
|
+
for (const item of planResult.plan.items) {
|
|
538
|
+
const kind = kindsByName.get(item.kind);
|
|
539
|
+
if (!kind)
|
|
540
|
+
throw new Error(`Install plan returned unknown artifact kind: ${item.kind}`);
|
|
541
|
+
const dest = item.destDir;
|
|
542
|
+
// Symlink-escape guard: reject before mkdir if dest (or any component
|
|
543
|
+
// between configDir and dest) is a symlink pointing outside configDir.
|
|
544
|
+
// mkdirSync follows symlinks, so this must run BEFORE the mkdir call.
|
|
545
|
+
if (hasExistingSymlinkBetween(node_path_1.default.resolve(configDir), dest)) {
|
|
546
|
+
throw new Error(`installRuntimeArtifacts: destDir "${dest}" contains a symlink escaping the install root "${configDir}" — refusing to create`);
|
|
547
|
+
}
|
|
548
|
+
node_fs_1.default.mkdirSync(dest, { recursive: true });
|
|
549
|
+
if (kind.kind === 'skills' && node_fs_1.default.existsSync(dest)) {
|
|
550
|
+
// Pre-prune: snapshot user-owned content before _removeGsdEntries wipes it,
|
|
551
|
+
// then restore after. This preserves user dirs across a wipe-and-replace
|
|
552
|
+
// install (#2973 / #3664).
|
|
553
|
+
//
|
|
554
|
+
// All runtimes (incl. Hermes after #947) use prefix='gsd-'.
|
|
555
|
+
// _removeGsdEntries removes only gsd-* entries; non-gsd-* user dirs are
|
|
556
|
+
// untouched. Preserve the explicit user-owned GSD-prefixed skill
|
|
557
|
+
// gsd-dev-preferences, which GSD does not reinstall from source but must
|
|
558
|
+
// survive the prune (#2973).
|
|
559
|
+
const toPreserve = new Map(); // dirName -> Map<relPath, Buffer>
|
|
560
|
+
{
|
|
561
|
+
// Preserve explicitly user-owned GSD-prefixed skill dirs.
|
|
562
|
+
// gsd-dev-preferences is the sole user-customisable skill in this category.
|
|
563
|
+
const USER_OWNED_SKILL_DIRS = ['gsd-dev-preferences'];
|
|
564
|
+
for (const dirName of USER_OWNED_SKILL_DIRS) {
|
|
565
|
+
const skillDir = node_path_1.default.join(dest, dirName);
|
|
566
|
+
if (!node_fs_1.default.existsSync(skillDir))
|
|
567
|
+
continue;
|
|
568
|
+
const snap = _snapshotDir(skillDir);
|
|
569
|
+
if (snap.size > 0)
|
|
570
|
+
toPreserve.set(dirName, snap);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
_removeGsdEntries(dest, kind);
|
|
574
|
+
_copyStaged(item.sourceDir, dest, kind, configDir);
|
|
575
|
+
// Restore user-owned dirs after the prune+copy
|
|
576
|
+
for (const [dirName, snap] of toPreserve) {
|
|
577
|
+
_restoreDir(node_path_1.default.join(dest, dirName), snap);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
else {
|
|
581
|
+
// For non-skills kinds (commands, agents): no user content to preserve;
|
|
582
|
+
// just prune stale gsd-* entries and copy new ones.
|
|
583
|
+
_removeGsdEntries(dest, kind);
|
|
584
|
+
_copyStaged(item.sourceDir, dest, kind, configDir);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
finally {
|
|
589
|
+
for (const dir of cleanupDirs) {
|
|
590
|
+
try {
|
|
591
|
+
node_fs_1.default.rmSync(dir, { recursive: true, force: true });
|
|
592
|
+
}
|
|
593
|
+
catch { /* best-effort */ }
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
// Hermes: after the install loop has written all gsd-<stem>/ dirs to
|
|
597
|
+
// skills/gsd/, remove any stale bare-stem dirs (skills/gsd/<stem>/) that
|
|
598
|
+
// correspond to the newly installed gsd-<stem> entries. This is the robust
|
|
599
|
+
// replacement for the readGsdCommandNames()-based pre-install cleanup that
|
|
600
|
+
// missed skills like 'dev-preferences' (#947 adversarial review).
|
|
601
|
+
//
|
|
602
|
+
// We run this AFTER the install loop so the installed set is authoritative:
|
|
603
|
+
// every gsd-<stem>/ present now was written this run (or was there before
|
|
604
|
+
// with the same prefix). User-owned bare dirs with no gsd-<stem> counterpart
|
|
605
|
+
// are untouched.
|
|
606
|
+
if (runtime === 'hermes') {
|
|
607
|
+
const nestedGsdDirForCleanup = node_path_1.default.join(configDir, 'skills', 'gsd');
|
|
608
|
+
_removeHermesBareStemDirs(nestedGsdDirForCleanup);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
// ---------------------------------------------------------------------------
|
|
612
|
+
// installOpencodeFamilySkills
|
|
613
|
+
// ---------------------------------------------------------------------------
|
|
614
|
+
/**
|
|
615
|
+
* Install the skills layout kind for an OpenCode-family runtime (OpenCode/Kilo).
|
|
616
|
+
*
|
|
617
|
+
* These runtimes do NOT go through installRuntimeArtifacts (their commands use a
|
|
618
|
+
* bespoke flattened-command writer), so this writes ONLY the skills kind
|
|
619
|
+
* alongside their existing command/ + agents/ surfaces. Uninstall is already
|
|
620
|
+
* layout-driven (uninstallRuntimeArtifacts iterates layout.kinds), so the
|
|
621
|
+
* skills/ dir is cleaned up automatically once the layout declares it.
|
|
622
|
+
*
|
|
623
|
+
* @param runtime - 'opencode' or 'kilo'
|
|
624
|
+
* @param targetDir - resolved runtime config directory
|
|
625
|
+
* @param rawCommandsDir - staged RAW Claude command dir (caller's _stageSkills output)
|
|
626
|
+
* @param pathPrefix - computed config-path prefix for body rewrites
|
|
627
|
+
* @param resolveAttribution - injection: (runtime) => attribution string | undefined
|
|
628
|
+
* @returns number of gsd-* skill directories written
|
|
629
|
+
*/
|
|
630
|
+
function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPrefix, resolveAttribution = () => undefined) {
|
|
631
|
+
const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, targetDir);
|
|
632
|
+
const skillsKindEntry = layout.kinds.find((k) => k.kind === 'skills');
|
|
633
|
+
if (!skillsKindEntry)
|
|
634
|
+
return 0;
|
|
635
|
+
const rawDir = rawCommandsDir;
|
|
636
|
+
if (!rawDir || !node_fs_1.default.existsSync(rawDir))
|
|
637
|
+
return 0;
|
|
638
|
+
const converter = runtime === 'kilo'
|
|
639
|
+
? convertClaudeCommandToKiloSkill
|
|
640
|
+
: convertClaudeCommandToOpencodeSkill;
|
|
641
|
+
const dest = runtimeArtifactInstallPlan.assertDestWithinConfigHome(targetDir, skillsKindEntry.destSubpath);
|
|
642
|
+
// Symlink-escape guard: reject if any path component between targetDir and
|
|
643
|
+
// dest is a symlink that would redirect writes outside the config root.
|
|
644
|
+
if (hasExistingSymlinkBetween(node_path_1.default.resolve(targetDir), dest)) {
|
|
645
|
+
throw new Error(`installOpencodeFamilySkills: destDir "${dest}" contains a symlink escaping the install root "${targetDir}" — refusing to write`);
|
|
646
|
+
}
|
|
647
|
+
node_fs_1.default.mkdirSync(dest, { recursive: true });
|
|
648
|
+
// Preserve user-owned GSD-prefixed skill dirs across the gsd-* prune.
|
|
649
|
+
// gsd-dev-preferences is generated by the user (via generate-dev-preferences)
|
|
650
|
+
// and lives at <configDir>/skills/gsd-dev-preferences — _removeGsdEntries
|
|
651
|
+
// would otherwise wipe it. Mirrors the preservation in installRuntimeArtifacts
|
|
652
|
+
// (#2973).
|
|
653
|
+
const USER_OWNED_SKILL_DIRS = ['gsd-dev-preferences'];
|
|
654
|
+
const toPreserve = new Map(); // dirName -> Map<relPath, Buffer>
|
|
655
|
+
for (const dirName of USER_OWNED_SKILL_DIRS) {
|
|
656
|
+
const skillDir = node_path_1.default.join(dest, dirName);
|
|
657
|
+
if (!node_fs_1.default.existsSync(skillDir))
|
|
658
|
+
continue;
|
|
659
|
+
const snap = _snapshotDir(skillDir);
|
|
660
|
+
if (snap.size > 0)
|
|
661
|
+
toPreserve.set(dirName, snap);
|
|
662
|
+
}
|
|
663
|
+
_removeGsdEntries(dest, skillsKindEntry);
|
|
664
|
+
let count = 0;
|
|
665
|
+
for (const entry of node_fs_1.default.readdirSync(rawDir, { withFileTypes: true })) {
|
|
666
|
+
if (!entry.isFile() || !entry.name.endsWith('.md'))
|
|
667
|
+
continue;
|
|
668
|
+
const stem = entry.name.slice(0, -3);
|
|
669
|
+
const skillName = `${skillsKindEntry.prefix}${stem}`;
|
|
670
|
+
let content = node_fs_1.default.readFileSync(node_path_1.default.join(rawDir, entry.name), 'utf8');
|
|
671
|
+
content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix);
|
|
672
|
+
content = processAttribution(content, resolveAttribution(runtime));
|
|
673
|
+
content = converter(content, skillName);
|
|
674
|
+
const skillDir = node_path_1.default.join(dest, skillName);
|
|
675
|
+
node_fs_1.default.mkdirSync(skillDir, { recursive: true });
|
|
676
|
+
node_fs_1.default.writeFileSync(node_path_1.default.join(skillDir, 'SKILL.md'), content);
|
|
677
|
+
count++;
|
|
678
|
+
}
|
|
679
|
+
// Restore user-owned dirs after the prune+copy.
|
|
680
|
+
for (const [dirName, snap] of toPreserve) {
|
|
681
|
+
_restoreDir(node_path_1.default.join(dest, dirName), snap);
|
|
682
|
+
}
|
|
683
|
+
return count;
|
|
684
|
+
}
|
|
685
|
+
// ---------------------------------------------------------------------------
|
|
686
|
+
// uninstallRuntimeArtifacts
|
|
687
|
+
// ---------------------------------------------------------------------------
|
|
688
|
+
/**
|
|
689
|
+
* Layout-driven uninstall orchestrator.
|
|
690
|
+
* Runs legacy cleanup first, then uses resolveRuntimeArtifactLayout to
|
|
691
|
+
* determine which GSD-owned entries to remove.
|
|
692
|
+
*
|
|
693
|
+
* @param runtime canonical runtime ID
|
|
694
|
+
* @param configDir resolved runtime config directory
|
|
695
|
+
* @param scope
|
|
696
|
+
*/
|
|
697
|
+
function uninstallRuntimeArtifacts(runtime, configDir, scope) {
|
|
698
|
+
// Legacy cleanup before layout-driven removal (scope-aware to avoid
|
|
699
|
+
// removing Claude local commands/gsd/ which is the primary install dir).
|
|
700
|
+
// Returns saved user artifacts so we can migrate AFTER layout removal
|
|
701
|
+
// (the layout's gsd-* prefix pass would wipe a skill dir created here).
|
|
702
|
+
const savedLegacyArtifacts = _runLegacyUninstallCleanup(runtime, configDir, scope);
|
|
703
|
+
const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, configDir, scope);
|
|
704
|
+
const plan = runtimeArtifactInstallPlan.createRuntimeArtifactUninstallPlan(layout);
|
|
705
|
+
const kindsByName = new Map(layout.kinds.map((kind) => [kind.kind, kind]));
|
|
706
|
+
for (const item of plan.items) {
|
|
707
|
+
const kind = kindsByName.get(item.kind);
|
|
708
|
+
if (!kind) {
|
|
709
|
+
throw new Error(`Runtime artifact uninstall plan referenced unknown kind: ${item.kind}`);
|
|
710
|
+
}
|
|
711
|
+
_removeGsdEntries(item.destDir, kind);
|
|
712
|
+
}
|
|
713
|
+
// Hermes: after removing gsd-* skill dirs from skills/gsd/, also remove
|
|
714
|
+
// the GSD-managed DESCRIPTION.md and then the category dir itself if it
|
|
715
|
+
// contains no user content (#947). _removeGsdEntries removed gsd-* dirs
|
|
716
|
+
// but left the category container and DESCRIPTION.md intact.
|
|
717
|
+
if (runtime === 'hermes') {
|
|
718
|
+
const nestedGsdDir = node_path_1.default.join(configDir, 'skills', 'gsd');
|
|
719
|
+
if (node_fs_1.default.existsSync(nestedGsdDir)) {
|
|
720
|
+
// Remove GSD-owned DESCRIPTION.md (written by writeHermesCategoryDescription)
|
|
721
|
+
node_fs_1.default.rmSync(node_path_1.default.join(nestedGsdDir, 'DESCRIPTION.md'), { force: true });
|
|
722
|
+
// Remove the category dir if empty (no user content remaining)
|
|
723
|
+
const remaining = node_fs_1.default.readdirSync(nestedGsdDir, { withFileTypes: true });
|
|
724
|
+
if (remaining.length === 0) {
|
|
725
|
+
node_fs_1.default.rmSync(nestedGsdDir, { recursive: true, force: true });
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
// #2973 / Codex review (bd1f06c9): migrate dev-preferences.md to the
|
|
730
|
+
// runtime-aware SKILL.md location after all layout-driven removal is
|
|
731
|
+
// complete. Do NOT restore to commands/gsd/ — the user is uninstalling.
|
|
732
|
+
if (savedLegacyArtifacts) {
|
|
733
|
+
migrateLegacyDevPreferencesToSkill(configDir, savedLegacyArtifacts, runtime, scope);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
module.exports = {
|
|
737
|
+
installRuntimeArtifacts,
|
|
738
|
+
uninstallRuntimeArtifacts,
|
|
739
|
+
installOpencodeFamilySkills,
|
|
740
|
+
_copyStaged,
|
|
741
|
+
hasExistingSymlinkBetween,
|
|
742
|
+
preserveUserArtifacts,
|
|
743
|
+
restoreUserArtifacts,
|
|
744
|
+
migrateLegacyDevPreferencesToSkill,
|
|
745
|
+
applyOpencodeFamilyPathPrefix,
|
|
746
|
+
convertClaudeCommandToOpencodeSkill,
|
|
747
|
+
convertClaudeCommandToKiloSkill,
|
|
748
|
+
USER_OWNED_ARTIFACTS,
|
|
749
|
+
_runLegacyInstallMigrations,
|
|
750
|
+
_runLegacyUninstallCleanup,
|
|
751
|
+
_removeGsdEntries,
|
|
752
|
+
_snapshotDir,
|
|
753
|
+
_restoreDir,
|
|
754
|
+
_removeHermesBareStemDirs,
|
|
755
|
+
};
|