@klhapp/skillmux 0.4.4 → 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/CHANGELOG.md +29 -0
- package/README.md +57 -11
- package/docs/configuration.md +50 -9
- package/package.json +1 -1
- package/src/cli.ts +524 -44
- package/src/completions.ts +33 -2
- package/src/config.ts +7 -3
- package/src/init-clients.ts +189 -0
- package/src/init-instructions.ts +173 -0
- package/src/init.ts +280 -17
- package/src/manifest.ts +24 -13
- package/src/setup.ts +145 -0
- package/src/sync.ts +136 -19
package/src/init.ts
CHANGED
|
@@ -1,7 +1,33 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
4
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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:
|
|
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
|
|
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
|
-
...
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
320
|
+
if (participant) {
|
|
321
|
+
participant.apply();
|
|
322
|
+
participantApplied = true;
|
|
323
|
+
}
|
|
88
324
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
@@ -19,12 +19,13 @@ const groupNameSchema = z.string().regex(/^[a-z][a-z0-9_-]*$/).max(64);
|
|
|
19
19
|
const skillIdSchema = z.string().regex(SKILL_ID_PATTERN);
|
|
20
20
|
|
|
21
21
|
const projectGroupSchema = z.object({
|
|
22
|
-
|
|
22
|
+
paths: z.array(z.string().min(1)),
|
|
23
23
|
skills: z.array(skillIdSchema),
|
|
24
24
|
}).strict();
|
|
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
|
|
|
@@ -54,6 +55,15 @@ export function parseManifest(toml: string): Manifest {
|
|
|
54
55
|
`[targets.${String(issue.path[1])}] uses the removed field "project" (boolean) — replace it with "project_groups" (an array of [project.<group>] names).`,
|
|
55
56
|
);
|
|
56
57
|
}
|
|
58
|
+
if (
|
|
59
|
+
issue.code === "unrecognized_keys" &&
|
|
60
|
+
issue.path[0] === "project" &&
|
|
61
|
+
issue.keys.includes("repos")
|
|
62
|
+
) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`[project.${String(issue.path[1])}] uses the removed field "repos" — replace it with "paths".`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
57
67
|
}
|
|
58
68
|
}
|
|
59
69
|
throw error;
|
|
@@ -70,13 +80,14 @@ export function serializeManifest(manifest: Manifest): string {
|
|
|
70
80
|
|
|
71
81
|
for (const [name, group] of Object.entries(manifest.project ?? {})) {
|
|
72
82
|
sections.push(
|
|
73
|
-
`[project.${name}]\
|
|
83
|
+
`[project.${name}]\npaths = ${tomlStringArray(group.paths)}\nskills = ${tomlStringArray(group.skills)}`,
|
|
74
84
|
);
|
|
75
85
|
}
|
|
76
86
|
|
|
77
87
|
for (const [name, target] of Object.entries(manifest.targets)) {
|
|
88
|
+
const host = target.host ? `\nhost = ${JSON.stringify(target.host)}` : "";
|
|
78
89
|
sections.push(
|
|
79
|
-
`[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)}`,
|
|
80
91
|
);
|
|
81
92
|
}
|
|
82
93
|
|
|
@@ -106,15 +117,15 @@ export function unpinCore(manifest: Manifest, skillId: string): Manifest {
|
|
|
106
117
|
return { ...manifest, core: { skills: manifest.core.skills.filter((id) => id !== skillId) } };
|
|
107
118
|
}
|
|
108
119
|
|
|
109
|
-
export function pinProject(manifest: Manifest, skillId: string, group: string,
|
|
120
|
+
export function pinProject(manifest: Manifest, skillId: string, group: string, paths?: string[]): Manifest {
|
|
110
121
|
if (!groupNameSchema.safeParse(group).success) {
|
|
111
122
|
throw new Error(`invalid group name "${group}" — must match /^[a-z][a-z0-9_-]*$/ (max 64 chars)`);
|
|
112
123
|
}
|
|
113
124
|
const existingGroup = manifest.project?.[group];
|
|
114
125
|
|
|
115
126
|
if (!existingGroup) {
|
|
116
|
-
if (!
|
|
117
|
-
throw new Error(`group "${group}" does not exist — pass --
|
|
127
|
+
if (!paths || paths.length === 0) {
|
|
128
|
+
throw new Error(`group "${group}" does not exist — pass --path <path> at least once to create it`);
|
|
118
129
|
}
|
|
119
130
|
const existing = findExistingPin(manifest, skillId);
|
|
120
131
|
if (existing) {
|
|
@@ -122,12 +133,12 @@ export function pinProject(manifest: Manifest, skillId: string, group: string, r
|
|
|
122
133
|
}
|
|
123
134
|
return {
|
|
124
135
|
...manifest,
|
|
125
|
-
project: { ...manifest.project, [group]: {
|
|
136
|
+
project: { ...manifest.project, [group]: { paths, skills: [skillId] } },
|
|
126
137
|
};
|
|
127
138
|
}
|
|
128
139
|
|
|
129
|
-
if (
|
|
130
|
-
throw new Error(`group "${group}" already exists — --
|
|
140
|
+
if (paths && paths.length > 0) {
|
|
141
|
+
throw new Error(`group "${group}" already exists — --path is only used when creating a new group`);
|
|
131
142
|
}
|
|
132
143
|
const existing = findExistingPin(manifest, skillId);
|
|
133
144
|
if (existing) {
|
|
@@ -160,7 +171,7 @@ export interface ManifestValidationResult {
|
|
|
160
171
|
notes: string[];
|
|
161
172
|
}
|
|
162
173
|
|
|
163
|
-
const CORE_SKILL_LIMIT = 25;
|
|
174
|
+
export const CORE_SKILL_LIMIT = 25;
|
|
164
175
|
|
|
165
176
|
function requireCoreVaultRoot(skillId: string, vaultPath: string, localVaultPaths: string[], location: string): void {
|
|
166
177
|
const root = resolveSkillRoot(skillId, vaultPath, localVaultPaths);
|
|
@@ -208,9 +219,9 @@ export function validateManifest(
|
|
|
208
219
|
throw new Error(`skill "${skillId}" appears in both [core] and [project.${groupName}]`);
|
|
209
220
|
}
|
|
210
221
|
}
|
|
211
|
-
for (const
|
|
212
|
-
if (!existsSync(expandHome(
|
|
213
|
-
notes.push(`[project.${groupName}]
|
|
222
|
+
for (const path of group.paths) {
|
|
223
|
+
if (!existsSync(expandHome(path))) {
|
|
224
|
+
notes.push(`[project.${groupName}] paths entry not found locally, skipped: ${path}`);
|
|
214
225
|
}
|
|
215
226
|
}
|
|
216
227
|
}
|
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
|
+
}
|