@oevery/rsp 3.1.0-beta.1 → 3.1.0-beta.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.
@@ -0,0 +1,322 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ ManagedPathError,
4
+ PKG_ROOT,
5
+ inspectManagedDirectory,
6
+ inspectManagedFileTree,
7
+ resolveManagedDirectoryChain
8
+ } from "./chunk-OTJFL6XH.mjs";
9
+
10
+ // src/commands/skills.ts
11
+ import { randomUUID } from "crypto";
12
+ import { lstatSync } from "fs";
13
+ import { chmod, copyFile, mkdir, readdir, readFile, rename, rm } from "fs/promises";
14
+ import { dirname, join, relative } from "path";
15
+ var DEFAULT_PACKAGED_SKILL_NAMES = [
16
+ "rsp",
17
+ "rsp-address-review",
18
+ "rsp-commit",
19
+ "rsp-design",
20
+ "rsp-diagnose",
21
+ "rsp-implement",
22
+ "rsp-manage",
23
+ "rsp-release-docs",
24
+ "rsp-review",
25
+ "rsp-shape",
26
+ "rsp-tdd"
27
+ ];
28
+ function unsupportedEntry(path, label) {
29
+ return new ManagedPathError(path, `unsupported entry in the ${label} tree: ${path}`);
30
+ }
31
+ function inspectDirectoryIdentity(path, label) {
32
+ const inspection = inspectManagedDirectory(path, label);
33
+ if (inspection.issue)
34
+ throw inspection.issue;
35
+ const stats = lstatSync(path);
36
+ return { path, dev: stats.dev, ino: stats.ino };
37
+ }
38
+ function assertDirectoryIdentities(identities, label) {
39
+ for (const identity of identities) {
40
+ const current = inspectDirectoryIdentity(identity.path, label);
41
+ if (current.dev !== identity.dev || current.ino !== identity.ino)
42
+ throw new ManagedPathError(identity.path, `${label} changed after validation: ${identity.path}`);
43
+ }
44
+ }
45
+ async function prepareTargetRoot(projectRoot, targetRoot) {
46
+ const projectIdentity = inspectDirectoryIdentity(projectRoot, "project Skills root");
47
+ const agentsRoot = join(projectRoot, ".agents");
48
+ const identities = [projectIdentity];
49
+ assertDirectoryIdentities(identities, "project Skills root");
50
+ await mkdir(agentsRoot).catch((error) => {
51
+ if (error.code !== "EEXIST")
52
+ throw error;
53
+ });
54
+ assertDirectoryIdentities(identities, "project Skills root");
55
+ identities.push(inspectDirectoryIdentity(agentsRoot, "project Skills root"));
56
+ assertDirectoryIdentities(identities, "project Skills root");
57
+ await mkdir(targetRoot).catch((error) => {
58
+ if (error.code !== "EEXIST")
59
+ throw error;
60
+ });
61
+ assertDirectoryIdentities(identities, "project Skills root");
62
+ identities.push(inspectDirectoryIdentity(targetRoot, "project Skills root"));
63
+ return identities;
64
+ }
65
+ async function inspectPackagedSkills(packageRoot) {
66
+ const skillsRoot = join(packageRoot, "skills");
67
+ const rootInspection = inspectManagedDirectory(skillsRoot, "packaged Skills root");
68
+ if (rootInspection.issue)
69
+ throw rootInspection.issue;
70
+ const entries = await readdir(skillsRoot, { withFileTypes: true });
71
+ const skills = [];
72
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
73
+ if (entry.name.startsWith("."))
74
+ continue;
75
+ const root = join(skillsRoot, entry.name);
76
+ if (!entry.isDirectory())
77
+ throw unsupportedEntry(root, "packaged Skills");
78
+ const inspection = await inspectManagedFileTree(root, `packaged Skill ${entry.name}`);
79
+ if (inspection.issues.length > 0)
80
+ throw inspection.issues[0];
81
+ skills.push({
82
+ name: entry.name,
83
+ root,
84
+ files: inspection.files.map((path) => relative(root, path))
85
+ });
86
+ }
87
+ return skills;
88
+ }
89
+ function inspectTarget(path, name) {
90
+ try {
91
+ const stats = lstatSync(path);
92
+ if (stats.isDirectory())
93
+ return "directory";
94
+ throw unsupportedEntry(path, `installed Skill ${name}`);
95
+ } catch (error) {
96
+ if (error.code === "ENOENT")
97
+ return "missing";
98
+ throw error;
99
+ }
100
+ }
101
+ function inspectInventoryTarget(path) {
102
+ try {
103
+ return lstatSync(path).isDirectory() ? "directory" : "divergent";
104
+ } catch (error) {
105
+ if (error.code === "ENOENT")
106
+ return "missing";
107
+ throw error;
108
+ }
109
+ }
110
+ async function treesEqual(source, targetRoot) {
111
+ const target = await inspectManagedFileTree(targetRoot, `installed Skill ${source.name}`);
112
+ if (target.issues.length > 0)
113
+ throw target.issues[0];
114
+ const targetFiles = target.files.map((path) => relative(targetRoot, path));
115
+ if (source.files.length !== targetFiles.length || source.files.some((path, index) => path !== targetFiles[index]))
116
+ return false;
117
+ for (const path of source.files) {
118
+ const sourcePath = join(source.root, path);
119
+ const targetPath = join(targetRoot, path);
120
+ const [sourceStats, targetStats] = [lstatSync(sourcePath), lstatSync(targetPath)];
121
+ if (!sourceStats.isFile() || !targetStats.isFile())
122
+ throw unsupportedEntry(!sourceStats.isFile() ? sourcePath : targetPath, `Skill ${source.name}`);
123
+ if ((sourceStats.mode & 511) !== (targetStats.mode & 511))
124
+ return false;
125
+ const [sourceContent, targetContent] = await Promise.all([readFile(sourcePath), readFile(targetPath)]);
126
+ if (!sourceContent.equals(targetContent))
127
+ return false;
128
+ }
129
+ return true;
130
+ }
131
+ async function inspectPackagedSkillInventory(options = {}) {
132
+ const packageRoot = options.packageRoot ?? PKG_ROOT;
133
+ const projectRoot = options.projectRoot ?? process.cwd();
134
+ const packagedSkills = await inspectPackagedSkills(packageRoot);
135
+ const packagedNames = new Set(packagedSkills.map((skill) => skill.name));
136
+ const missingDefaults = DEFAULT_PACKAGED_SKILL_NAMES.filter((name) => !packagedNames.has(name));
137
+ if (missingDefaults.length > 0)
138
+ throw new Error(`packaged Skills missing default Skills: ${missingDefaults.join(", ")}`);
139
+ const manifest = JSON.parse(await readFile(join(packageRoot, "package.json"), "utf8"));
140
+ if (typeof manifest.name !== "string" || typeof manifest.version !== "string")
141
+ throw new Error("package.json must contain string name and version fields");
142
+ const targetRoot = resolveManagedDirectoryChain(projectRoot, [".agents", "skills"], "project Skills root");
143
+ const defaults = new Set(DEFAULT_PACKAGED_SKILL_NAMES);
144
+ const skills = [];
145
+ for (const skill of packagedSkills) {
146
+ const target = join(targetRoot, skill.name);
147
+ const targetStatus = inspectInventoryTarget(target);
148
+ skills.push({
149
+ name: skill.name,
150
+ kind: defaults.has(skill.name) ? "default" : "optional",
151
+ status: targetStatus === "missing" ? "missing" : targetStatus === "divergent" ? "divergent" : await treesEqual(skill, target) ? "unchanged" : "divergent"
152
+ });
153
+ }
154
+ return {
155
+ package: { name: manifest.name, version: manifest.version },
156
+ target: relative(projectRoot, targetRoot) || ".",
157
+ skills
158
+ };
159
+ }
160
+ function printPackagedSkillInventory(inventory) {
161
+ console.log(` ${inventory.package.name}@${inventory.package.version}`);
162
+ console.log(` target: ${inventory.target}`);
163
+ for (const group of [
164
+ { heading: "Default lifecycle Skills", kind: "default" },
165
+ { heading: "Optional project Skills", kind: "optional" }
166
+ ]) {
167
+ console.log("");
168
+ console.log(` ${group.heading}`);
169
+ const skills = inventory.skills.filter((skill) => skill.kind === group.kind);
170
+ if (skills.length === 0)
171
+ console.log(" none");
172
+ for (const skill of skills)
173
+ console.log(` ${skill.name} ${skill.status}`);
174
+ }
175
+ }
176
+ async function copySkillTree(source, targetRoot) {
177
+ await mkdir(targetRoot, { recursive: true });
178
+ for (const path of source.files) {
179
+ const sourcePath = join(source.root, path);
180
+ const targetPath = join(targetRoot, path);
181
+ const stats = lstatSync(sourcePath);
182
+ if (!stats.isFile())
183
+ throw unsupportedEntry(sourcePath, `packaged Skill ${source.name}`);
184
+ await mkdir(dirname(targetPath), { recursive: true });
185
+ await copyFile(sourcePath, targetPath);
186
+ await chmod(targetPath, stats.mode & 511);
187
+ }
188
+ }
189
+ async function installPackagedSkills(args = {}, options = {}) {
190
+ const packageRoot = options.packageRoot ?? PKG_ROOT;
191
+ const projectRoot = options.projectRoot ?? process.cwd();
192
+ const renamePath = options.renamePath ?? rename;
193
+ const packagedSkills = await inspectPackagedSkills(packageRoot);
194
+ const requestedNames = args.names?.length ? [...new Set(args.names)] : [...DEFAULT_PACKAGED_SKILL_NAMES];
195
+ const packagedSkillsByName = new Map(packagedSkills.map((skill) => [skill.name, skill]));
196
+ for (const name of requestedNames) {
197
+ if (!packagedSkillsByName.has(name))
198
+ throw new Error(`unknown packaged Skill: ${name}`);
199
+ }
200
+ const skills = requestedNames.map((name) => packagedSkillsByName.get(name)).sort((a, b) => a.name.localeCompare(b.name));
201
+ const targetRoot = resolveManagedDirectoryChain(projectRoot, [".agents", "skills"], "project Skills root");
202
+ const result = { installed: [], unchanged: [], replaced: [] };
203
+ const conflicts = [];
204
+ const targetTreeIdentities = /* @__PURE__ */ new Map();
205
+ for (const skill of skills) {
206
+ const target = join(targetRoot, skill.name);
207
+ if (inspectTarget(target, skill.name) === "missing") {
208
+ result.installed.push(skill.name);
209
+ continue;
210
+ }
211
+ const targetIdentity = inspectDirectoryIdentity(target, `installed Skill ${skill.name}`);
212
+ if (await treesEqual(skill, target)) {
213
+ result.unchanged.push(skill.name);
214
+ continue;
215
+ }
216
+ assertDirectoryIdentities([targetIdentity], `installed Skill ${skill.name}`);
217
+ if (args.force) {
218
+ result.replaced.push(skill.name);
219
+ targetTreeIdentities.set(skill.name, targetIdentity);
220
+ } else {
221
+ conflicts.push(skill.name);
222
+ }
223
+ }
224
+ if (conflicts.length > 0)
225
+ throw new Error(`conflicting packaged Skills: ${conflicts.join(", ")}; rerun with --force to replace only these package-owned directories`);
226
+ if (args.dryRun)
227
+ return result;
228
+ await options.onMutationStep?.({ phase: "before-target-root-mutation", targetRoot });
229
+ const targetIdentities = await prepareTargetRoot(projectRoot, targetRoot);
230
+ const stagingRoot = join(targetRoot, `.rsp-skills-install-${randomUUID()}`);
231
+ const nextRoot = join(stagingRoot, "next");
232
+ const previousRoot = join(stagingRoot, "previous");
233
+ const failedRoot = join(stagingRoot, "failed");
234
+ const movedPrevious = [];
235
+ const activated = [];
236
+ let removeStaging = true;
237
+ try {
238
+ assertDirectoryIdentities(targetIdentities, "project Skills root");
239
+ await mkdir(nextRoot, { recursive: true });
240
+ await mkdir(previousRoot);
241
+ await mkdir(failedRoot);
242
+ for (const skill of skills) {
243
+ if (result.unchanged.includes(skill.name))
244
+ continue;
245
+ await copySkillTree(skill, join(nextRoot, skill.name));
246
+ }
247
+ for (const name of result.installed) {
248
+ const target = join(targetRoot, name);
249
+ if (inspectTarget(target, name) !== "missing")
250
+ throw new ManagedPathError(target, `installed Skill changed after preflight: ${target}`);
251
+ }
252
+ for (const name of result.replaced) {
253
+ assertDirectoryIdentities([targetTreeIdentities.get(name)], `installed Skill ${name}`);
254
+ }
255
+ for (const name of result.replaced) {
256
+ assertDirectoryIdentities(targetIdentities, "project Skills root");
257
+ assertDirectoryIdentities([targetTreeIdentities.get(name)], `installed Skill ${name}`);
258
+ await renamePath(join(targetRoot, name), join(previousRoot, name));
259
+ movedPrevious.push(name);
260
+ }
261
+ for (const name of [...result.installed, ...result.replaced].sort()) {
262
+ const target = join(targetRoot, name);
263
+ assertDirectoryIdentities(targetIdentities, "project Skills root");
264
+ await options.onMutationStep?.({ phase: "before-activate", name, target });
265
+ assertDirectoryIdentities(targetIdentities, "project Skills root");
266
+ if (inspectTarget(target, name) !== "missing")
267
+ throw new ManagedPathError(target, `installed Skill changed after preflight: ${target}`);
268
+ await renamePath(join(nextRoot, name), target);
269
+ activated.push(name);
270
+ }
271
+ } catch (error) {
272
+ const rollbackErrors = [];
273
+ for (const name of activated.reverse()) {
274
+ const target = join(targetRoot, name);
275
+ try {
276
+ assertDirectoryIdentities(targetIdentities, "project Skills root");
277
+ if (inspectTarget(target, name) === "directory")
278
+ await renamePath(target, join(failedRoot, name));
279
+ } catch (rollbackError) {
280
+ rollbackErrors.push(rollbackError instanceof Error ? rollbackError.message : String(rollbackError));
281
+ }
282
+ }
283
+ for (const name of movedPrevious.reverse()) {
284
+ const target = join(targetRoot, name);
285
+ const previous = join(previousRoot, name);
286
+ try {
287
+ assertDirectoryIdentities(targetIdentities, "project Skills root");
288
+ await options.onMutationStep?.({ phase: "before-rollback-restore", name, target, previous });
289
+ assertDirectoryIdentities(targetIdentities, "project Skills root");
290
+ if (inspectTarget(target, name) !== "missing")
291
+ throw new ManagedPathError(target, `cannot restore installed Skill because the target is occupied: ${target}`);
292
+ await renamePath(previous, target);
293
+ } catch (rollbackError) {
294
+ rollbackErrors.push(rollbackError instanceof Error ? rollbackError.message : String(rollbackError));
295
+ }
296
+ }
297
+ if (rollbackErrors.length > 0) {
298
+ removeStaging = false;
299
+ const message = error instanceof Error ? error.message : String(error);
300
+ throw new Error(`${message}; rollback incomplete; recover original Skills from ${previousRoot}; ${rollbackErrors.join("; ")}`);
301
+ }
302
+ throw error;
303
+ } finally {
304
+ if (removeStaging)
305
+ await rm(stagingRoot, { recursive: true, force: true });
306
+ }
307
+ return result;
308
+ }
309
+ function printSkillInstallResult(result, dryRun = false) {
310
+ const prefix = dryRun ? "would be " : "";
311
+ for (const status of ["installed", "unchanged", "replaced"]) {
312
+ if (result[status].length > 0)
313
+ console.log(` ${prefix}${status}: ${result[status].join(", ")}`);
314
+ }
315
+ }
316
+
317
+ export {
318
+ inspectPackagedSkillInventory,
319
+ printPackagedSkillInventory,
320
+ installPackagedSkills,
321
+ printSkillInstallResult
322
+ };