@klhapp/skillmux 0.4.5 → 0.6.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 +47 -0
- package/README.md +85 -9
- package/docs/configuration.md +45 -7
- package/package.json +1 -1
- package/src/cli.ts +1042 -38
- package/src/completions.ts +79 -3
- package/src/config.ts +7 -3
- package/src/init-clients.ts +220 -0
- package/src/init-instructions.ts +173 -0
- package/src/init.ts +280 -17
- package/src/manifest.ts +105 -4
- package/src/output.ts +5 -6
- package/src/project-setup.ts +36 -0
- package/src/prompts.ts +69 -0
- package/src/setup.ts +145 -0
- package/src/sync.ts +128 -11
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
|
+
}
|
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,9 +216,31 @@ 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 };
|