@klhapp/skillmux 0.4.5 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/init.ts CHANGED
@@ -1,7 +1,33 @@
1
- import { existsSync, lstatSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
1
+ import {
2
+ existsSync,
3
+ lstatSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ readlinkSync,
7
+ readdirSync,
8
+ realpathSync,
9
+ renameSync,
10
+ rmdirSync,
11
+ symlinkSync,
12
+ unlinkSync,
13
+ writeFileSync,
14
+ } from "node:fs";
15
+ import { hostname } from "node:os";
2
16
  import { basename, dirname, join } from "node:path";
3
- import { serializeManifest, type Manifest, MANIFEST_FILENAME } from "./manifest";
4
- import { adoptTarget, readSkillmuxMarker } from "./sync";
17
+ import {
18
+ parseManifest,
19
+ resolveManifestPath,
20
+ serializeManifest,
21
+ type Manifest,
22
+ CORE_SKILL_LIMIT,
23
+ MANIFEST_FILENAME,
24
+ } from "./manifest";
25
+ import {
26
+ adoptTarget,
27
+ preflightAdoptTarget,
28
+ readSkillmuxMarker,
29
+ SKILLMUX_MARKER_FILENAME,
30
+ } from "./sync";
5
31
  import { SKILL_ID_PATTERN } from "./vault";
6
32
 
7
33
  export const DEFAULT_SURFACE_CANDIDATES = ["~/.claude/skills", "~/.agents/skills"];
@@ -20,10 +46,13 @@ export function surfaceCandidates(): string[] {
20
46
 
21
47
  export interface SurfaceCandidate {
22
48
  path: string;
49
+ canonicalPath?: string;
23
50
  exists: boolean;
24
51
  isSymlink: boolean;
25
52
  skillCount: number;
26
53
  alreadyMarked: boolean;
54
+ state: "missing" | "directory" | "broken-symlink" | "external-symlink" | "full-vault" | "unsupported";
55
+ deliveryMode: "managed-pins" | "full-vault" | "external";
27
56
  }
28
57
 
29
58
  function countSkillDirs(dir: string): number {
@@ -36,17 +65,93 @@ function countSkillDirs(dir: string): number {
36
65
  return count;
37
66
  }
38
67
 
39
- export function detectSurfaces(candidatePaths: string[]): SurfaceCandidate[] {
40
- return candidatePaths.map((path) => {
41
- if (!existsSync(path)) {
42
- return { path, exists: false, isSymlink: false, skillCount: 0, alreadyMarked: false };
68
+ export function detectSurfaces(candidatePaths: string[], vaultPath?: string): SurfaceCandidate[] {
69
+ const canonicalVaultPath = vaultPath ? realpathSync(vaultPath) : undefined;
70
+
71
+ return candidatePaths.map((path): SurfaceCandidate => {
72
+ let stat;
73
+ try {
74
+ stat = lstatSync(path);
75
+ } catch (error) {
76
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
77
+ return {
78
+ path,
79
+ exists: false,
80
+ isSymlink: false,
81
+ skillCount: 0,
82
+ alreadyMarked: false,
83
+ state: "missing",
84
+ deliveryMode: "managed-pins",
85
+ };
43
86
  }
87
+
88
+ if (stat.isSymbolicLink()) {
89
+ let canonicalPath: string;
90
+ try {
91
+ canonicalPath = realpathSync(path);
92
+ } catch (error) {
93
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
94
+ return {
95
+ path,
96
+ exists: false,
97
+ isSymlink: true,
98
+ skillCount: 0,
99
+ alreadyMarked: false,
100
+ state: "broken-symlink",
101
+ deliveryMode: "external",
102
+ };
103
+ }
104
+
105
+ const isFullVault = canonicalVaultPath !== undefined && canonicalPath === canonicalVaultPath;
106
+ return {
107
+ path,
108
+ canonicalPath,
109
+ exists: true,
110
+ isSymlink: true,
111
+ skillCount: 0,
112
+ alreadyMarked: false,
113
+ state: isFullVault ? "full-vault" : "external-symlink",
114
+ deliveryMode: isFullVault ? "full-vault" : "external",
115
+ };
116
+ }
117
+
118
+ const canonicalPath = realpathSync(path);
119
+ const isFullVault = canonicalVaultPath !== undefined && canonicalPath === canonicalVaultPath;
120
+ if (isFullVault) {
121
+ return {
122
+ path,
123
+ canonicalPath,
124
+ exists: true,
125
+ isSymlink: false,
126
+ skillCount: 0,
127
+ alreadyMarked: false,
128
+ state: "full-vault",
129
+ deliveryMode: "full-vault",
130
+ };
131
+ }
132
+
133
+ if (!stat.isDirectory()) {
134
+ return {
135
+ path,
136
+ canonicalPath,
137
+ exists: true,
138
+ isSymlink: false,
139
+ skillCount: 0,
140
+ alreadyMarked: false,
141
+ state: "unsupported",
142
+ deliveryMode: "external",
143
+ };
144
+ }
145
+
44
146
  return {
45
147
  path,
148
+ canonicalPath,
46
149
  exists: true,
47
- isSymlink: lstatSync(path).isSymbolicLink(),
150
+ isSymlink: false,
48
151
  skillCount: countSkillDirs(path),
49
152
  alreadyMarked: readSkillmuxMarker(path) !== null,
153
+ state: "directory",
154
+ deliveryMode: "managed-pins",
50
155
  };
51
156
  });
52
157
  }
@@ -68,6 +173,45 @@ export function deriveTargetName(path: string): string {
68
173
  export interface ConfirmedTarget {
69
174
  name: string;
70
175
  dir: string;
176
+ migrateFullVault?: boolean;
177
+ }
178
+
179
+ export interface InitTransactionParticipant {
180
+ apply: () => void;
181
+ rollback: () => void;
182
+ }
183
+
184
+ function preflightManagedTargets(vaultPath: string, targets: ConfirmedTarget[]): void {
185
+ const canonicalVaultPath = realpathSync(vaultPath);
186
+
187
+ for (const target of targets) {
188
+ let stat;
189
+ try {
190
+ stat = lstatSync(target.dir);
191
+ } catch (error) {
192
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
193
+ throw error;
194
+ }
195
+
196
+ if (stat.isSymbolicLink()) {
197
+ const canonicalTargetPath = realpathSync(target.dir);
198
+ if (target.migrateFullVault && canonicalTargetPath === canonicalVaultPath) {
199
+ continue;
200
+ }
201
+ throw new Error(
202
+ `target "${target.name}" (${target.dir}) is a symbolic link; classify or migrate it before managed-pins adoption`,
203
+ );
204
+ }
205
+ if (!stat.isDirectory()) {
206
+ throw new Error(`target "${target.name}" (${target.dir}) is not a directory`);
207
+ }
208
+ if (realpathSync(target.dir) === canonicalVaultPath) {
209
+ throw new Error(
210
+ `target "${target.name}" (${target.dir}) is the full-vault surface; it cannot be adopted as managed-pins`,
211
+ );
212
+ }
213
+ preflightAdoptTarget(target.dir, target.name, vaultPath);
214
+ }
71
215
  }
72
216
 
73
217
  /**
@@ -76,19 +220,138 @@ export interface ConfirmedTarget {
76
220
  * first if it doesn't exist yet). Unconfirmed candidates are simply never
77
221
  * passed in — this function never discovers paths on its own.
78
222
  */
79
- export function applyInit(vaultPath: string, confirmedTargets: ConfirmedTarget[]): Manifest {
223
+ export function planInitManifest(
224
+ vaultPath: string,
225
+ confirmedTargets: ConfirmedTarget[],
226
+ coreSkillIds: string[] = [],
227
+ ): Manifest {
228
+ preflightManagedTargets(vaultPath, confirmedTargets);
229
+
230
+ const existingManifestPath = resolveManifestPath(vaultPath);
231
+ const existingManifest = existingManifestPath
232
+ ? parseManifest(readFileSync(existingManifestPath, "utf-8"))
233
+ : { ...proposeManifest([]), targets: {} };
80
234
  const manifest: Manifest = {
81
- ...proposeManifest([]),
82
- targets: Object.fromEntries(
83
- confirmedTargets.map((target) => [target.name, { dir: target.dir, project_groups: [] }]),
84
- ),
235
+ ...existingManifest,
236
+ core: {
237
+ ...existingManifest.core,
238
+ skills: [...new Set([...existingManifest.core.skills, ...coreSkillIds])],
239
+ },
240
+ targets: {
241
+ ...existingManifest.targets,
242
+ ...Object.fromEntries(
243
+ confirmedTargets.map((target) => {
244
+ const existingTarget = existingManifest.targets[target.name];
245
+ return [
246
+ target.name,
247
+ existingTarget
248
+ ? { ...existingTarget, dir: target.dir }
249
+ : { dir: target.dir, host: hostname(), project_groups: [] },
250
+ ];
251
+ }),
252
+ ),
253
+ },
85
254
  };
255
+ if (manifest.core.skills.length > CORE_SKILL_LIMIT) {
256
+ throw new Error(
257
+ `[core] has ${manifest.core.skills.length} skills, exceeding the limit of ${CORE_SKILL_LIMIT}`,
258
+ );
259
+ }
260
+ for (const skillId of coreSkillIds) {
261
+ if (!SKILL_ID_PATTERN.test(skillId) || !existsSync(join(vaultPath, skillId, "SKILL.md"))) {
262
+ throw new Error(`[core] skill "${skillId}" does not exist in the vault`);
263
+ }
264
+ for (const [groupName, group] of Object.entries(existingManifest.project ?? {})) {
265
+ if (group.skills.includes(skillId)) {
266
+ throw new Error(`skill "${skillId}" appears in both [core] and [project.${groupName}]`);
267
+ }
268
+ }
269
+ }
270
+ return manifest;
271
+ }
272
+
273
+ export function applyInit(
274
+ vaultPath: string,
275
+ confirmedTargets: ConfirmedTarget[],
276
+ participant?: InitTransactionParticipant,
277
+ coreSkillIds: string[] = [],
278
+ ): Manifest {
279
+ const manifest = planInitManifest(vaultPath, confirmedTargets, coreSkillIds);
280
+
281
+ const manifestPath = join(vaultPath, MANIFEST_FILENAME);
282
+ const serializedManifest = serializeManifest(manifest);
283
+ const shouldWriteManifest =
284
+ !existsSync(manifestPath) || readFileSync(manifestPath, "utf-8") !== serializedManifest;
285
+ const createdDirs: string[] = [];
286
+ const adoptedDirs: string[] = [];
287
+ const migratedFullVaultDirs: Array<{ dir: string; linkTarget: string }> = [];
288
+ let participantApplied = false;
289
+
290
+ try {
291
+ for (const target of confirmedTargets) {
292
+ let targetStat;
293
+ try {
294
+ targetStat = lstatSync(target.dir);
295
+ } catch (error) {
296
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
297
+ }
298
+ if (targetStat?.isSymbolicLink()) {
299
+ if (!target.migrateFullVault || realpathSync(target.dir) !== realpathSync(vaultPath)) {
300
+ throw new Error(
301
+ `target "${target.name}" (${target.dir}) changed to an unsafe symbolic link after preflight`,
302
+ );
303
+ }
304
+ const linkTarget = readlinkSync(target.dir);
305
+ unlinkSync(target.dir);
306
+ mkdirSync(target.dir, { recursive: true });
307
+ migratedFullVaultDirs.push({ dir: target.dir, linkTarget });
308
+ } else if (targetStat && !targetStat.isDirectory()) {
309
+ throw new Error(`target "${target.name}" (${target.dir}) changed to a non-directory after preflight`);
310
+ }
311
+ if (!existsSync(target.dir)) {
312
+ mkdirSync(target.dir, { recursive: true });
313
+ createdDirs.push(target.dir);
314
+ }
315
+ if (adoptTarget(target.dir, target.name, vaultPath).adopted) {
316
+ adoptedDirs.push(target.dir);
317
+ }
318
+ }
86
319
 
87
- writeFileSync(join(vaultPath, MANIFEST_FILENAME), serializeManifest(manifest));
320
+ if (participant) {
321
+ participant.apply();
322
+ participantApplied = true;
323
+ }
88
324
 
89
- for (const target of confirmedTargets) {
90
- if (!existsSync(target.dir)) mkdirSync(target.dir, { recursive: true });
91
- adoptTarget(target.dir, target.name);
325
+ if (shouldWriteManifest) {
326
+ const temporaryManifestPath = join(
327
+ vaultPath,
328
+ `.${MANIFEST_FILENAME}.${process.pid}-${Date.now()}.tmp`,
329
+ );
330
+ try {
331
+ writeFileSync(temporaryManifestPath, serializedManifest);
332
+ renameSync(temporaryManifestPath, manifestPath);
333
+ } catch (error) {
334
+ if (existsSync(temporaryManifestPath)) unlinkSync(temporaryManifestPath);
335
+ throw error;
336
+ }
337
+ }
338
+ } catch (error) {
339
+ try {
340
+ if (participantApplied) participant?.rollback();
341
+ } finally {
342
+ for (const dir of adoptedDirs.reverse()) {
343
+ const markerPath = join(dir, SKILLMUX_MARKER_FILENAME);
344
+ if (existsSync(markerPath)) unlinkSync(markerPath);
345
+ }
346
+ for (const migration of migratedFullVaultDirs.reverse()) {
347
+ if (existsSync(migration.dir)) rmdirSync(migration.dir);
348
+ symlinkSync(migration.linkTarget, migration.dir);
349
+ }
350
+ for (const dir of createdDirs.reverse()) {
351
+ if (existsSync(dir)) rmdirSync(dir);
352
+ }
353
+ }
354
+ throw error;
92
355
  }
93
356
 
94
357
  return manifest;
package/src/manifest.ts CHANGED
@@ -25,6 +25,7 @@ const projectGroupSchema = z.object({
25
25
 
26
26
  const targetSchema = z.object({
27
27
  dir: z.string().min(1),
28
+ host: z.string().min(1).optional(),
28
29
  project_groups: z.array(groupNameSchema).default([]),
29
30
  }).strict();
30
31
 
@@ -84,8 +85,9 @@ export function serializeManifest(manifest: Manifest): string {
84
85
  }
85
86
 
86
87
  for (const [name, target] of Object.entries(manifest.targets)) {
88
+ const host = target.host ? `\nhost = ${JSON.stringify(target.host)}` : "";
87
89
  sections.push(
88
- `[targets.${name}]\ndir = ${JSON.stringify(target.dir)}\nproject_groups = ${tomlStringArray(target.project_groups)}`,
90
+ `[targets.${name}]\ndir = ${JSON.stringify(target.dir)}${host}\nproject_groups = ${tomlStringArray(target.project_groups)}`,
89
91
  );
90
92
  }
91
93
 
@@ -169,7 +171,7 @@ export interface ManifestValidationResult {
169
171
  notes: string[];
170
172
  }
171
173
 
172
- const CORE_SKILL_LIMIT = 25;
174
+ export const CORE_SKILL_LIMIT = 25;
173
175
 
174
176
  function requireCoreVaultRoot(skillId: string, vaultPath: string, localVaultPaths: string[], location: string): void {
175
177
  const root = resolveSkillRoot(skillId, vaultPath, localVaultPaths);
package/src/setup.ts ADDED
@@ -0,0 +1,145 @@
1
+ import {
2
+ existsSync,
3
+ linkSync,
4
+ lstatSync,
5
+ mkdirSync,
6
+ readdirSync,
7
+ realpathSync,
8
+ statSync,
9
+ unlinkSync,
10
+ rmSync,
11
+ writeFileSync,
12
+ } from "node:fs";
13
+ import { basename, dirname, join } from "node:path";
14
+ import { SKILL_ID_PATTERN } from "./vault";
15
+
16
+ export interface VaultHealth {
17
+ path: string;
18
+ state: "missing" | "broken-symlink" | "not-directory" | "empty" | "ready";
19
+ ok: boolean;
20
+ skillCount: number;
21
+ message: string;
22
+ }
23
+
24
+ export interface ConfigInitPlan {
25
+ configPath: string;
26
+ vaultPath: string;
27
+ action: "create" | "preserve";
28
+ content?: string;
29
+ }
30
+
31
+ export function inspectVault(path: string): VaultHealth {
32
+ let stat;
33
+ try {
34
+ stat = lstatSync(path);
35
+ } catch (error) {
36
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
37
+ return {
38
+ path,
39
+ state: "missing",
40
+ ok: false,
41
+ skillCount: 0,
42
+ message: `vault does not exist: ${path}`,
43
+ };
44
+ }
45
+ throw error;
46
+ }
47
+
48
+ if (stat.isSymbolicLink()) {
49
+ try {
50
+ realpathSync(path);
51
+ } catch (error) {
52
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
53
+ return {
54
+ path,
55
+ state: "broken-symlink",
56
+ ok: false,
57
+ skillCount: 0,
58
+ message: `vault is a dangling symlink: ${path}`,
59
+ };
60
+ }
61
+ throw error;
62
+ }
63
+ }
64
+
65
+ const resolvedStat = stat.isSymbolicLink() ? statSync(path) : stat;
66
+ if (!resolvedStat.isDirectory()) {
67
+ return {
68
+ path,
69
+ state: "not-directory",
70
+ ok: false,
71
+ skillCount: 0,
72
+ message: `vault is not a directory: ${path}`,
73
+ };
74
+ }
75
+
76
+ const skillCount = readdirSync(path, { withFileTypes: true }).filter(
77
+ (entry) =>
78
+ entry.isDirectory() &&
79
+ SKILL_ID_PATTERN.test(entry.name) &&
80
+ existsSync(join(path, entry.name, "SKILL.md")),
81
+ ).length;
82
+ if (skillCount === 0) {
83
+ return {
84
+ path,
85
+ state: "empty",
86
+ ok: false,
87
+ skillCount,
88
+ message: `vault contains no skill directories: ${path}`,
89
+ };
90
+ }
91
+
92
+ return {
93
+ path,
94
+ state: "ready",
95
+ ok: true,
96
+ skillCount,
97
+ message: `vault ready: ${path} (${skillCount} ${skillCount === 1 ? "skill" : "skills"})`,
98
+ };
99
+ }
100
+
101
+ export function planConfigInit(configPath: string, vaultPath: string): ConfigInitPlan {
102
+ if (existsSync(configPath)) {
103
+ return { configPath, vaultPath, action: "preserve" };
104
+ }
105
+
106
+ const vaultHealth = inspectVault(vaultPath);
107
+ if (!vaultHealth.ok) {
108
+ throw new Error(vaultHealth.message);
109
+ }
110
+
111
+ return {
112
+ configPath,
113
+ vaultPath,
114
+ action: "create",
115
+ content: `vault_path = ${JSON.stringify(vaultPath)}\n`,
116
+ };
117
+ }
118
+
119
+ export function applyConfigInit(plan: ConfigInitPlan): "created" | "preserved" {
120
+ if (plan.action === "preserve" || existsSync(plan.configPath)) {
121
+ return "preserved";
122
+ }
123
+
124
+ mkdirSync(dirname(plan.configPath), { recursive: true });
125
+ const temporaryPath = join(
126
+ dirname(plan.configPath),
127
+ `.${basename(plan.configPath)}.${process.pid}.${crypto.randomUUID()}.tmp`,
128
+ );
129
+ writeFileSync(temporaryPath, plan.content as string, { encoding: "utf8", mode: 0o600 });
130
+ try {
131
+ linkSync(temporaryPath, plan.configPath);
132
+ return "created";
133
+ } catch (error) {
134
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") {
135
+ return "preserved";
136
+ }
137
+ throw error;
138
+ } finally {
139
+ unlinkSync(temporaryPath);
140
+ }
141
+ }
142
+
143
+ export function rollbackConfigInit(plan: ConfigInitPlan): void {
144
+ if (plan.action === "create") rmSync(plan.configPath, { force: true });
145
+ }