diff --git a/.gitignore b/.gitignore index c8c8c9a..20e01bf 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ coverage/ .nyc_output/ *.tgz *.tsbuildinfo +skvlt.yaml # Bun caches and legacy lockfiles .bun/ diff --git a/src/cli.ts b/src/cli.ts index 62a46ba..8a9f1ec 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -34,7 +34,7 @@ function printHelp(): string { "The CLI for backing up and restoring Agent Skills", "", helpHeading("Usage"), - " skvlt [options]", + " bunx skvlt [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", }; diff --git a/src/commands/backup.ts b/src/commands/backup.ts index 4ec26a7..8dbb354 100644 --- a/src/commands/backup.ts +++ b/src/commands/backup.ts @@ -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 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")}`, ); } diff --git a/src/commands/completion.ts b/src/commands/completion.ts index a26c10b..74331c0 100644 --- a/src/commands/completion.ts +++ b/src/commands/completion.ts @@ -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 ", + " bunx skvlt completion ", "", 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 ", - "skvlt completion bash", + "bunx skvlt completion ", + "bunx skvlt completion bash", ); } diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index c6972da..2616233 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -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 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", ); } diff --git a/src/commands/restore.ts b/src/commands/restore.ts index 7e84cc3..c4500d5 100644 --- a/src/commands/restore.ts +++ b/src/commands/restore.ts @@ -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 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) { diff --git a/src/internal/cli/theme.ts b/src/internal/cli/theme.ts index 1d56d4d..80c342c 100644 --- a/src/internal/cli/theme.ts +++ b/src/internal/cli/theme.ts @@ -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); } diff --git a/test/backup.test.ts b/test/backup.test.ts index 767b118..6ae5f98 100644 --- a/test/backup.test.ts +++ b/test/backup.test.ts @@ -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", ); diff --git a/test/cli.test.ts b/test/cli.test.ts index d4f06b5..2ef7ed5 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -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 [options]"); + expect(result.stdout).toContain("bunx skvlt [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 () => { diff --git a/test/completion.test.ts b/test/completion.test.ts index 472f90c..8488e54 100644 --- a/test/completion.test.ts +++ b/test/completion.test.ts @@ -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 "); + expect(plainStderr).toContain("bunx skvlt completion "); 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"); diff --git a/test/doctor.test.ts b/test/doctor.test.ts index 643865b..746d25f 100644 --- a/test/doctor.test.ts +++ b/test/doctor.test.ts @@ -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", diff --git a/test/restore.test.ts b/test/restore.test.ts index e22d979..fc109a1 100644 --- a/test/restore.test.ts +++ b/test/restore.test.ts @@ -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); });