feat(restore): support skills add passthrough flags

This commit is contained in:
xixu-me committed 2026-03-25 13:48:16 +08:00
1 parent 17c27cdc84
commit 0359706be3
4 files changed
+181 -18

No files matched your search

+48 -12
View File
@@ -39,7 +39,10 @@ Usage:
Options:
--manifest <path> Path to manifests/skills.yaml
--only-source <source> Restore only one source; repeatable
--agent <agent> Target one agent; repeatable
--project-scope Restore to project scope instead of global
--copy Copy files instead of symlinking to agent directories
--all Install all skills from the selected sources
--reinstall-all Reinstall everything instead of only missing skills
--continue-on-error Keep going if one source fails to install
--concurrency <count> Maximum sources to install at the same time
@@ -116,13 +119,32 @@ function selectSources(
function buildInstallCommand(
source: string,
skills: string[],
projectScope: boolean,
options: Pick<
ReturnType<typeof parseRestoreArgs>,
"agents" | "copy" | "installAll" | "projectScope"
>,
): string[] {
const command = ["skills", "add", source];
if (!projectScope) {
if (!options.projectScope) {
command.push("-g");
}
command.push("-y", "--skill", ...skills);
command.push("-y");
for (const agent of options.agents) {
command.push("--agent", agent);
}
if (options.copy) {
command.push("--copy");
}
if (options.installAll) {
command.push("--all");
return command;
}
command.push("--skill", ...skills);
return command;
}
@@ -162,7 +184,7 @@ async function runRestoreFromManifest(
...buildInstallCommand(
entry.source,
entry.skills,
options.projectScope,
options,
),
]
.map(encodeShellArg)
@@ -177,9 +199,9 @@ async function runRestoreFromManifest(
};
}
const installedLookup = await dependencies.getInstalledSkillNames(
options.projectScope,
);
const installedLookup = options.installAll
? null
: await dependencies.getInstalledSkillNames(options.projectScope);
let pendingSkillTotal = 0;
let alreadyInstalledTotal = 0;
const installTasks: Array<{
@@ -189,6 +211,15 @@ async function runRestoreFromManifest(
}> = [];
for (const entry of entries) {
if (options.installAll) {
installTasks.push({
entry,
skillsToInstall: [],
command: buildInstallCommand(entry.source, entry.skills, options),
});
continue;
}
if (options.reinstallAll) {
entry.pendingSkills = [...entry.skills];
pendingSkillTotal += entry.count;
@@ -198,17 +229,17 @@ async function runRestoreFromManifest(
command: buildInstallCommand(
entry.source,
entry.skills,
options.projectScope,
options,
),
});
continue;
}
entry.pendingSkills = entry.skills.filter(
(skill) => !installedLookup.has(skill),
(skill) => !installedLookup!.has(skill),
);
entry.alreadyInstalledSkills = entry.skills.filter((skill) =>
installedLookup.has(skill),
installedLookup!.has(skill),
);
pendingSkillTotal += entry.pendingSkills.length;
alreadyInstalledTotal += entry.alreadyInstalledSkills.length;
@@ -220,13 +251,17 @@ async function runRestoreFromManifest(
command: buildInstallCommand(
entry.source,
entry.pendingSkills,
options.projectScope,
options,
),
});
}
}
if (options.reinstallAll) {
if (options.installAll) {
lines.push(
`Installing all skills from ${entries.length} source(s) using '${scopeLabel}' scope.`,
);
} else if (options.reinstallAll) {
lines.push(
`Reinstalling ${requestedSkillTotal} skill(s) from ${entries.length} source(s) using '${scopeLabel}' scope.`,
);
@@ -271,6 +306,7 @@ async function runRestoreFromManifest(
concurrency,
async (task) => {
if (
!options.installAll &&
!options.reinstallAll &&
task.entry.alreadyInstalledSkills.length > 0
) {
+21
View File
@@ -3,8 +3,11 @@ import { defaultManifestPath } from "../paths/defaults";
export type RestoreOptions = {
manifestPath: string;
onlySources: string[];
agents: string[];
projectScope: boolean;
reinstallAll: boolean;
installAll: boolean;
copy: boolean;
continueOnError: boolean;
concurrency?: number;
dryRun: boolean;
@@ -28,8 +31,11 @@ export function parseRestoreArgs(argv: string[]): RestoreOptions {
const options: RestoreOptions = {
manifestPath: defaultManifestPath,
onlySources: [],
agents: [],
projectScope: false,
reinstallAll: false,
installAll: false,
copy: false,
continueOnError: false,
concurrency: undefined,
dryRun: false,
@@ -58,12 +64,27 @@ export function parseRestoreArgs(argv: string[]): RestoreOptions {
index += 1;
break;
}
case "--agent": {
const value = argv[index + 1];
if (!value || value.startsWith("-")) {
throw new Error("--agent requires a value");
}
options.agents.push(value);
index += 1;
break;
}
case "--project-scope":
options.projectScope = true;
break;
case "--reinstall-all":
options.reinstallAll = true;
break;
case "--all":
options.installAll = true;
break;
case "--copy":
options.copy = true;
break;
case "--continue-on-error":
options.continueOnError = true;
break;
+12 -6
View File
@@ -16,13 +16,19 @@ test("publishes the expected package and skvlt binary metadata", () => {
expect(existsSync(resolve(repoRoot, packageJson.bin!.skvlt))).toBe(true);
});
test("documents the public skvlt CLI in the readme", () => {
const readme = readFileSync(resolve(repoRoot, "README.md"), "utf8");
test("uses package scripts that point at the skvlt cli entrypoint", () => {
const packageJson = JSON.parse(
readFileSync(resolve(repoRoot, "package.json"), "utf8"),
) as {
scripts?: Record<string, string>;
};
expect(readme).toContain("# Skills Vault");
expect(readme).toContain("`@xixu-me/skills-vault`");
expect(readme).toContain("`skvlt`");
expect(readme).not.toContain("bun scripts/");
expect(packageJson.scripts?.backup).toBe("bun run ./src/cli.ts backup");
expect(packageJson.scripts?.restore).toBe("bun run ./src/cli.ts restore");
expect(packageJson.scripts?.format).toContain("prettier --write");
expect(Object.values(packageJson.scripts ?? {})).not.toContain(
expect.stringContaining("scripts/"),
);
});
test("leaves no production files in scripts", () => {
+100
View File
@@ -40,6 +40,31 @@ test("prints per-source install commands during restore dry-run", async () => {
expect(result.stdout).not.toContain("github/awesome-copilot");
});
test("prints passthrough skills add options during restore dry-run", async () => {
const result = await runRestore(
[
"--dry-run",
"--only-source",
"anthropics/skills",
"--agent",
"codex",
"--agent",
"claude-code",
"--copy",
"--all",
],
{
readManifest: () => sampleManifest,
},
);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain(
"[dry-run] bunx skills add anthropics/skills -g -y --agent codex --agent claude-code --copy --all",
);
expect(result.stdout).not.toContain("--skill");
});
test("restores only missing skills during live execution", async () => {
const commands: string[][] = [];
@@ -63,6 +88,81 @@ test("restores only missing skills during live execution", async () => {
]);
});
test("passes agent and copy options through during live restore", async () => {
const commands: string[][] = [];
const result = await runRestore(
[
"--manifest",
"ignored.yaml",
"--agent",
"codex",
"--agent",
"claude-code",
"--copy",
],
{
readManifest: () => sampleManifest,
getInstalledSkillNames: async () => new Set(["alpha"]),
runBunx: async (args) => {
commands.push(args);
return { exitCode: 0, stdout: "", stderr: "" };
},
},
);
expect(result.exitCode).toBe(0);
expect(commands).toEqual([
[
"skills",
"add",
"anthropics/skills",
"-g",
"-y",
"--agent",
"codex",
"--agent",
"claude-code",
"--copy",
"--skill",
"beta",
],
[
"skills",
"add",
"github/awesome-copilot",
"-g",
"-y",
"--agent",
"codex",
"--agent",
"claude-code",
"--copy",
"--skill",
"gamma",
],
]);
});
test("installs all source skills when --all is enabled", async () => {
const commands: string[][] = [];
const result = await runRestore(["--manifest", "ignored.yaml", "--all"], {
readManifest: () => sampleManifest,
getInstalledSkillNames: async () => new Set(["alpha", "beta", "gamma"]),
runBunx: async (args) => {
commands.push(args);
return { exitCode: 0, stdout: "", stderr: "" };
},
});
expect(result.exitCode).toBe(0);
expect(commands).toEqual([
["skills", "add", "anthropics/skills", "-g", "-y", "--all"],
["skills", "add", "github/awesome-copilot", "-g", "-y", "--all"],
]);
});
test("continues and returns success when continue-on-error is enabled", async () => {
const result = await runRestore(
["--manifest", "ignored.yaml", "--continue-on-error"],