feat(cli): improve restore progress and command guidance

This commit is contained in:
xixu-me committed 2026-03-27 01:50:45 +08:00
1 parent 29374db01f
commit 8c8315c181
12 files changed
+140 -57

No files matched your search

+1
View File
@@ -9,6 +9,7 @@ coverage/
.nyc_output/
*.tgz
*.tsbuildinfo
skvlt.yaml
# Bun caches and legacy lockfiles
.bun/
+11 -11
View File
@@ -34,7 +34,7 @@ function printHelp(): string {
"The CLI for backing up and restoring Agent Skills",
"",
helpHeading("Usage"),
" skvlt <command> [options]",
" bunx skvlt <command> [options]",
"",
helpHeading("Commands"),
" backup Backup installed skills into a manifest",
@@ -48,11 +48,11 @@ function printHelp(): string {
" -v, --version Print the installed package version",
"",
helpHeading("Examples"),
helpExample("skvlt backup --output ./skvlt.yaml"),
helpExample("skvlt completion bash"),
helpExample("skvlt doctor"),
helpExample("skvlt restore --all"),
helpExample("skvlt restore --only-source xixu-me/skills"),
helpExample("bunx skvlt backup --output ./skvlt.yaml"),
helpExample("bunx skvlt completion bash"),
helpExample("bunx skvlt doctor"),
helpExample("bunx skvlt restore --all"),
helpExample("bunx skvlt restore --only-source xixu-me/skills"),
helpFooter("https://github.com/xixu-me/skills-vault"),
].join("\n")}`;
}
@@ -65,10 +65,10 @@ function printLanding(): string {
return `\n${[
dimGray("The CLI for backing up and restoring Agent Skills"),
"",
landingCommand("skvlt backup", "Backup installed skills"),
landingCommand("skvlt restore", "Restore from skvlt.yaml"),
landingCommand("skvlt doctor", "Inspect local setup"),
landingCommand("skvlt completion", "Print shell completions"),
landingCommand("bunx skvlt backup", "Backup installed skills"),
landingCommand("bunx skvlt restore", "Restore from skvlt.yaml"),
landingCommand("bunx skvlt doctor", "Inspect local setup"),
landingCommand("bunx skvlt completion", "Print shell completions"),
"",
`${dimGray("Explore the open-source repo at")} ${textGray("https://github.com/xixu-me/skills-vault")}\n`,
].join("\n")}`;
@@ -177,7 +177,7 @@ export async function runCli(
exitCode: 1,
stdout: "",
stderr: page(
`Unknown command: ${command}\nRun ${bold("skvlt --help")} for usage.`,
`Unknown command: ${command}\nRun ${bold("bunx skvlt --help")} for usage.`,
),
errorCode: "UNKNOWN_COMMAND",
};
+6 -6
View File
@@ -48,7 +48,7 @@ function printHelp(): string {
"Backup installed skills into skvlt.yaml.",
"",
helpHeading("Usage"),
" skvlt backup [options]",
" bunx skvlt backup [options]",
"",
helpHeading("Options"),
" --output <path> Output path for skvlt.yaml",
@@ -63,10 +63,10 @@ function printHelp(): string {
" - Project scope currently requires --lock-file because the Skills CLI does not expose a discoverable project lock path here.",
"",
helpHeading("Examples"),
helpExample("skvlt backup"),
helpExample(`skvlt backup --output ${formatCliPath("./skvlt.yaml")}`),
helpExample("bunx skvlt backup"),
helpExample(`bunx skvlt backup --output ${formatCliPath("./skvlt.yaml")}`),
helpExample(
`skvlt backup --project-scope --lock-file ${formatCliPath("./skills-lock.json")}`,
`bunx skvlt backup --project-scope --lock-file ${formatCliPath("./skills-lock.json")}`,
),
helpFooter("https://github.com/xixu-me/skills-vault"),
].join("\n")}`;
@@ -108,8 +108,8 @@ function formatBackupError(message: string, errorCode: string): string {
if (errorCode === "INVALID_ARGUMENT") {
return commandErrorPage(
message,
"skvlt backup [options]",
`skvlt backup --output ${formatCliPath("./skvlt.yaml")}`,
"bunx skvlt backup [options]",
`bunx skvlt backup --output ${formatCliPath("./skvlt.yaml")}`,
);
}
+7 -7
View File
@@ -21,18 +21,18 @@ export type CompletionRunResult = {
function printHelp(): string {
return `\n${[
"Print shell completion scripts for skvlt.",
"Print shell completion scripts for bunx skvlt.",
"",
helpHeading("Usage"),
" skvlt completion <bash|zsh|powershell>",
" bunx skvlt completion <bash|zsh|powershell>",
"",
helpHeading("Options"),
" --help Show this help",
"",
helpHeading("Examples"),
helpExample("skvlt completion bash"),
helpExample("skvlt completion zsh"),
helpExample("skvlt completion powershell"),
helpExample("bunx skvlt completion bash"),
helpExample("bunx skvlt completion zsh"),
helpExample("bunx skvlt completion powershell"),
helpFooter("https://github.com/xixu-me/skills-vault"),
].join("\n")}`;
}
@@ -154,8 +154,8 @@ function formatCompletionError(message: string, errorCode: string): string {
if (errorCode === "INVALID_ARGUMENT") {
return commandErrorPage(
message,
"skvlt completion <bash|zsh|powershell>",
"skvlt completion bash",
"bunx skvlt completion <bash|zsh|powershell>",
"bunx skvlt completion bash",
);
}
+5 -5
View File
@@ -59,15 +59,15 @@ function printHelp(): string {
"Inspect the local Skills Vault environment and installation state.",
"",
helpHeading("Usage"),
" skvlt doctor [options]",
" bunx skvlt doctor [options]",
"",
helpHeading("Options"),
" --manifest <path> Manifest path to inspect",
" --help Show this help",
"",
helpHeading("Examples"),
helpExample("skvlt doctor"),
helpExample("skvlt doctor --manifest ./skvlt.yaml"),
helpExample("bunx skvlt doctor"),
helpExample("bunx skvlt doctor --manifest ./skvlt.yaml"),
helpFooter("https://github.com/xixu-me/skills-vault"),
].join("\n")}`;
}
@@ -110,8 +110,8 @@ function formatDoctorError(message: string): string {
) {
return commandErrorPage(
message,
"skvlt doctor [options]",
"skvlt doctor --manifest ./skvlt.yaml",
"bunx skvlt doctor [options]",
"bunx skvlt doctor --manifest ./skvlt.yaml",
);
}
+30 -11
View File
@@ -30,6 +30,7 @@ import {
helpHeading,
infoLine,
page,
progressLine,
summaryLine,
} from "../internal/cli/theme";
import { formatCliPath } from "../internal/paths/format-cli-path";
@@ -76,7 +77,7 @@ function printHelp(): string {
"Restore skills from skvlt.yaml.",
"",
helpHeading("Usage"),
" skvlt restore [options]",
" bunx skvlt restore [options]",
"",
helpHeading("Options"),
" --manifest <path> Path to skvlt.yaml",
@@ -97,9 +98,9 @@ function printHelp(): string {
` - Global restore verifies ${formatCliPath(defaultGlobalSkillsPath)} against ${formatCliPath(defaultGlobalLockFilePath)}`,
"",
helpHeading("Examples"),
helpExample("skvlt restore --only-source xixu-me/skills"),
helpExample("skvlt restore --all"),
helpExample("skvlt restore --project-scope --manifest ./skvlt.yaml"),
helpExample("bunx skvlt restore --only-source xixu-me/skills"),
helpExample("bunx skvlt restore --all"),
helpExample("bunx skvlt restore --project-scope --manifest ./skvlt.yaml"),
helpFooter("https://github.com/xixu-me/skills-vault"),
].join("\n")}`;
}
@@ -142,8 +143,8 @@ function formatRestoreError(message: string, errorCode: string): string {
if (errorCode === "INVALID_ARGUMENT") {
return commandErrorPage(
message,
"skvlt restore [options]",
"skvlt restore --all",
"bunx skvlt restore [options]",
"bunx skvlt restore --all",
);
}
@@ -218,16 +219,19 @@ async function runRestoreFromManifest(
const streamOutput = dependencies.streamOutput;
const reportProgress = dependencies.reportProgress;
let streamedOutput = false;
const emitStreamLine = (line: string) => {
if (!streamedOutput) {
reportProgress("\n");
streamedOutput = true;
}
reportProgress(`${line}\n`);
};
const appendLine = (line: string) => {
const formattedLine = line.includes("summary:")
? summaryLine(line)
: infoLine(line);
if (streamOutput) {
if (!streamedOutput) {
reportProgress("\n");
streamedOutput = true;
}
reportProgress(`${formattedLine}\n`);
emitStreamLine(formattedLine);
return;
}
@@ -302,6 +306,7 @@ async function runRestoreFromManifest(
const installedLookup = options.installAll
? null
: await dependencies.getInstalledSkillNames(projectScope);
const showSkillProgress = streamOutput && !options.installAll;
let pendingSkillTotal = 0;
let alreadyInstalledTotal = 0;
const scheduledSkillTotal =
@@ -462,6 +467,13 @@ async function runRestoreFromManifest(
appendLine(
`Installing from ${installTasks.length} source(s) with concurrency ${concurrency} (lock-safe mode).`,
);
const totalProgressSkills = options.reinstallAll
? requestedSkillTotal
: pendingSkillTotal;
let completedProgressSkills = 0;
if (showSkillProgress && totalProgressSkills > 0) {
emitStreamLine(progressLine(0, totalProgressSkills));
}
let failures: ConcurrentFailure[] = [];
@@ -498,6 +510,13 @@ async function runRestoreFromManifest(
errors.push(errorMessage(result.stderr.trimEnd()));
}
if (showSkillProgress && totalProgressSkills > 0) {
completedProgressSkills += task.skillsToInstall.length;
emitStreamLine(
progressLine(completedProgressSkills, totalProgressSkills),
);
}
if (result.exitCode !== 0) {
const message = `Install failed for source '${task.entry.source}' with exit code ${result.exitCode}`;
if (options.continueOnError) {
+13
View File
@@ -103,6 +103,19 @@ export function infoLine(text: string): string {
return text;
}
export function progressLine(
completed: number,
total: number,
label: string = "skills",
): string {
const width = 10;
const safeTotal = Math.max(total, 1);
const safeCompleted = Math.min(Math.max(completed, 0), total);
const filled = Math.floor((safeCompleted / safeTotal) * width);
const bar = `[${"#".repeat(filled)}${"-".repeat(width - filled)}]`;
return `${grayDark("Progress:")} ${textGray(bar)} ${dimGray(`${safeCompleted}/${total} ${label}`)}`;
}
export function summaryLine(text: string): string {
return grayDark(text);
}
+3 -3
View File
@@ -26,9 +26,9 @@ test("renders backup argument errors as a usage page", async () => {
expect(plainStderr).toContain("ERROR");
expect(plainStderr).toContain("--output requires a path");
expect(plainStderr).toContain("Usage:");
expect(plainStderr).toContain("skvlt backup [options]");
expect(plainStderr).toContain("bunx skvlt backup [options]");
expect(plainStderr).toContain("Example:");
expect(plainStderr).toContain("skvlt backup --output");
expect(plainStderr).toContain("bunx skvlt backup --output");
expect(plainStderr.endsWith("\n\n")).toBe(true);
});
@@ -188,7 +188,7 @@ test("shows help before project-scope validation", async () => {
expect(result.stdout).toContain("\x1b[1mUsage:\x1b[0m");
expect(result.stdout).toContain("\x1b[1mExamples:\x1b[0m");
expect(result.stdout).toContain("\x1b[38;5;102m$\x1b[0m");
expect(result.stdout).not.toContain("skvlt backup --dry-run");
expect(result.stdout).not.toContain("bunx skvlt backup --dry-run");
expect(plainStdout).toContain(
"\n\nExplore the open-source repo at https://github.com/xixu-me/skills-vault",
);
+8 -6
View File
@@ -67,14 +67,16 @@ test("prints top-level help", async () => {
"The CLI for backing up and restoring Agent Skills",
);
expect(result.stdout).toContain("Usage:");
expect(result.stdout).toContain("skvlt <command> [options]");
expect(result.stdout).toContain("bunx skvlt <command> [options]");
expect(result.stdout).toContain("backup");
expect(result.stdout).toContain("completion");
expect(result.stdout).toContain("doctor");
expect(result.stdout).toContain("restore");
expect(result.stdout).toContain("skvlt restore --only-source xixu-me/skills");
expect(result.stdout).not.toContain("skvlt backup --dry-run");
expect(result.stdout).not.toContain("skvlt restore --dry-run");
expect(result.stdout).toContain(
"bunx skvlt restore --only-source xixu-me/skills",
);
expect(result.stdout).not.toContain("bunx skvlt backup --dry-run");
expect(result.stdout).not.toContain("bunx skvlt restore --dry-run");
expect(result.stdout).not.toContain("anthropics/skills");
expect(result.stdout).toContain("\x1b[1mUsage:\x1b[0m");
expect(result.stdout).toContain("\x1b[1mExamples:\x1b[0m");
@@ -126,10 +128,10 @@ test("rejects unknown commands with guidance", async () => {
expect(result.stdout).toBe("");
expect(plainStderr.startsWith("\n")).toBe(true);
expect(plainStderr).toContain("Unknown command: sync");
expect(plainStderr).toContain("Run skvlt --help for usage.");
expect(plainStderr).toContain("Run bunx skvlt --help for usage.");
expect(plainStderr.endsWith("\n\n")).toBe(true);
expect(result.stderr).toContain("Unknown command: sync");
expect(result.stderr).toContain("\x1b[1mskvlt --help\x1b[0m");
expect(result.stderr).toContain("\x1b[1mbunx skvlt --help\x1b[0m");
});
test("returns JSON errors for unknown commands", async () => {
+3 -3
View File
@@ -12,7 +12,7 @@ test("shows completion help text", async () => {
expect(result.exitCode).toBe(0);
expect(plainStdout.startsWith("\n")).toBe(true);
expect(result.stdout).toContain("Usage:");
expect(result.stdout).toContain("skvlt completion");
expect(result.stdout).toContain("bunx skvlt completion");
expect(result.stdout).toContain("\x1b[1mUsage:\x1b[0m");
expect(plainStdout).toContain(
"\n\nExplore the open-source repo at https://github.com/xixu-me/skills-vault",
@@ -50,9 +50,9 @@ test("rejects unsupported shells with a stable error code", async () => {
expect(plainStderr).toContain("ERROR");
expect(plainStderr).toContain("Unsupported shell: fish");
expect(plainStderr).toContain("Usage:");
expect(plainStderr).toContain("skvlt completion <bash|zsh|powershell>");
expect(plainStderr).toContain("bunx skvlt completion <bash|zsh|powershell>");
expect(plainStderr).toContain("Example:");
expect(plainStderr).toContain("skvlt completion bash");
expect(plainStderr).toContain("bunx skvlt completion bash");
expect(plainStderr.endsWith("\n\n")).toBe(true);
expect(result.stderr).toContain("\x1b[");
expect(result.stderr).toContain("ERROR");
+1 -1
View File
@@ -13,7 +13,7 @@ test("shows doctor help text", async () => {
expect(result.exitCode).toBe(0);
expect(plainStdout.startsWith("\n")).toBe(true);
expect(result.stdout).toContain("Usage:");
expect(result.stdout).toContain("skvlt doctor");
expect(result.stdout).toContain("bunx skvlt doctor");
expect(result.stdout).toContain("\x1b[1mUsage:\x1b[0m");
expect(plainStdout).toContain(
"\n\nExplore the open-source repo at https://github.com/xixu-me/skills-vault",
+52 -4
View File
@@ -30,8 +30,8 @@ test("shows restore help text", async () => {
expect(result.exitCode).toBe(0);
expect(plainStdout.startsWith("\n")).toBe(true);
expect(result.stdout).toContain("Usage:");
expect(result.stdout).toContain("skvlt restore");
expect(result.stdout).not.toContain("skvlt restore --dry-run");
expect(result.stdout).toContain("bunx skvlt restore");
expect(result.stdout).not.toContain("bunx skvlt restore --dry-run");
expect(plainStdout).toContain(
"\n\nExplore the open-source repo at https://github.com/xixu-me/skills-vault",
);
@@ -75,6 +75,26 @@ test("prints per-source install commands during restore dry-run", async () => {
expect(result.stdout).not.toContain("github/awesome-copilot");
});
test("does not stream a skill progress bar during restore dry-run", async () => {
let streamed = "";
const result = await runRestore(
["--dry-run", "--only-source", "anthropics/skills"],
{
readManifest: () => sampleManifest,
streamOutput: true,
reportProgress: (chunk) => {
streamed += chunk;
},
},
);
const plainStream = streamed.replace(/\x1b\[[0-9;]*m/g, "");
expect(result.exitCode).toBe(0);
expect(plainStream).not.toContain("Progress:");
});
test("prints passthrough skills add options during restore dry-run", async () => {
const result = await runRestore(
[
@@ -172,6 +192,34 @@ test("restores only missing skills during live execution", async () => {
]);
});
test("streams a skill progress bar during live restore execution", async () => {
let streamed = "";
const result = await runRestore(["--manifest", "ignored.yaml"], {
readManifest: () => sampleManifest,
getInstalledSkillNames: async () => new Set(["alpha"]),
readTrackedGlobalSkillNames: async () =>
new Set(["alpha", "beta", "gamma"]),
readInstalledGlobalSkillNames: async () =>
new Set(["alpha", "beta", "gamma"]),
runBunx: async () => ({ exitCode: 0, stdout: "", stderr: "" }),
streamOutput: true,
reportProgress: (chunk) => {
streamed += chunk;
},
});
const plainStream = streamed.replace(/\x1b\[[0-9;]*m/g, "");
expect(result.exitCode).toBe(0);
expect(plainStream).toContain(
"Installing from 2 source(s) with concurrency 1 (lock-safe mode).",
);
expect(plainStream).toContain("Progress: [----------] 0/2 skills");
expect(plainStream).toContain("Progress: [#####-----] 1/2 skills");
expect(plainStream).toContain("Progress: [##########] 2/2 skills");
});
test("passes agent and copy options through during live restore", async () => {
const commands: string[][] = [];
@@ -429,8 +477,8 @@ test("renders restore argument errors as a usage page", async () => {
expect(plainStderr).toContain("ERROR");
expect(plainStderr).toContain("--concurrency requires a value");
expect(plainStderr).toContain("Usage:");
expect(plainStderr).toContain("skvlt restore [options]");
expect(plainStderr).toContain("bunx skvlt restore [options]");
expect(plainStderr).toContain("Example:");
expect(plainStderr).toContain("skvlt restore --all");
expect(plainStderr).toContain("bunx skvlt restore --all");
expect(plainStderr.endsWith("\n\n")).toBe(true);
});