feat(cli): improve restore progress and command guidance
This commit is contained in:
1 parent
29374db01f
commit
8c8315c181
12 files changed
+140
-57
No files matched your search
@@ -9,6 +9,7 @@ coverage/
|
||||
.nyc_output/
|
||||
*.tgz
|
||||
*.tsbuildinfo
|
||||
skvlt.yaml
|
||||
|
||||
# Bun caches and legacy lockfiles
|
||||
.bun/
|
||||
|
||||
+11
-11
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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")}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user