@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/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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
1
|
+
import { existsSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { expandHome } from "./config";
|
|
@@ -25,13 +25,14 @@ 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
|
|
|
31
32
|
const manifestSchema = z.object({
|
|
32
33
|
core: z.object({ skills: z.array(skillIdSchema) }).strict(),
|
|
33
34
|
project: z.record(groupNameSchema, projectGroupSchema).optional(),
|
|
34
|
-
targets: z.record(groupNameSchema, targetSchema),
|
|
35
|
+
targets: z.record(groupNameSchema, targetSchema).default({}),
|
|
35
36
|
}).strict();
|
|
36
37
|
|
|
37
38
|
export type ProjectGroup = z.infer<typeof projectGroupSchema>;
|
|
@@ -84,14 +85,25 @@ 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
|
|
|
92
94
|
return `${sections.join("\n\n")}\n`;
|
|
93
95
|
}
|
|
94
96
|
|
|
97
|
+
export function writeManifestAtomic(path: string, manifest: Manifest): void {
|
|
98
|
+
const temporaryPath = `${path}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
99
|
+
try {
|
|
100
|
+
writeFileSync(temporaryPath, serializeManifest(manifest), "utf8");
|
|
101
|
+
renameSync(temporaryPath, path);
|
|
102
|
+
} finally {
|
|
103
|
+
rmSync(temporaryPath, { force: true });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
95
107
|
function findExistingPin(manifest: Manifest, skillId: string): string | null {
|
|
96
108
|
if (manifest.core.skills.includes(skillId)) return "[core]";
|
|
97
109
|
for (const [groupName, group] of Object.entries(manifest.project ?? {})) {
|
|
@@ -165,11 +177,100 @@ export function unpinProject(manifest: Manifest, skillId: string, group: string)
|
|
|
165
177
|
};
|
|
166
178
|
}
|
|
167
179
|
|
|
180
|
+
export interface UpsertProjectOptions {
|
|
181
|
+
name: string;
|
|
182
|
+
paths: string[];
|
|
183
|
+
skills: string[];
|
|
184
|
+
targets: string[];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function upsertProject(manifest: Manifest, options: UpsertProjectOptions): Manifest {
|
|
188
|
+
if (!groupNameSchema.safeParse(options.name).success) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
`invalid group name "${options.name}" — must match /^[a-z][a-z0-9_-]*$/ (max 64 chars)`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const existingGroup = manifest.project?.[options.name] ?? { paths: [], skills: [] };
|
|
195
|
+
for (const skillId of options.skills) {
|
|
196
|
+
if (!skillIdSchema.safeParse(skillId).success) {
|
|
197
|
+
throw new Error(`invalid skill ID "${skillId}"`);
|
|
198
|
+
}
|
|
199
|
+
if (existingGroup.skills.includes(skillId)) continue;
|
|
200
|
+
const existing = findExistingPin(manifest, skillId);
|
|
201
|
+
if (existing) throw new Error(`skill "${skillId}" already pinned in ${existing}`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const targets = { ...manifest.targets };
|
|
205
|
+
for (const targetName of options.targets) {
|
|
206
|
+
const target = targets[targetName];
|
|
207
|
+
if (!target) throw new Error(`target "${targetName}" does not exist`);
|
|
208
|
+
targets[targetName] = {
|
|
209
|
+
...target,
|
|
210
|
+
project_groups: [...new Set([...target.project_groups, options.name])],
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
...manifest,
|
|
216
|
+
project: {
|
|
217
|
+
...manifest.project,
|
|
218
|
+
[options.name]: {
|
|
219
|
+
paths: [...new Set([...existingGroup.paths, ...options.paths])],
|
|
220
|
+
skills: [...new Set([...existingGroup.skills, ...options.skills])],
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
targets,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function updateProjectPaths(
|
|
228
|
+
manifest: Manifest,
|
|
229
|
+
group: string,
|
|
230
|
+
changes: { add?: string[]; remove?: string[] },
|
|
231
|
+
): Manifest {
|
|
232
|
+
const existingGroup = manifest.project?.[group];
|
|
233
|
+
if (!existingGroup) throw new Error(`[project.${group}] does not exist`);
|
|
234
|
+
const removed = new Set(changes.remove ?? []);
|
|
235
|
+
const paths = [...new Set([...existingGroup.paths, ...(changes.add ?? [])])]
|
|
236
|
+
.filter((path) => !removed.has(path));
|
|
237
|
+
return {
|
|
238
|
+
...manifest,
|
|
239
|
+
project: {
|
|
240
|
+
...manifest.project,
|
|
241
|
+
[group]: { ...existingGroup, paths },
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function updateProjectTargets(
|
|
247
|
+
manifest: Manifest,
|
|
248
|
+
group: string,
|
|
249
|
+
changes: { attach?: string[]; detach?: string[] },
|
|
250
|
+
): Manifest {
|
|
251
|
+
if (!manifest.project?.[group]) throw new Error(`[project.${group}] does not exist`);
|
|
252
|
+
const attach = new Set(changes.attach ?? []);
|
|
253
|
+
const detach = new Set(changes.detach ?? []);
|
|
254
|
+
const requested = new Set([...attach, ...detach]);
|
|
255
|
+
for (const target of requested) {
|
|
256
|
+
if (!manifest.targets[target]) throw new Error(`target "${target}" does not exist`);
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
...manifest,
|
|
260
|
+
targets: Object.fromEntries(Object.entries(manifest.targets).map(([name, target]) => {
|
|
261
|
+
let groups = target.project_groups;
|
|
262
|
+
if (attach.has(name)) groups = [...new Set([...groups, group])];
|
|
263
|
+
if (detach.has(name)) groups = groups.filter((item) => item !== group);
|
|
264
|
+
return [name, { ...target, project_groups: groups }];
|
|
265
|
+
})),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
168
269
|
export interface ManifestValidationResult {
|
|
169
270
|
notes: string[];
|
|
170
271
|
}
|
|
171
272
|
|
|
172
|
-
const CORE_SKILL_LIMIT = 25;
|
|
273
|
+
export const CORE_SKILL_LIMIT = 25;
|
|
173
274
|
|
|
174
275
|
function requireCoreVaultRoot(skillId: string, vaultPath: string, localVaultPaths: string[], location: string): void {
|
|
175
276
|
const root = resolveSkillRoot(skillId, vaultPath, localVaultPaths);
|
package/src/output.ts
CHANGED
|
@@ -110,12 +110,11 @@ export function suggestCorrection(input: string, candidates: string[]): string |
|
|
|
110
110
|
return bestMatch;
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
export function isInteractive(
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
);
|
|
113
|
+
export function isInteractive(
|
|
114
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
115
|
+
stdoutIsTTY = process.stdout.isTTY,
|
|
116
|
+
): boolean {
|
|
117
|
+
return stdoutIsTTY === true && env.TERM !== "dumb";
|
|
119
118
|
}
|
|
120
119
|
|
|
121
120
|
export function renderTargetBanner(target: ResolvedTarget): void {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
|
|
3
|
+
interface ResolveProjectDirectoryOptions {
|
|
4
|
+
cwd?: string;
|
|
5
|
+
findGitRoot?: (cwd: string) => string | null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function findGitRoot(cwd: string): string | null {
|
|
9
|
+
const result = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], {
|
|
10
|
+
cwd,
|
|
11
|
+
stdout: "pipe",
|
|
12
|
+
stderr: "ignore",
|
|
13
|
+
});
|
|
14
|
+
if (result.exitCode !== 0) return null;
|
|
15
|
+
const root = result.stdout.toString().trim();
|
|
16
|
+
return root || null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function resolveProjectDirectory(
|
|
20
|
+
explicitPath?: string,
|
|
21
|
+
options: ResolveProjectDirectoryOptions = {},
|
|
22
|
+
): string {
|
|
23
|
+
const cwd = options.cwd ?? process.cwd();
|
|
24
|
+
if (explicitPath) return resolve(explicitPath);
|
|
25
|
+
const gitRoot = (options.findGitRoot ?? findGitRoot)(cwd);
|
|
26
|
+
return resolve(gitRoot ?? cwd);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function suggestProjectName(directoryName: string): string {
|
|
30
|
+
const slug = directoryName
|
|
31
|
+
.toLowerCase()
|
|
32
|
+
.replace(/[^a-z0-9_-]+/g, "-")
|
|
33
|
+
.replace(/^-+|-+$/g, "");
|
|
34
|
+
const prefixed = /^[a-z]/.test(slug) ? slug : `project-${slug || "workspace"}`;
|
|
35
|
+
return prefixed.slice(0, 64).replace(/-+$/g, "");
|
|
36
|
+
}
|
package/src/prompts.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
|
|
3
|
+
export interface SelectOption<T extends string> {
|
|
4
|
+
value: T;
|
|
5
|
+
label: string;
|
|
6
|
+
detail?: string;
|
|
7
|
+
selected?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function parseNumberSelection(input: string, optionCount: number): number[] {
|
|
11
|
+
if (input.trim() === "") return [];
|
|
12
|
+
const indexes = input.split(",").map((part) => {
|
|
13
|
+
const choice = Number(part.trim());
|
|
14
|
+
if (!Number.isInteger(choice) || choice < 1 || choice > optionCount) {
|
|
15
|
+
throw new Error(`select numbers between 1 and ${optionCount}, separated by commas`);
|
|
16
|
+
}
|
|
17
|
+
return choice - 1;
|
|
18
|
+
});
|
|
19
|
+
return [...new Set(indexes)].sort((a, b) => a - b);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function parseCommaList(input: string): string[] {
|
|
23
|
+
return [...new Set(input.split(",").map((value) => value.trim()).filter(Boolean))];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function shouldUseWizard(
|
|
27
|
+
args: readonly string[],
|
|
28
|
+
mode: { interactive: boolean; json: boolean; dryRun: boolean },
|
|
29
|
+
): boolean {
|
|
30
|
+
if (!mode.interactive || mode.json || mode.dryRun || args.includes("--yes")) return false;
|
|
31
|
+
if (args.includes("--interactive")) return true;
|
|
32
|
+
return args.length === 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function promptMultiSelect<T extends string>(
|
|
36
|
+
question: string,
|
|
37
|
+
options: readonly SelectOption<T>[],
|
|
38
|
+
): Promise<T[]> {
|
|
39
|
+
console.log(`\n${question}`);
|
|
40
|
+
options.forEach((option, index) => {
|
|
41
|
+
const checked = option.selected ? "x" : " ";
|
|
42
|
+
const detail = option.detail ? ` ${option.detail}` : "";
|
|
43
|
+
console.log(` ${index + 1}. [${checked}] ${option.label}${detail}`);
|
|
44
|
+
});
|
|
45
|
+
const defaults = options
|
|
46
|
+
.map((option, index) => option.selected ? String(index + 1) : "")
|
|
47
|
+
.filter(Boolean)
|
|
48
|
+
.join(",");
|
|
49
|
+
const readline = createInterface({ input: process.stdin, output: process.stdout });
|
|
50
|
+
try {
|
|
51
|
+
const suffix = defaults ? ` [${defaults}]` : "";
|
|
52
|
+
const answer = await readline.question(`Select numbers, comma-separated${suffix}: `);
|
|
53
|
+
const selection = answer.trim() === "" && defaults ? defaults : answer;
|
|
54
|
+
return parseNumberSelection(selection, options.length).map((index) => options[index]!.value);
|
|
55
|
+
} finally {
|
|
56
|
+
readline.close();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function promptText(question: string, defaultValue = ""): Promise<string> {
|
|
61
|
+
const readline = createInterface({ input: process.stdin, output: process.stdout });
|
|
62
|
+
try {
|
|
63
|
+
const suffix = defaultValue ? ` [${defaultValue}]` : "";
|
|
64
|
+
const answer = (await readline.question(`${question}${suffix}: `)).trim();
|
|
65
|
+
return answer || defaultValue;
|
|
66
|
+
} finally {
|
|
67
|
+
readline.close();
|
|
68
|
+
}
|
|
69
|
+
}
|