skvlt 0.9.9
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/LICENSE +21 -0
- package/README.md +199 -0
- package/README.zh.md +199 -0
- package/package.json +55 -0
- package/src/cli.ts +211 -0
- package/src/commands/backup.ts +224 -0
- package/src/commands/completion.ts +197 -0
- package/src/commands/doctor.ts +257 -0
- package/src/commands/restore.ts +689 -0
- package/src/internal/args/parse-backup-args.ts +77 -0
- package/src/internal/args/parse-completion-args.ts +30 -0
- package/src/internal/args/parse-doctor-args.ts +40 -0
- package/src/internal/args/parse-restore-args.ts +119 -0
- package/src/internal/cli/theme.ts +136 -0
- package/src/internal/install/global-skill-state.ts +102 -0
- package/src/internal/install/resolve-install-concurrency.ts +74 -0
- package/src/internal/install/run-with-concurrency.ts +72 -0
- package/src/internal/install/skills-add-command.ts +53 -0
- package/src/internal/manifest/build-manifest.ts +78 -0
- package/src/internal/manifest/manifest-types.ts +27 -0
- package/src/internal/manifest/parse-manifest.ts +200 -0
- package/src/internal/paths/defaults.ts +19 -0
- package/src/internal/paths/format-cli-path.ts +13 -0
- package/src/internal/process/list-installed-skill-names.ts +27 -0
- package/src/internal/process/run-bunx.ts +27 -0
|
@@ -0,0 +1,689 @@
|
|
|
1
|
+
import { parseRestoreArgs } from "../internal/args/parse-restore-args";
|
|
2
|
+
import {
|
|
3
|
+
diffGlobalSkillState,
|
|
4
|
+
formatGlobalSkillStateMismatch,
|
|
5
|
+
readInstalledGlobalSkillNames,
|
|
6
|
+
readTrackedGlobalSkillNames,
|
|
7
|
+
type GlobalSkillStateDiff,
|
|
8
|
+
} from "../internal/install/global-skill-state";
|
|
9
|
+
import { resolveInstallConcurrency } from "../internal/install/resolve-install-concurrency";
|
|
10
|
+
import {
|
|
11
|
+
buildSkillsAddCommand,
|
|
12
|
+
formatCommandPreview,
|
|
13
|
+
} from "../internal/install/skills-add-command";
|
|
14
|
+
import {
|
|
15
|
+
runWithConcurrency,
|
|
16
|
+
type ConcurrentFailure,
|
|
17
|
+
} from "../internal/install/run-with-concurrency";
|
|
18
|
+
import { parseManifest } from "../internal/manifest/parse-manifest";
|
|
19
|
+
import type { Manifest } from "../internal/manifest/manifest-types";
|
|
20
|
+
import {
|
|
21
|
+
defaultManifestOptionPath,
|
|
22
|
+
defaultGlobalLockFilePath,
|
|
23
|
+
defaultGlobalSkillsPath,
|
|
24
|
+
} from "../internal/paths/defaults";
|
|
25
|
+
import {
|
|
26
|
+
commandErrorPage,
|
|
27
|
+
errorMessage,
|
|
28
|
+
helpExample,
|
|
29
|
+
helpFooter,
|
|
30
|
+
helpHeading,
|
|
31
|
+
infoLine,
|
|
32
|
+
page,
|
|
33
|
+
progressLine,
|
|
34
|
+
summaryLine,
|
|
35
|
+
} from "../internal/cli/theme";
|
|
36
|
+
import { formatCliPath } from "../internal/paths/format-cli-path";
|
|
37
|
+
import { listInstalledSkillNames } from "../internal/process/list-installed-skill-names";
|
|
38
|
+
import { runBunx, type BunxResult } from "../internal/process/run-bunx";
|
|
39
|
+
|
|
40
|
+
type SourceRestoreEntry = {
|
|
41
|
+
source: string;
|
|
42
|
+
count: number;
|
|
43
|
+
skills: string[];
|
|
44
|
+
pendingSkills: string[];
|
|
45
|
+
alreadyInstalledSkills: string[];
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type RestoreRunResult = {
|
|
49
|
+
exitCode: number;
|
|
50
|
+
stdout: string;
|
|
51
|
+
stderr: string;
|
|
52
|
+
errorCode?: string;
|
|
53
|
+
payload?: {
|
|
54
|
+
manifestPath: string;
|
|
55
|
+
projectScope: boolean;
|
|
56
|
+
dryRun: boolean;
|
|
57
|
+
sourceCount: number;
|
|
58
|
+
requestedSkillTotal: number;
|
|
59
|
+
pendingSkillTotal: number;
|
|
60
|
+
installTaskCount: number;
|
|
61
|
+
continueOnError: boolean;
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export type RestoreDependencies = {
|
|
66
|
+
readManifest?: (manifestPath: string) => Manifest;
|
|
67
|
+
getInstalledSkillNames?: (projectScope: boolean) => Promise<Set<string>>;
|
|
68
|
+
readTrackedGlobalSkillNames?: () => Promise<Set<string>>;
|
|
69
|
+
readInstalledGlobalSkillNames?: () => Promise<Set<string>>;
|
|
70
|
+
runBunx?: (args: string[]) => Promise<BunxResult>;
|
|
71
|
+
reportProgress?: (chunk: string) => void;
|
|
72
|
+
streamOutput?: boolean;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
function printHelp(): string {
|
|
76
|
+
return `\n${[
|
|
77
|
+
"Restore skills from skvlt.yaml.",
|
|
78
|
+
"",
|
|
79
|
+
helpHeading("Usage"),
|
|
80
|
+
" bunx skvlt restore [options]",
|
|
81
|
+
"",
|
|
82
|
+
helpHeading("Options"),
|
|
83
|
+
" --manifest <path> Path to skvlt.yaml",
|
|
84
|
+
" --only-source <source> Restore only one source; repeatable",
|
|
85
|
+
" --agent <agent> Target one agent; repeatable",
|
|
86
|
+
" --project-scope Restore to project scope instead of global",
|
|
87
|
+
" --copy Copy files instead of symlinking to agent directories",
|
|
88
|
+
" --all Install all skills from the selected sources",
|
|
89
|
+
" --reinstall-all Reinstall everything instead of only missing skills",
|
|
90
|
+
" --continue-on-error Keep going if one source fails to install",
|
|
91
|
+
" --concurrency <count> Maximum sources to install at the same time",
|
|
92
|
+
" --dry-run Print manifest-derived bunx commands without running them",
|
|
93
|
+
" --help Show this help",
|
|
94
|
+
"",
|
|
95
|
+
helpHeading("Notes"),
|
|
96
|
+
` - Manifest defaults to: ${formatCliPath(defaultManifestOptionPath)}`,
|
|
97
|
+
" - Restore scope defaults to the scope recorded in the manifest; --project-scope overrides it to project scope.",
|
|
98
|
+
` - Global restore verifies ${formatCliPath(defaultGlobalSkillsPath)} against ${formatCliPath(defaultGlobalLockFilePath)}`,
|
|
99
|
+
"",
|
|
100
|
+
helpHeading("Examples"),
|
|
101
|
+
helpExample("bunx skvlt restore --only-source xixu-me/skills"),
|
|
102
|
+
helpExample("bunx skvlt restore --all"),
|
|
103
|
+
helpExample("bunx skvlt restore --project-scope --manifest ./skvlt.yaml"),
|
|
104
|
+
helpFooter("https://github.com/xixu-me/skills-vault"),
|
|
105
|
+
].join("\n")}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function getInstalledSkillNames(
|
|
109
|
+
projectScope: boolean,
|
|
110
|
+
): Promise<Set<string>> {
|
|
111
|
+
return new Set(await listInstalledSkillNames(projectScope));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function resolveRestoreErrorCode(message: string): string {
|
|
115
|
+
if (
|
|
116
|
+
message.startsWith("Unknown argument:") ||
|
|
117
|
+
message.includes("requires a value") ||
|
|
118
|
+
message.includes("requires a path") ||
|
|
119
|
+
message.includes("requires a positive integer")
|
|
120
|
+
) {
|
|
121
|
+
return "INVALID_ARGUMENT";
|
|
122
|
+
}
|
|
123
|
+
if (message.startsWith("Manifest not found:")) {
|
|
124
|
+
return "MANIFEST_NOT_FOUND";
|
|
125
|
+
}
|
|
126
|
+
if (message.startsWith("Requested source(s) not found in manifest:")) {
|
|
127
|
+
return "SOURCE_NOT_FOUND";
|
|
128
|
+
}
|
|
129
|
+
if (message.startsWith("Unable to list installed skills")) {
|
|
130
|
+
return "SKILLS_LIST_FAILED";
|
|
131
|
+
}
|
|
132
|
+
if (message.startsWith("Install failed for source")) {
|
|
133
|
+
return "INSTALL_FAILED";
|
|
134
|
+
}
|
|
135
|
+
if (message.startsWith("Global skills state mismatch:")) {
|
|
136
|
+
return "GLOBAL_STATE_MISMATCH";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return "RESTORE_FAILED";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function formatRestoreError(message: string, errorCode: string): string {
|
|
143
|
+
if (errorCode === "INVALID_ARGUMENT") {
|
|
144
|
+
return commandErrorPage(
|
|
145
|
+
message,
|
|
146
|
+
"bunx skvlt restore [options]",
|
|
147
|
+
"bunx skvlt restore --all",
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return page(errorMessage(message));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function selectSources(
|
|
155
|
+
manifest: Manifest,
|
|
156
|
+
onlySources: string[],
|
|
157
|
+
): SourceRestoreEntry[] {
|
|
158
|
+
const entries: SourceRestoreEntry[] = Array.from(
|
|
159
|
+
manifest.sources.entries(),
|
|
160
|
+
).map(([source, entry]) => ({
|
|
161
|
+
source,
|
|
162
|
+
count: entry.count,
|
|
163
|
+
skills: [...entry.skills],
|
|
164
|
+
pendingSkills: [],
|
|
165
|
+
alreadyInstalledSkills: [],
|
|
166
|
+
}));
|
|
167
|
+
|
|
168
|
+
if (onlySources.length === 0) {
|
|
169
|
+
return entries;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const selected = entries.filter((entry) =>
|
|
173
|
+
onlySources.includes(entry.source),
|
|
174
|
+
);
|
|
175
|
+
const selectedSources = new Set(selected.map((entry) => entry.source));
|
|
176
|
+
const missing = onlySources.filter((source) => !selectedSources.has(source));
|
|
177
|
+
|
|
178
|
+
if (missing.length > 0) {
|
|
179
|
+
throw new Error(
|
|
180
|
+
`Requested source(s) not found in manifest: ${missing.join(", ")}`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return selected;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function verifyGlobalSkillStateConsistency(
|
|
188
|
+
dependencies: Required<RestoreDependencies>,
|
|
189
|
+
): Promise<GlobalSkillStateDiff> {
|
|
190
|
+
const [trackedSkillNames, installedSkillNames] = await Promise.all([
|
|
191
|
+
dependencies.readTrackedGlobalSkillNames(),
|
|
192
|
+
dependencies.readInstalledGlobalSkillNames(),
|
|
193
|
+
]);
|
|
194
|
+
|
|
195
|
+
return diffGlobalSkillState(trackedSkillNames, installedSkillNames);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function buildRestoreSummary(
|
|
199
|
+
sourceCount: number,
|
|
200
|
+
installTaskCount: number,
|
|
201
|
+
summarySkillTotal: number,
|
|
202
|
+
installAll: boolean,
|
|
203
|
+
): string {
|
|
204
|
+
if (installAll) {
|
|
205
|
+
return `Restore summary: ${sourceCount} source(s) processed, ${installTaskCount} source install(s) attempted, upstream source state determined the installed skill count.`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return `Restore summary: ${sourceCount} source(s) processed, ${installTaskCount} source install(s) attempted, ${summarySkillTotal} skill(s) scheduled.`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function runRestoreFromManifest(
|
|
212
|
+
manifest: Manifest,
|
|
213
|
+
manifestPath: string,
|
|
214
|
+
options: ReturnType<typeof parseRestoreArgs>,
|
|
215
|
+
dependencies: Required<RestoreDependencies>,
|
|
216
|
+
): Promise<RestoreRunResult> {
|
|
217
|
+
const lines: string[] = [];
|
|
218
|
+
const errors: string[] = [];
|
|
219
|
+
const streamOutput = dependencies.streamOutput;
|
|
220
|
+
const reportProgress = dependencies.reportProgress;
|
|
221
|
+
let streamedOutput = false;
|
|
222
|
+
const emitStreamLine = (line: string) => {
|
|
223
|
+
if (!streamedOutput) {
|
|
224
|
+
reportProgress("\n");
|
|
225
|
+
streamedOutput = true;
|
|
226
|
+
}
|
|
227
|
+
reportProgress(`${line}\n`);
|
|
228
|
+
};
|
|
229
|
+
const appendLine = (line: string) => {
|
|
230
|
+
const formattedLine = line.includes("summary:")
|
|
231
|
+
? summaryLine(line)
|
|
232
|
+
: infoLine(line);
|
|
233
|
+
if (streamOutput) {
|
|
234
|
+
emitStreamLine(formattedLine);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
lines.push(formattedLine);
|
|
239
|
+
};
|
|
240
|
+
const entries = selectSources(manifest, options.onlySources);
|
|
241
|
+
const requestedSkillTotal = entries.reduce(
|
|
242
|
+
(sum, entry) => sum + entry.count,
|
|
243
|
+
0,
|
|
244
|
+
);
|
|
245
|
+
// The manifest becomes the source of truth for scope unless the user forces
|
|
246
|
+
// project scope on the command line.
|
|
247
|
+
const projectScope = options.projectScope || manifest.scope === "project";
|
|
248
|
+
const scopeLabel = projectScope ? "project" : "global";
|
|
249
|
+
|
|
250
|
+
if (options.dryRun) {
|
|
251
|
+
const previewConcurrency = resolveInstallConcurrency({
|
|
252
|
+
configuredConcurrency: options.concurrency,
|
|
253
|
+
taskCount: entries.length,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
appendLine(
|
|
257
|
+
`Previewing ${requestedSkillTotal} skill(s) from ${entries.length} source(s) using '${scopeLabel}' scope.`,
|
|
258
|
+
);
|
|
259
|
+
appendLine(
|
|
260
|
+
`Parallel install concurrency: ${previewConcurrency}${options.concurrency ? " (configured)" : " (auto)"}.`,
|
|
261
|
+
);
|
|
262
|
+
if (options.installAll) {
|
|
263
|
+
// `skills add --all` delegates skill selection to the upstream source, so
|
|
264
|
+
// it is intentionally broader than the manifest snapshot.
|
|
265
|
+
appendLine(
|
|
266
|
+
"Manifest skill selections are ignored when --all is enabled; upstream source state determines what gets installed.",
|
|
267
|
+
);
|
|
268
|
+
} else {
|
|
269
|
+
appendLine(
|
|
270
|
+
"Dry run does not inspect locally installed skills; commands include every skill listed in the manifest.",
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
for (const entry of entries) {
|
|
275
|
+
const preview = formatCommandPreview([
|
|
276
|
+
"bunx",
|
|
277
|
+
...buildSkillsAddCommand(entry.source, entry.skills, {
|
|
278
|
+
...options,
|
|
279
|
+
projectScope,
|
|
280
|
+
}),
|
|
281
|
+
]);
|
|
282
|
+
appendLine(`[dry-run] ${preview}`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
exitCode: 0,
|
|
287
|
+
stdout: streamOutput
|
|
288
|
+
? streamedOutput
|
|
289
|
+
? "\n"
|
|
290
|
+
: ""
|
|
291
|
+
: page(lines.join("\n")),
|
|
292
|
+
stderr: "",
|
|
293
|
+
payload: {
|
|
294
|
+
manifestPath,
|
|
295
|
+
projectScope,
|
|
296
|
+
dryRun: true,
|
|
297
|
+
sourceCount: entries.length,
|
|
298
|
+
requestedSkillTotal,
|
|
299
|
+
pendingSkillTotal: requestedSkillTotal,
|
|
300
|
+
installTaskCount: entries.length,
|
|
301
|
+
continueOnError: options.continueOnError,
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const installedLookup = options.installAll
|
|
307
|
+
? null
|
|
308
|
+
: await dependencies.getInstalledSkillNames(projectScope);
|
|
309
|
+
const showSkillProgress = streamOutput && !options.installAll;
|
|
310
|
+
let pendingSkillTotal = 0;
|
|
311
|
+
let alreadyInstalledTotal = 0;
|
|
312
|
+
const scheduledSkillTotal =
|
|
313
|
+
options.installAll || options.reinstallAll ? requestedSkillTotal : 0;
|
|
314
|
+
const installTasks: Array<{
|
|
315
|
+
order: number;
|
|
316
|
+
entry: SourceRestoreEntry;
|
|
317
|
+
skillsToInstall: string[];
|
|
318
|
+
command: string[];
|
|
319
|
+
}> = [];
|
|
320
|
+
appendLine(`Loading manifest from ${formatCliPath(manifestPath)}...`);
|
|
321
|
+
appendLine("Planning restore operations...");
|
|
322
|
+
|
|
323
|
+
for (const [index, entry] of entries.entries()) {
|
|
324
|
+
if (options.installAll) {
|
|
325
|
+
appendLine(
|
|
326
|
+
`Source ${index + 1}/${entries.length}: ${entry.source} (manifest lists ${entry.count} skill(s); --all delegates selection upstream).`,
|
|
327
|
+
);
|
|
328
|
+
installTasks.push({
|
|
329
|
+
order: index + 1,
|
|
330
|
+
entry,
|
|
331
|
+
skillsToInstall: [],
|
|
332
|
+
command: buildSkillsAddCommand(entry.source, entry.skills, {
|
|
333
|
+
...options,
|
|
334
|
+
projectScope,
|
|
335
|
+
}),
|
|
336
|
+
});
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (options.reinstallAll) {
|
|
341
|
+
entry.pendingSkills = [...entry.skills];
|
|
342
|
+
pendingSkillTotal += entry.count;
|
|
343
|
+
appendLine(
|
|
344
|
+
`Source ${index + 1}/${entries.length}: ${entry.source} (${entry.pendingSkills.length} skill(s) selected via --reinstall-all).`,
|
|
345
|
+
);
|
|
346
|
+
installTasks.push({
|
|
347
|
+
order: index + 1,
|
|
348
|
+
entry,
|
|
349
|
+
skillsToInstall: [...entry.skills],
|
|
350
|
+
command: buildSkillsAddCommand(entry.source, entry.skills, {
|
|
351
|
+
...options,
|
|
352
|
+
projectScope,
|
|
353
|
+
}),
|
|
354
|
+
});
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
entry.pendingSkills = entry.skills.filter(
|
|
359
|
+
(skill) => !installedLookup!.has(skill),
|
|
360
|
+
);
|
|
361
|
+
entry.alreadyInstalledSkills = entry.skills.filter((skill) =>
|
|
362
|
+
installedLookup!.has(skill),
|
|
363
|
+
);
|
|
364
|
+
pendingSkillTotal += entry.pendingSkills.length;
|
|
365
|
+
alreadyInstalledTotal += entry.alreadyInstalledSkills.length;
|
|
366
|
+
appendLine(
|
|
367
|
+
`Source ${index + 1}/${entries.length}: ${entry.source} (${entry.pendingSkills.length} pending, ${entry.alreadyInstalledSkills.length} already installed).`,
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
if (entry.pendingSkills.length > 0) {
|
|
371
|
+
installTasks.push({
|
|
372
|
+
order: index + 1,
|
|
373
|
+
entry,
|
|
374
|
+
skillsToInstall: [...entry.pendingSkills],
|
|
375
|
+
command: buildSkillsAddCommand(entry.source, entry.pendingSkills, {
|
|
376
|
+
...options,
|
|
377
|
+
projectScope,
|
|
378
|
+
}),
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (options.installAll) {
|
|
384
|
+
appendLine(
|
|
385
|
+
`Installing all skills from ${entries.length} source(s) using '${scopeLabel}' scope.`,
|
|
386
|
+
);
|
|
387
|
+
appendLine(
|
|
388
|
+
"Manifest skill selections are ignored when --all is enabled; upstream source state determines what gets installed.",
|
|
389
|
+
);
|
|
390
|
+
} else if (options.reinstallAll) {
|
|
391
|
+
appendLine(
|
|
392
|
+
`Reinstalling ${requestedSkillTotal} skill(s) from ${entries.length} source(s) using '${scopeLabel}' scope.`,
|
|
393
|
+
);
|
|
394
|
+
} else {
|
|
395
|
+
appendLine(
|
|
396
|
+
`Restoring up to ${requestedSkillTotal} skill(s) from ${entries.length} source(s) using '${scopeLabel}' scope.`,
|
|
397
|
+
);
|
|
398
|
+
appendLine(
|
|
399
|
+
`${pendingSkillTotal} pending, ${alreadyInstalledTotal} already installed.`,
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
for (const entry of entries) {
|
|
404
|
+
if (
|
|
405
|
+
!options.installAll &&
|
|
406
|
+
!options.reinstallAll &&
|
|
407
|
+
entry.pendingSkills.length === 0
|
|
408
|
+
) {
|
|
409
|
+
appendLine(
|
|
410
|
+
`Skipping ${entry.source}: all ${entry.count} skill(s) already installed.`,
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (installTasks.length === 0) {
|
|
416
|
+
if (!projectScope) {
|
|
417
|
+
const globalSkillStateDiff =
|
|
418
|
+
await verifyGlobalSkillStateConsistency(dependencies);
|
|
419
|
+
if (
|
|
420
|
+
globalSkillStateDiff.missingFromLock.length > 0 ||
|
|
421
|
+
globalSkillStateDiff.missingFromDirectory.length > 0
|
|
422
|
+
) {
|
|
423
|
+
return {
|
|
424
|
+
exitCode: 1,
|
|
425
|
+
stdout: streamOutput
|
|
426
|
+
? streamedOutput
|
|
427
|
+
? "\n"
|
|
428
|
+
: ""
|
|
429
|
+
: page(lines.join("\n")),
|
|
430
|
+
stderr: page(
|
|
431
|
+
formatGlobalSkillStateMismatch(globalSkillStateDiff)
|
|
432
|
+
.map((line) => errorMessage(line))
|
|
433
|
+
.join("\n"),
|
|
434
|
+
),
|
|
435
|
+
errorCode: "GLOBAL_STATE_MISMATCH",
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
appendLine(
|
|
441
|
+
`Restore summary: ${entries.length} source(s) processed, 0 source install(s) attempted, 0 skill(s) scheduled.`,
|
|
442
|
+
);
|
|
443
|
+
return {
|
|
444
|
+
exitCode: 0,
|
|
445
|
+
stdout: streamOutput
|
|
446
|
+
? streamedOutput
|
|
447
|
+
? "\n"
|
|
448
|
+
: ""
|
|
449
|
+
: page(lines.join("\n")),
|
|
450
|
+
stderr: "",
|
|
451
|
+
payload: {
|
|
452
|
+
manifestPath,
|
|
453
|
+
projectScope,
|
|
454
|
+
dryRun: false,
|
|
455
|
+
sourceCount: entries.length,
|
|
456
|
+
requestedSkillTotal,
|
|
457
|
+
pendingSkillTotal: 0,
|
|
458
|
+
installTaskCount: 0,
|
|
459
|
+
continueOnError: options.continueOnError,
|
|
460
|
+
},
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// The Skills CLI mutates a shared global lock file, so live installs stay
|
|
465
|
+
// serialized even when dry-run previews show a higher theoretical limit.
|
|
466
|
+
const concurrency = 1;
|
|
467
|
+
appendLine(
|
|
468
|
+
`Installing from ${installTasks.length} source(s) with concurrency ${concurrency} (lock-safe mode).`,
|
|
469
|
+
);
|
|
470
|
+
const totalProgressSkills = options.reinstallAll
|
|
471
|
+
? requestedSkillTotal
|
|
472
|
+
: pendingSkillTotal;
|
|
473
|
+
let completedProgressSkills = 0;
|
|
474
|
+
if (showSkillProgress && totalProgressSkills > 0) {
|
|
475
|
+
emitStreamLine(progressLine(0, totalProgressSkills));
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
let failures: ConcurrentFailure[] = [];
|
|
479
|
+
|
|
480
|
+
try {
|
|
481
|
+
failures = await runWithConcurrency(
|
|
482
|
+
installTasks,
|
|
483
|
+
concurrency,
|
|
484
|
+
async (task) => {
|
|
485
|
+
appendLine(
|
|
486
|
+
`Installing source ${task.order}/${entries.length}: ${task.entry.source}`,
|
|
487
|
+
);
|
|
488
|
+
if (options.installAll) {
|
|
489
|
+
appendLine(
|
|
490
|
+
`Installing all skills from ${task.entry.source} via --all...`,
|
|
491
|
+
);
|
|
492
|
+
} else if (
|
|
493
|
+
!options.reinstallAll &&
|
|
494
|
+
task.entry.alreadyInstalledSkills.length > 0
|
|
495
|
+
) {
|
|
496
|
+
appendLine(
|
|
497
|
+
`Installing ${task.skillsToInstall.length} missing skill(s) from ${task.entry.source} and skipping ${task.entry.alreadyInstalledSkills.length} already installed.`,
|
|
498
|
+
);
|
|
499
|
+
} else {
|
|
500
|
+
appendLine(
|
|
501
|
+
`Installing ${task.skillsToInstall.length} skill(s) from ${task.entry.source}...`,
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const result = await dependencies.runBunx(task.command);
|
|
506
|
+
if (result.stdout.trim()) {
|
|
507
|
+
appendLine(result.stdout.trimEnd());
|
|
508
|
+
}
|
|
509
|
+
if (result.stderr.trim()) {
|
|
510
|
+
errors.push(errorMessage(result.stderr.trimEnd()));
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (showSkillProgress && totalProgressSkills > 0) {
|
|
514
|
+
completedProgressSkills += task.skillsToInstall.length;
|
|
515
|
+
emitStreamLine(
|
|
516
|
+
progressLine(completedProgressSkills, totalProgressSkills),
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (result.exitCode !== 0) {
|
|
521
|
+
const message = `Install failed for source '${task.entry.source}' with exit code ${result.exitCode}`;
|
|
522
|
+
if (options.continueOnError) {
|
|
523
|
+
errors.push(message);
|
|
524
|
+
}
|
|
525
|
+
throw new Error(message);
|
|
526
|
+
}
|
|
527
|
+
},
|
|
528
|
+
{ continueOnError: options.continueOnError },
|
|
529
|
+
);
|
|
530
|
+
} catch (error) {
|
|
531
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
532
|
+
return {
|
|
533
|
+
exitCode: 1,
|
|
534
|
+
stdout: streamOutput
|
|
535
|
+
? streamedOutput
|
|
536
|
+
? "\n"
|
|
537
|
+
: ""
|
|
538
|
+
: lines.length > 0
|
|
539
|
+
? page(lines.join("\n"))
|
|
540
|
+
: "",
|
|
541
|
+
stderr: page([...errors, errorMessage(message)].join("\n")),
|
|
542
|
+
errorCode: resolveRestoreErrorCode(message),
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const summarySkillTotal =
|
|
547
|
+
options.installAll || options.reinstallAll
|
|
548
|
+
? scheduledSkillTotal
|
|
549
|
+
: pendingSkillTotal;
|
|
550
|
+
|
|
551
|
+
if (options.continueOnError && failures.length > 0) {
|
|
552
|
+
appendLine(
|
|
553
|
+
buildRestoreSummary(
|
|
554
|
+
entries.length,
|
|
555
|
+
installTasks.length,
|
|
556
|
+
summarySkillTotal,
|
|
557
|
+
options.installAll,
|
|
558
|
+
),
|
|
559
|
+
);
|
|
560
|
+
errors.push(
|
|
561
|
+
errorMessage(
|
|
562
|
+
`Completed restore with ${failures.length} source failure(s).`,
|
|
563
|
+
),
|
|
564
|
+
);
|
|
565
|
+
return {
|
|
566
|
+
exitCode: 0,
|
|
567
|
+
stdout: streamOutput
|
|
568
|
+
? streamedOutput
|
|
569
|
+
? "\n"
|
|
570
|
+
: ""
|
|
571
|
+
: lines.length > 0
|
|
572
|
+
? page(lines.join("\n"))
|
|
573
|
+
: "",
|
|
574
|
+
stderr: page(errors.join("\n")),
|
|
575
|
+
payload: {
|
|
576
|
+
manifestPath,
|
|
577
|
+
projectScope,
|
|
578
|
+
dryRun: false,
|
|
579
|
+
sourceCount: entries.length,
|
|
580
|
+
requestedSkillTotal,
|
|
581
|
+
pendingSkillTotal: summarySkillTotal,
|
|
582
|
+
installTaskCount: installTasks.length,
|
|
583
|
+
continueOnError: options.continueOnError,
|
|
584
|
+
},
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
if (!projectScope) {
|
|
589
|
+
const globalSkillStateDiff =
|
|
590
|
+
await verifyGlobalSkillStateConsistency(dependencies);
|
|
591
|
+
if (
|
|
592
|
+
globalSkillStateDiff.missingFromLock.length > 0 ||
|
|
593
|
+
globalSkillStateDiff.missingFromDirectory.length > 0
|
|
594
|
+
) {
|
|
595
|
+
return {
|
|
596
|
+
exitCode: 1,
|
|
597
|
+
stdout: streamOutput
|
|
598
|
+
? streamedOutput
|
|
599
|
+
? "\n"
|
|
600
|
+
: ""
|
|
601
|
+
: lines.length > 0
|
|
602
|
+
? page(lines.join("\n"))
|
|
603
|
+
: "",
|
|
604
|
+
stderr: page(
|
|
605
|
+
formatGlobalSkillStateMismatch(globalSkillStateDiff)
|
|
606
|
+
.map((line) => errorMessage(line))
|
|
607
|
+
.join("\n"),
|
|
608
|
+
),
|
|
609
|
+
errorCode: "GLOBAL_STATE_MISMATCH",
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
appendLine(
|
|
615
|
+
buildRestoreSummary(
|
|
616
|
+
entries.length,
|
|
617
|
+
installTasks.length,
|
|
618
|
+
summarySkillTotal,
|
|
619
|
+
options.installAll,
|
|
620
|
+
),
|
|
621
|
+
);
|
|
622
|
+
return {
|
|
623
|
+
exitCode: 0,
|
|
624
|
+
stdout: streamOutput
|
|
625
|
+
? streamedOutput
|
|
626
|
+
? "\n"
|
|
627
|
+
: ""
|
|
628
|
+
: lines.length > 0
|
|
629
|
+
? page(lines.join("\n"))
|
|
630
|
+
: "",
|
|
631
|
+
stderr: errors.length > 0 ? page(errors.join("\n")) : "",
|
|
632
|
+
payload: {
|
|
633
|
+
manifestPath,
|
|
634
|
+
projectScope,
|
|
635
|
+
dryRun: false,
|
|
636
|
+
sourceCount: entries.length,
|
|
637
|
+
requestedSkillTotal,
|
|
638
|
+
pendingSkillTotal: summarySkillTotal,
|
|
639
|
+
installTaskCount: installTasks.length,
|
|
640
|
+
continueOnError: options.continueOnError,
|
|
641
|
+
},
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Restores skills from a manifest while preserving the manifest's scope by default.
|
|
647
|
+
*/
|
|
648
|
+
export async function runRestore(
|
|
649
|
+
argv: string[],
|
|
650
|
+
dependencies: RestoreDependencies = {},
|
|
651
|
+
): Promise<RestoreRunResult> {
|
|
652
|
+
try {
|
|
653
|
+
const options = parseRestoreArgs(argv);
|
|
654
|
+
if (options.help) {
|
|
655
|
+
return { exitCode: 0, stdout: printHelp(), stderr: "" };
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const resolvedDependencies: Required<RestoreDependencies> = {
|
|
659
|
+
readManifest: dependencies.readManifest ?? parseManifest,
|
|
660
|
+
getInstalledSkillNames:
|
|
661
|
+
dependencies.getInstalledSkillNames ?? getInstalledSkillNames,
|
|
662
|
+
readTrackedGlobalSkillNames:
|
|
663
|
+
dependencies.readTrackedGlobalSkillNames ?? readTrackedGlobalSkillNames,
|
|
664
|
+
readInstalledGlobalSkillNames:
|
|
665
|
+
dependencies.readInstalledGlobalSkillNames ??
|
|
666
|
+
readInstalledGlobalSkillNames,
|
|
667
|
+
runBunx: dependencies.runBunx ?? runBunx,
|
|
668
|
+
reportProgress: dependencies.reportProgress ?? (() => {}),
|
|
669
|
+
streamOutput: dependencies.streamOutput ?? false,
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
const manifest = resolvedDependencies.readManifest(options.manifestPath);
|
|
673
|
+
return runRestoreFromManifest(
|
|
674
|
+
manifest,
|
|
675
|
+
options.manifestPath,
|
|
676
|
+
options,
|
|
677
|
+
resolvedDependencies,
|
|
678
|
+
);
|
|
679
|
+
} catch (error) {
|
|
680
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
681
|
+
const errorCode = resolveRestoreErrorCode(message);
|
|
682
|
+
return {
|
|
683
|
+
exitCode: 1,
|
|
684
|
+
stdout: "",
|
|
685
|
+
stderr: formatRestoreError(message, errorCode),
|
|
686
|
+
errorCode,
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
}
|