@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/sync.ts
CHANGED
|
@@ -7,10 +7,12 @@ export const SKILLMUX_MARKER_FILENAME = ".skillmux";
|
|
|
7
7
|
export const LEGACY_MARKER_FILENAME = ".skr";
|
|
8
8
|
|
|
9
9
|
export interface SkillmuxMarker {
|
|
10
|
+
schema_version?: 1;
|
|
10
11
|
managed_by: "skillmux" | "skr";
|
|
11
12
|
role: "target" | "local_vault";
|
|
12
13
|
target?: string;
|
|
13
14
|
vault_path?: string;
|
|
15
|
+
managed_entries?: string[];
|
|
14
16
|
created_at: string;
|
|
15
17
|
}
|
|
16
18
|
|
|
@@ -18,30 +20,68 @@ function normalizeMarker(raw: Omit<SkillmuxMarker, "role"> & { role?: SkillmuxMa
|
|
|
18
20
|
return { ...raw, role: raw.role ?? "target" };
|
|
19
21
|
}
|
|
20
22
|
|
|
23
|
+
function validateMarker(marker: SkillmuxMarker, path: string): SkillmuxMarker {
|
|
24
|
+
if (marker.schema_version === undefined) return marker;
|
|
25
|
+
if (marker.schema_version !== 1) throw new Error(`${path}: unsupported marker schema_version`);
|
|
26
|
+
if (marker.managed_by !== "skillmux") throw new Error(`${path}: invalid managed_by`);
|
|
27
|
+
if (marker.role !== "target" && marker.role !== "local_vault") {
|
|
28
|
+
throw new Error(`${path}: invalid role`);
|
|
29
|
+
}
|
|
30
|
+
if (marker.role === "target") {
|
|
31
|
+
if (typeof marker.target !== "string" || marker.target.length === 0) {
|
|
32
|
+
throw new Error(`${path}: target marker is missing target`);
|
|
33
|
+
}
|
|
34
|
+
if (typeof marker.vault_path !== "string" || marker.vault_path.length === 0) {
|
|
35
|
+
throw new Error(`${path}: target marker is missing vault_path`);
|
|
36
|
+
}
|
|
37
|
+
if (!Array.isArray(marker.managed_entries) || marker.managed_entries.some((entry) => typeof entry !== "string")) {
|
|
38
|
+
throw new Error(`${path}: target marker is missing managed_entries`);
|
|
39
|
+
}
|
|
40
|
+
} else if (typeof marker.vault_path !== "string" || marker.vault_path.length === 0) {
|
|
41
|
+
throw new Error(`${path}: local_vault marker is missing vault_path`);
|
|
42
|
+
}
|
|
43
|
+
if (typeof marker.created_at !== "string") throw new Error(`${path}: marker is missing created_at`);
|
|
44
|
+
return marker;
|
|
45
|
+
}
|
|
46
|
+
|
|
21
47
|
export function readSkillmuxMarker(dir: string): SkillmuxMarker | null {
|
|
22
48
|
const newPath = join(dir, SKILLMUX_MARKER_FILENAME);
|
|
23
49
|
if (existsSync(newPath)) {
|
|
24
|
-
return normalizeMarker(JSON.parse(readFileSync(newPath, "utf-8")));
|
|
50
|
+
return validateMarker(normalizeMarker(JSON.parse(readFileSync(newPath, "utf-8"))), newPath);
|
|
25
51
|
}
|
|
26
52
|
const legacyPath = join(dir, LEGACY_MARKER_FILENAME);
|
|
27
53
|
if (existsSync(legacyPath)) {
|
|
28
|
-
return normalizeMarker(JSON.parse(readFileSync(legacyPath, "utf-8")));
|
|
54
|
+
return validateMarker(normalizeMarker(JSON.parse(readFileSync(legacyPath, "utf-8"))), legacyPath);
|
|
29
55
|
}
|
|
30
56
|
return null;
|
|
31
57
|
}
|
|
32
58
|
|
|
33
|
-
function
|
|
59
|
+
function writeTargetMarker(
|
|
60
|
+
dir: string,
|
|
61
|
+
targetName: string,
|
|
62
|
+
vaultPath: string,
|
|
63
|
+
managedEntries: string[],
|
|
64
|
+
createdAt = new Date().toISOString(),
|
|
65
|
+
): void {
|
|
34
66
|
const marker: SkillmuxMarker = {
|
|
67
|
+
schema_version: 1,
|
|
35
68
|
managed_by: "skillmux",
|
|
36
69
|
role: "target",
|
|
37
70
|
target: targetName,
|
|
38
|
-
|
|
71
|
+
vault_path: vaultPath,
|
|
72
|
+
managed_entries: managedEntries,
|
|
73
|
+
created_at: createdAt,
|
|
39
74
|
};
|
|
40
|
-
|
|
75
|
+
const markerPath = join(dir, SKILLMUX_MARKER_FILENAME);
|
|
76
|
+
const serialized = JSON.stringify(marker, null, 2);
|
|
77
|
+
if (!existsSync(markerPath) || readFileSync(markerPath, "utf-8") !== serialized) {
|
|
78
|
+
writeFileSync(markerPath, serialized);
|
|
79
|
+
}
|
|
41
80
|
}
|
|
42
81
|
|
|
43
82
|
export function writeLocalVaultMarker(dir: string, vaultPath: string): void {
|
|
44
83
|
const marker: SkillmuxMarker = {
|
|
84
|
+
schema_version: 1,
|
|
45
85
|
managed_by: "skillmux",
|
|
46
86
|
role: "local_vault",
|
|
47
87
|
vault_path: vaultPath,
|
|
@@ -78,23 +118,61 @@ export function syncTarget(params: SyncTargetParams, options: SyncTargetOptions
|
|
|
78
118
|
for (const skillId of coreSkillIds) {
|
|
79
119
|
symlinkSync(join(skillSource(skillId), skillId), join(targetDir, skillId));
|
|
80
120
|
}
|
|
81
|
-
|
|
121
|
+
writeTargetMarker(targetDir, targetName, vaultPath, coreSkillIds);
|
|
82
122
|
return { added: [...coreSkillIds], removed: [] };
|
|
83
123
|
}
|
|
84
124
|
|
|
85
|
-
|
|
125
|
+
let marker = readSkillmuxMarker(targetDir);
|
|
126
|
+
if (!marker) {
|
|
86
127
|
throw new Error(`not owned by skillmux — run skillmux init`);
|
|
87
128
|
}
|
|
129
|
+
if (marker.role === "local_vault") {
|
|
130
|
+
throw new Error(`${targetDir} has a local_vault marker, not target ownership`);
|
|
131
|
+
}
|
|
132
|
+
if (marker.target !== targetName) {
|
|
133
|
+
throw new Error(`${targetDir} is owned by target "${marker.target}", not "${targetName}"`);
|
|
134
|
+
}
|
|
135
|
+
if (marker.schema_version === undefined) {
|
|
136
|
+
const legacyEntries = readdirSync(targetDir).filter(
|
|
137
|
+
(name) => name !== SKILLMUX_MARKER_FILENAME && name !== LEGACY_MARKER_FILENAME,
|
|
138
|
+
);
|
|
139
|
+
if (legacyEntries.length > 0) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
`${targetDir} has a legacy marker with untracked entries; remove or migrate them before running skillmux sync`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
marker = {
|
|
145
|
+
schema_version: 1,
|
|
146
|
+
managed_by: "skillmux",
|
|
147
|
+
role: "target",
|
|
148
|
+
target: targetName,
|
|
149
|
+
vault_path: vaultPath,
|
|
150
|
+
managed_entries: [],
|
|
151
|
+
created_at: marker.created_at,
|
|
152
|
+
};
|
|
153
|
+
if (!dryRun) writeTargetMarker(targetDir, targetName, vaultPath, [], marker.created_at);
|
|
154
|
+
}
|
|
155
|
+
if (marker.vault_path !== vaultPath) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
`${targetDir} marker recorded vault_path ${marker.vault_path}, currently configured vault_path is ${vaultPath}`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
88
160
|
|
|
89
161
|
const desired = new Set(coreSkillIds);
|
|
90
162
|
const existing = readdirSync(targetDir).filter((name) => name !== SKILLMUX_MARKER_FILENAME && name !== LEGACY_MARKER_FILENAME);
|
|
163
|
+
const managedEntries = new Set(marker.managed_entries ?? []);
|
|
164
|
+
const collisions = coreSkillIds.filter((skillId) => existing.includes(skillId) && !managedEntries.has(skillId));
|
|
165
|
+
if (collisions.length > 0) {
|
|
166
|
+
throw new Error(`unmanaged entry collisions in ${targetDir}: ${collisions.join(", ")}`);
|
|
167
|
+
}
|
|
91
168
|
|
|
92
|
-
const removed =
|
|
169
|
+
const removed = [...managedEntries].filter((name) => existing.includes(name) && !desired.has(name));
|
|
93
170
|
const added = coreSkillIds.filter((skillId) => !existing.includes(skillId));
|
|
94
171
|
if (dryRun) return { added, removed };
|
|
95
172
|
|
|
96
173
|
for (const name of removed) unlinkSync(join(targetDir, name));
|
|
97
174
|
for (const skillId of added) symlinkSync(join(skillSource(skillId), skillId), join(targetDir, skillId));
|
|
175
|
+
writeTargetMarker(targetDir, targetName, vaultPath, coreSkillIds, marker.created_at);
|
|
98
176
|
|
|
99
177
|
return { added, removed };
|
|
100
178
|
}
|
|
@@ -103,6 +181,22 @@ export interface AdoptTargetResult {
|
|
|
103
181
|
adopted: boolean;
|
|
104
182
|
}
|
|
105
183
|
|
|
184
|
+
export function preflightAdoptTarget(dir: string, targetName: string, vaultPath: string): void {
|
|
185
|
+
const marker = readSkillmuxMarker(dir);
|
|
186
|
+
if (marker?.role === "local_vault") {
|
|
187
|
+
throw new Error(`${dir} has a local_vault marker, not target ownership`);
|
|
188
|
+
}
|
|
189
|
+
if (!marker) return;
|
|
190
|
+
if (marker.target !== targetName) {
|
|
191
|
+
throw new Error(`${dir} is already owned by target "${marker.target}", not "${targetName}"`);
|
|
192
|
+
}
|
|
193
|
+
if (marker.schema_version !== undefined && marker.vault_path !== vaultPath) {
|
|
194
|
+
throw new Error(
|
|
195
|
+
`${dir} marker recorded vault_path ${marker.vault_path}, currently configured vault_path is ${vaultPath}`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
106
200
|
/**
|
|
107
201
|
* Marks an existing directory as skillmux-owned without touching its content —
|
|
108
202
|
* the consented, one-time adoption skillmux init performs (see SkillmuxMarker in
|
|
@@ -110,9 +204,10 @@ export interface AdoptTargetResult {
|
|
|
110
204
|
* previously-unmarked directory"). syncTarget's fresh-dir case handles the
|
|
111
205
|
* "doesn't exist yet" side of that rule; this handles "already exists".
|
|
112
206
|
*/
|
|
113
|
-
export function adoptTarget(dir: string, targetName: string): AdoptTargetResult {
|
|
207
|
+
export function adoptTarget(dir: string, targetName: string, vaultPath: string): AdoptTargetResult {
|
|
208
|
+
preflightAdoptTarget(dir, targetName, vaultPath);
|
|
114
209
|
if (readSkillmuxMarker(dir)) return { adopted: false };
|
|
115
|
-
|
|
210
|
+
writeTargetMarker(dir, targetName, vaultPath, []);
|
|
116
211
|
return { adopted: true };
|
|
117
212
|
}
|
|
118
213
|
|
|
@@ -121,26 +216,48 @@ export interface RestoreMonolithResult {
|
|
|
121
216
|
}
|
|
122
217
|
|
|
123
218
|
export function restoreMonolith(targetDir: string, vaultPath: string): RestoreMonolithResult {
|
|
124
|
-
|
|
219
|
+
const marker = readSkillmuxMarker(targetDir);
|
|
220
|
+
if (!marker) {
|
|
125
221
|
return { restored: false };
|
|
126
222
|
}
|
|
223
|
+
if (marker.role === "local_vault") {
|
|
224
|
+
throw new Error(`${targetDir} has a local_vault marker, not target ownership`);
|
|
225
|
+
}
|
|
226
|
+
if (marker.schema_version === undefined) {
|
|
227
|
+
throw new Error(`${targetDir} has a legacy marker; migrate it before restoring full-vault delivery`);
|
|
228
|
+
}
|
|
229
|
+
if (marker.vault_path !== vaultPath) {
|
|
230
|
+
throw new Error(
|
|
231
|
+
`${targetDir} marker recorded vault_path ${marker.vault_path}, currently configured vault_path is ${vaultPath}`,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
const managedEntries = new Set(marker.managed_entries ?? []);
|
|
235
|
+
const unmanagedEntries = readdirSync(targetDir).filter(
|
|
236
|
+
(name) =>
|
|
237
|
+
name !== SKILLMUX_MARKER_FILENAME &&
|
|
238
|
+
name !== LEGACY_MARKER_FILENAME &&
|
|
239
|
+
!managedEntries.has(name),
|
|
240
|
+
);
|
|
241
|
+
if (unmanagedEntries.length > 0) {
|
|
242
|
+
throw new Error(`refusing to restore over unmanaged entries in ${targetDir}: ${unmanagedEntries.join(", ")}`);
|
|
243
|
+
}
|
|
127
244
|
rmSync(targetDir, { recursive: true, force: true });
|
|
128
245
|
symlinkSync(vaultPath, targetDir);
|
|
129
246
|
return { restored: true };
|
|
130
247
|
}
|
|
131
248
|
|
|
132
|
-
export function resolveProjectPinDir(targetDir: string,
|
|
249
|
+
export function resolveProjectPinDir(targetDir: string, path: string): string {
|
|
133
250
|
const rel = relative(homedir(), targetDir);
|
|
134
251
|
if (rel === "" || rel.startsWith("..")) {
|
|
135
252
|
throw new Error(
|
|
136
253
|
`target dir "${targetDir}" must be inside $HOME to compute a project pin dir (got relative path "${rel}")`,
|
|
137
254
|
);
|
|
138
255
|
}
|
|
139
|
-
return join(
|
|
256
|
+
return join(path, rel);
|
|
140
257
|
}
|
|
141
258
|
|
|
142
259
|
export interface ProjectGroupInput {
|
|
143
|
-
|
|
260
|
+
paths: string[];
|
|
144
261
|
skills: string[];
|
|
145
262
|
}
|
|
146
263
|
|
|
@@ -154,7 +271,7 @@ export interface SyncProjectTargetsParams {
|
|
|
154
271
|
|
|
155
272
|
export interface ProjectPinSyncResult extends SyncTargetResult {
|
|
156
273
|
group: string;
|
|
157
|
-
|
|
274
|
+
path: string;
|
|
158
275
|
pinDir: string;
|
|
159
276
|
}
|
|
160
277
|
|
|
@@ -166,14 +283,14 @@ export function syncProjectTargets(
|
|
|
166
283
|
const results: ProjectPinSyncResult[] = [];
|
|
167
284
|
|
|
168
285
|
for (const [group, projectGroup] of Object.entries(projectGroups)) {
|
|
169
|
-
for (const
|
|
170
|
-
if (!existsSync(
|
|
171
|
-
const pinDir = resolveProjectPinDir(targetDir,
|
|
286
|
+
for (const path of projectGroup.paths) {
|
|
287
|
+
if (!existsSync(path)) continue;
|
|
288
|
+
const pinDir = resolveProjectPinDir(targetDir, path);
|
|
172
289
|
const result = syncTarget(
|
|
173
290
|
{ vaultPath, targetDir: pinDir, targetName, coreSkillIds: projectGroup.skills, localVaultPaths },
|
|
174
291
|
options,
|
|
175
292
|
);
|
|
176
|
-
results.push({ group,
|
|
293
|
+
results.push({ group, path, pinDir, ...result });
|
|
177
294
|
}
|
|
178
295
|
}
|
|
179
296
|
|