refactor(package): publish skvlt cli

This commit is contained in:
xixu-me committed 2026-03-25 12:12:25 +08:00
commit 32545773c9
22 files changed
+2548

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Xi Xu
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+25
View File
@@ -0,0 +1,25 @@
# Skills Vault
`Skills Vault` is a Bun-based CLI package for backing up and restoring installed agent skills.
## Package
- Package name: `@xixu-me/skills-vault`
- CLI: `skvlt`
## Usage
```bash
skvlt backup
skvlt backup --dry-run
skvlt restore --dry-run
skvlt restore --only-source anthropics/skills
```
## Development
```bash
bun test
bun run ./src/cli.ts backup --dry-run
bun run ./src/cli.ts restore --dry-run --only-source anthropics/skills
```
@@ -0,0 +1,369 @@
# Skills Vault CLI Package Reorganization Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Reorganize the repository into a publishable pure CLI package named `@xixu-me/skills-vault` with the public binary `skvlt`, while preserving the existing `backup` and `restore` behavior.
**Architecture:** Introduce a single CLI dispatcher in `src/cli.ts`, move command orchestration into `src/commands/`, isolate non-public helpers under `src/internal/`, and relocate tests into `test/` with behavior-oriented names. The migration should preserve behavior through TDD: write the failing tests first, then move logic, then remove the legacy `scripts/` production layout.
**Tech Stack:** Bun 1.3.x, TypeScript, Bun test runner, Node-compatible filesystem/process APIs
---
## File Map
### Create
- `src/cli.ts`
- `src/commands/backup.ts`
- `src/commands/restore.ts`
- `src/internal/args/parse-backup-args.ts`
- `src/internal/args/parse-restore-args.ts`
- `src/internal/install/resolve-install-concurrency.ts`
- `src/internal/install/run-with-concurrency.ts`
- `src/internal/manifest/build-manifest.ts`
- `src/internal/manifest/parse-manifest.ts`
- `src/internal/paths/defaults.ts`
- `src/internal/process/run-bunx.ts`
- `test/cli.test.ts`
- `test/backup.test.ts`
- `test/restore.test.ts`
- `test/install-concurrency.test.ts`
- `README.md`
### Modify
- `package.json`
### Delete After Migration
- `scripts/backup.ts`
- `scripts/restore.ts`
- `scripts/restore-lib.ts`
- `scripts/restore-lib.test.ts`
## Task 1: Establish the CLI Shell and Package Metadata
**Files:**
- Create: `src/cli.ts`
- Create: `test/cli.test.ts`
- Modify: `package.json`
- [ ] **Step 1: Write the failing CLI dispatch test**
```ts
import { expect, test } from "bun:test";
import { runCli } from "../src/cli";
test("dispatches the restore command", async () => {
const result = await runCli(["restore", "--help"]);
expect(result.exitCode).toBe(0);
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `bun test test/cli.test.ts`
Expected: FAIL because `../src/cli` does not exist yet.
- [ ] **Step 3: Write minimal CLI implementation**
```ts
type CliResult = { exitCode: number };
export async function runCli(argv: string[]): Promise<CliResult> {
const [command] = argv;
if (command === "backup") {
return { exitCode: 0 };
}
if (command === "restore") {
return { exitCode: 0 };
}
console.error("Usage: skvlt <backup|restore>");
return { exitCode: 1 };
}
if (import.meta.main) {
const result = await runCli(Bun.argv.slice(2));
process.exit(result.exitCode);
}
```
- [ ] **Step 4: Update package metadata for the new CLI**
```json
{
"name": "@xixu-me/skills-vault",
"bin": {
"skvlt": "./src/cli.ts"
},
"scripts": {
"backup": "bun run ./src/cli.ts backup",
"backup:dry-run": "bun run ./src/cli.ts backup --dry-run",
"restore": "bun run ./src/cli.ts restore",
"restore:dry-run": "bun run ./src/cli.ts restore --dry-run"
}
}
```
- [ ] **Step 5: Run test to verify it passes**
Run: `bun test test/cli.test.ts`
Expected: PASS
- [ ] **Step 6: Smoke test the top-level CLI**
Run: `bun run ./src/cli.ts`
Expected: exit code `1` and top-level usage text for `skvlt`
- [ ] **Step 7: Commit**
```bash
git add package.json src/cli.ts test/cli.test.ts
git commit -m "refactor(cli): add top-level skvlt entrypoint"
```
## Task 2: Move Backup Into Command and Internal Modules
**Files:**
- Create: `src/commands/backup.ts`
- Create: `src/internal/args/parse-backup-args.ts`
- Create: `src/internal/manifest/build-manifest.ts`
- Create: `src/internal/paths/defaults.ts`
- Create: `src/internal/process/run-bunx.ts`
- Create: `test/backup.test.ts`
- Modify: `src/cli.ts`
- Delete later: `scripts/backup.ts`
- [ ] **Step 1: Write the failing backup dry-run test**
```ts
import { expect, test } from "bun:test";
import { runBackup } from "../src/commands/backup";
test("prints manifest YAML during backup dry-run", async () => {
const result = await runBackup(["--dry-run"]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("sources:");
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `bun test test/backup.test.ts`
Expected: FAIL because `../src/commands/backup` does not exist yet.
- [ ] **Step 3: Extract shared process and path helpers**
```ts
// src/internal/process/run-bunx.ts
export async function runBunx(args: string[]) {
const proc = Bun.spawn({ cmd: ["bunx", ...args], stdout: "pipe", stderr: "pipe" });
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
return { stdout, stderr, exitCode };
}
```
- [ ] **Step 4: Move backup orchestration into `src/commands/backup.ts`**
```ts
export async function runBackup(argv: string[]) {
const options = parseBackupArgs(argv);
const installedSkillNames = await getInstalledSkillNames(options.projectScope);
const manifest = buildManifest(installedSkillNames, options.lockFilePath);
return options.dryRun
? { exitCode: 0, stdout: manifest, stderr: "" }
: await writeBackupManifest(options.outputPath, manifest);
}
```
- [ ] **Step 5: Wire the CLI dispatcher to the backup command**
Run: `bun test test/cli.test.ts test/backup.test.ts`
Expected: PASS
- [ ] **Step 6: Smoke test backup dry-run through the new CLI**
Run: `bun run ./src/cli.ts backup --dry-run`
Expected: prints YAML with `sources:` and exits `0`
- [ ] **Step 7: Commit**
```bash
git add src/cli.ts src/commands/backup.ts src/internal/args/parse-backup-args.ts src/internal/manifest/build-manifest.ts src/internal/paths/defaults.ts src/internal/process/run-bunx.ts test/backup.test.ts
git commit -m "refactor(backup): move backup flow into command modules"
```
## Task 3: Move Restore Concurrency Helpers Into Internal Install Modules
**Files:**
- Create: `src/internal/install/resolve-install-concurrency.ts`
- Create: `src/internal/install/run-with-concurrency.ts`
- Create: `test/install-concurrency.test.ts`
- Delete later: `scripts/restore-lib.ts`
- Delete later: `scripts/restore-lib.test.ts`
- [ ] **Step 1: Copy the existing concurrency coverage into the new test location and add one naming assertion**
```ts
import { expect, test } from "bun:test";
import { resolveInstallConcurrency } from "../src/internal/install/resolve-install-concurrency";
import { runWithConcurrency } from "../src/internal/install/run-with-concurrency";
test("resolveInstallConcurrency caps auto concurrency at task count", () => {
expect(resolveInstallConcurrency({ taskCount: 2, availableParallelism: 16 })).toBe(2);
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `bun test test/install-concurrency.test.ts`
Expected: FAIL because the new install modules do not exist yet.
- [ ] **Step 3: Move the concurrency implementations into focused internal files**
```ts
// src/internal/install/resolve-install-concurrency.ts
export function resolveInstallConcurrency(options: ResolveOptions): number {
// keep half-of-available-parallelism default with safe caps
}
// src/internal/install/run-with-concurrency.ts
export async function runWithConcurrency<T>(items: T[], concurrency: number, worker: Worker<T>, options: Options) {
// preserve bounded scheduling and continue-on-error semantics
}
```
- [ ] **Step 4: Run the concurrency tests to verify they pass**
Run: `bun test test/install-concurrency.test.ts`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/internal/install/resolve-install-concurrency.ts src/internal/install/run-with-concurrency.ts test/install-concurrency.test.ts
git commit -m "refactor(restore): isolate install concurrency helpers"
```
## Task 4: Move Restore Into Command and Manifest Modules
**Files:**
- Create: `src/commands/restore.ts`
- Create: `src/internal/args/parse-restore-args.ts`
- Create: `src/internal/manifest/parse-manifest.ts`
- Create: `test/restore.test.ts`
- Modify: `src/cli.ts`
- Modify: `src/internal/process/run-bunx.ts`
- Delete later: `scripts/restore.ts`
- [ ] **Step 1: Write the failing restore dry-run test**
```ts
import { expect, test } from "bun:test";
import { runRestore } from "../src/commands/restore";
test("prints per-source install commands during restore dry-run", async () => {
const result = await runRestore(["--dry-run"]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("[dry-run] bunx skills add");
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `bun test test/restore.test.ts`
Expected: FAIL because `../src/commands/restore` does not exist yet.
- [ ] **Step 3: Move manifest parsing and restore orchestration into the new structure**
```ts
export async function runRestore(argv: string[]) {
const options = parseRestoreArgs(argv);
const manifest = parseManifest(options.manifestPath);
const plan = createRestorePlan(manifest, options);
return options.dryRun ? previewRestorePlan(plan) : await executeRestorePlan(plan, options);
}
```
- [ ] **Step 4: Reconnect CLI dispatch to the restore command**
Run: `bun test test/cli.test.ts test/restore.test.ts test/install-concurrency.test.ts`
Expected: PASS
- [ ] **Step 5: Smoke test restore dry-run through the CLI**
Run: `bun run ./src/cli.ts restore --dry-run --concurrency 4 --only-source anthropics/skills`
Expected: prints dry-run commands and a concurrency summary using the new module layout
- [ ] **Step 6: Commit**
```bash
git add src/cli.ts src/commands/restore.ts src/internal/args/parse-restore-args.ts src/internal/manifest/parse-manifest.ts src/internal/process/run-bunx.ts test/restore.test.ts
git commit -m "refactor(restore): move restore flow into command modules"
```
## Task 5: Remove Legacy Script Layout and Finish Packaging
**Files:**
- Create: `README.md`
- Modify: `package.json`
- Delete: `scripts/backup.ts`
- Delete: `scripts/restore.ts`
- Delete: `scripts/restore-lib.ts`
- Delete: `scripts/restore-lib.test.ts`
- [ ] **Step 1: Write the failing packaging/readme expectation test or checklist**
```md
- package name is @xixu-me/skills-vault
- CLI docs use skvlt
- no production code remains under scripts/
```
- [ ] **Step 2: Delete the legacy production script files after the new tests are green**
Run: `rg --files scripts`
Expected: only temporary migration leftovers remain before deletion, then no production code files remain there
- [ ] **Step 3: Write the README around the single supported CLI**
````md
# Skills Vault
## Install
```bash
bun install
```
## Usage
```bash
skvlt backup
skvlt restore --dry-run
```
````
- [ ] **Step 4: Run full verification**
Run: `bun test`
Expected: all tests pass
Run: `bun run ./src/cli.ts backup --dry-run`
Expected: YAML manifest output
Run: `bun run ./src/cli.ts restore --dry-run --only-source anthropics/skills`
Expected: dry-run install command output
- [ ] **Step 5: Commit**
```bash
git add package.json README.md src test
git rm scripts/backup.ts scripts/restore.ts scripts/restore-lib.ts scripts/restore-lib.test.ts
git commit -m "refactor(package): publish as skvlt CLI package"
```
@@ -0,0 +1,287 @@
# Skills Vault CLI Package Structure Design
**Date:** 2026-03-25
**Status:** Proposed
**Scope:** Reorganize the repository into a publishable CLI package with a clear public entrypoint and cleaner internal file naming.
## Goal
Turn the repository from a small script-oriented workspace into a pure CLI package with:
- one stable public command
- clear separation between command entrypoints and internal implementation
- file names that describe behavior instead of temporary script intent
- test files that map cleanly to CLI behavior and internal concurrency logic
## Brand and Naming Contract
- Product brand: `Skills Vault`
- Package scope: `@xixu-me`
- CLI binary: `skvlt`
- Repository name: `skills-vault`
- Fixed package name: `@xixu-me/skills-vault`
## Current State
The repository is currently organized around a `scripts/` folder:
- `scripts/backup.ts`
- `scripts/restore.ts`
- `scripts/restore-lib.ts`
This works for local execution, but it blurs a few boundaries:
- public CLI entrypoints and internal implementation live in the same folder
- `restore-lib.ts` reads like a temporary helper rather than a long-term package module
- tests are currently colocated with script-era naming instead of package-era behavior naming
- the future published interface is not obvious from the repository layout
## Design Principles
1. Treat the package as a pure CLI product, not a general-purpose library.
2. Expose exactly one stable public command: `skvlt`.
3. Keep command orchestration separate from lower-level helpers.
4. Use descriptive file names such as `parse-manifest.ts` instead of generic names such as `utils.ts`.
5. Mark non-public code as internal so future maintainers do not mistake it for supported API surface.
6. Keep test layout behavior-oriented and stable across future refactors.
## Target Structure
```text
skills-vault/
src/
cli.ts
commands/
backup.ts
restore.ts
internal/
args/
parse-backup-args.ts
parse-restore-args.ts
install/
resolve-install-concurrency.ts
run-with-concurrency.ts
manifest/
build-manifest.ts
parse-manifest.ts
paths/
defaults.ts
process/
run-bunx.ts
test/
backup.test.ts
restore.test.ts
install-concurrency.test.ts
manifests/
skills.yaml
package.json
README.md
LICENSE
```
## Responsibilities
### `src/cli.ts`
Top-level executable entrypoint.
Responsibilities:
- read the first subcommand
- dispatch to `backup` or `restore`
- print top-level help for invalid or missing commands
- normalize top-level error handling and exit codes
It should not contain manifest parsing, installation logic, or path computation.
### `src/commands/backup.ts`
Backup command orchestration.
Responsibilities:
- parse backup-specific arguments
- read installed skills and lock metadata
- build manifest content
- write output or print dry-run content
- emit backup-focused user messages
### `src/commands/restore.ts`
Restore command orchestration.
Responsibilities:
- parse restore-specific arguments
- parse manifest content
- compute pending install work
- perform dry-run previews
- invoke bounded concurrent installation
- report per-source install progress and failures
### `src/internal/args/*`
Argument parsing only.
Responsibilities:
- transform raw argv into strongly shaped options
- validate numeric values such as concurrency
- keep parsing rules close to each command without mixing them into business logic
### `src/internal/install/*`
Restore-only install execution helpers.
Responsibilities:
- derive safe default concurrency from device parallelism
- cap configured concurrency to meaningful task counts
- run source installs with bounded concurrency
- preserve failure behavior for `continue-on-error` vs fail-fast execution
### `src/internal/manifest/*`
Manifest transformation logic.
Responsibilities:
- parse YAML-like manifest structure into typed in-memory state
- build manifest output from installed skills and lock file data
### `src/internal/process/run-bunx.ts`
Single process-launch wrapper.
Responsibilities:
- run `bunx`
- capture stdout, stderr, and exit code consistently
- avoid duplicating spawn logic in each command
### `src/internal/paths/defaults.ts`
Default path derivation.
Responsibilities:
- compute default manifest path
- compute default global lock file path
- keep default filesystem behavior centralized
## Naming Rules
The reorganization should adopt these naming conventions:
- public entrypoints use short command-oriented names
- internal files use behavior names, not umbrella names
- avoid `lib.ts`, `utils.ts`, `helpers.ts`, or `common.ts`
- prefer one responsibility per file
- keep the `internal/` namespace explicit so the package does not accidentally imply a supported programmatic API
Examples:
- good: `parse-restore-args.ts`
- good: `run-with-concurrency.ts`
- poor: `restore-lib.ts`
- poor: `shared-utils.ts`
## Public Interface
The public interface becomes:
```bash
skvlt backup
skvlt restore
skvlt restore --dry-run
```
This is the only documented public contract.
Development-time convenience scripts may remain in `package.json`, but they should call the new CLI entrypoint rather than pointing at legacy script files. The repository should no longer treat `scripts/*.ts` paths as public or stable.
## Migration Plan
1. Create `src/`, `src/commands/`, `src/internal/`, and `test/`.
2. Move backup orchestration from `scripts/backup.ts` into `src/commands/backup.ts`.
3. Move restore orchestration from `scripts/restore.ts` into `src/commands/restore.ts`.
4. Split `scripts/restore-lib.ts` into `src/internal/install/resolve-install-concurrency.ts` and `src/internal/install/run-with-concurrency.ts`.
5. Extract shared `bunx` process logic into `src/internal/process/run-bunx.ts`.
6. Extract default paths into `src/internal/paths/defaults.ts`.
7. Extract command-specific argv parsing into `src/internal/args/`.
8. Move tests into `test/` and rename them to match behavior-level ownership.
9. Remove `scripts/` as a production code directory.
10. Update `package.json` so the package name is `@xixu-me/skills-vault` and the published CLI entrypoint is `skvlt`.
11. Add a `README.md` that documents `skvlt` as the single supported entrypoint.
## Error Handling
The structure change should preserve current operational semantics:
- invalid CLI input exits with code `1`
- dry-run never mutates install state
- restore fail-fast behavior remains the default
- `--continue-on-error` continues remaining source installs and reports final failures
- concurrency remains bounded and does not exceed useful work count
## Testing Strategy
The new layout should support three main testing layers:
### `test/backup.test.ts`
- backup command option parsing
- manifest generation behavior
- dry-run output behavior
### `test/restore.test.ts`
- restore command option parsing
- source filtering
- already-installed skip behavior
- dry-run behavior
- failure reporting behavior
### `test/install-concurrency.test.ts`
- automatic concurrency derivation
- configured concurrency clamping
- maximum active worker limit
- fail-fast scheduling behavior
- continue-on-error aggregation behavior
## Non-Goals
This reorganization does not attempt to:
- expose a reusable library API
- add new end-user CLI commands beyond `backup` and `restore`
- redesign the manifest format
- change installation providers or command semantics beyond current behavior
## Risks and Mitigations
### Risk: behavior drift during file moves
Mitigation:
- preserve existing tests where possible
- add focused tests around concurrency and command parsing before major movement
### Risk: accidental exposure of internal modules as public API
Mitigation:
- keep implementation under `src/internal/`
- document only the `skvlt` CLI command in `README.md`
### Risk: release confusion if old script paths remain documented
Mitigation:
- remove `scripts/` as a supported runtime path
- update all docs and package scripts to point at the single CLI entrypoint
## Recommendation
Proceed with the reorganization as a pure CLI package for `Skills Vault`, using the `@xixu-me` scope, the `skvlt` binary, explicit internal namespaces, behavior-based file names, and tests separated from runtime code.
+241
View File
@@ -0,0 +1,241 @@
total_sources: 24
total_skills: 141
sources:
'anthropics/skills':
count: 11
skills:
- 'algorithmic-art'
- 'canvas-design'
- 'doc-coauthoring'
- 'docx'
- 'mcp-builder'
- 'pdf'
- 'skill-creator'
- 'slack-gif-creator'
- 'theme-factory'
- 'webapp-testing'
- 'xlsx'
'blader/humanizer':
count: 1
skills:
- 'humanizer'
'callstackincubator/agent-skills':
count: 5
skills:
- 'github'
- 'react-native-best-practices'
- 'react-native-brownfield-migration'
- 'upgrading-react-native'
- 'validate-skills'
'chromedevtools/chrome-devtools-mcp':
count: 4
skills:
- 'a11y-debugging'
- 'chrome-devtools'
- 'debug-optimize-lcp'
- 'troubleshooting'
'cloudflare/skills':
count: 8
skills:
- 'agents-sdk'
- 'building-mcp-server-on-cloudflare'
- 'cloudflare'
- 'durable-objects'
- 'sandbox-sdk'
- 'web-perf'
- 'workers-best-practices'
- 'wrangler'
'coderabbitai/skills':
count: 1
skills:
- 'autofix'
'github/awesome-copilot':
count: 21
skills:
- 'agent-governance'
- 'agentic-eval'
- 'automate-this'
- 'cloud-design-patterns'
- 'codeql'
- 'create-agentsmd'
- 'create-architectural-decision-record'
- 'create-github-action-workflow-specification'
- 'create-readme'
- 'dependabot'
- 'documentation-writer'
- 'doublecheck'
- 'editorconfig'
- 'git-commit'
- 'github-issues'
- 'make-repo-contribution'
- 'mcp-cli'
- 'publish-to-pages'
- 'python-mcp-server-generator'
- 'refactor'
- 'secret-scanning'
'huggingface/skills':
count: 11
skills:
- 'hf-cli'
- 'huggingface-community-evals'
- 'huggingface-datasets'
- 'huggingface-gradio'
- 'huggingface-jobs'
- 'huggingface-llm-trainer'
- 'huggingface-paper-publisher'
- 'huggingface-papers'
- 'huggingface-trackio'
- 'huggingface-vision-trainer'
- 'transformers-js'
'langchain-ai/langchain-skills':
count: 11
skills:
- 'deep-agents-core'
- 'deep-agents-memory'
- 'deep-agents-orchestration'
- 'framework-selection'
- 'langchain-dependencies'
- 'langchain-fundamentals'
- 'langchain-middleware'
- 'langchain-rag'
- 'langgraph-fundamentals'
- 'langgraph-human-in-the-loop'
- 'langgraph-persistence'
'makenotion/skills':
count: 1
skills:
- 'notion-cli'
'mastra-ai/skills':
count: 1
skills:
- 'mastra'
'microsoft/playwright-cli':
count: 1
skills:
- 'playwright-cli'
'obra/superpowers':
count: 14
skills:
- 'brainstorming'
- 'dispatching-parallel-agents'
- 'executing-plans'
- 'finishing-a-development-branch'
- 'receiving-code-review'
- 'requesting-code-review'
- 'subagent-driven-development'
- 'systematic-debugging'
- 'test-driven-development'
- 'using-git-worktrees'
- 'using-superpowers'
- 'verification-before-completion'
- 'writing-plans'
- 'writing-skills'
'openai/skills':
count: 27
skills:
- 'aspnet-core'
- 'chatgpt-apps'
- 'develop-web-game'
- 'figma'
- 'frontend-skill'
- 'gh-address-comments'
- 'gh-fix-ci'
- 'imagegen'
- 'jupyter-notebook'
- 'linear'
- 'netlify-deploy'
- 'notion-knowledge-capture'
- 'notion-meeting-intelligence'
- 'notion-research-documentation'
- 'notion-spec-to-implementation'
- 'openai-docs'
- 'playwright-interactive'
- 'render-deploy'
- 'screenshot'
- 'security-ownership-map'
- 'security-threat-model'
- 'sentry'
- 'slides'
- 'sora'
- 'speech'
- 'transcribe'
- 'winui-app'
'redis/agent-skills':
count: 1
skills:
- 'redis-development'
'remotion-dev/skills':
count: 1
skills:
- 'remotion-best-practices'
'resciencelab/opc-skills':
count: 10
skills:
- 'archive'
- 'banner-creator'
- 'domain-hunter'
- 'logo-creator'
- 'nanobanana'
- 'producthunt'
- 'reddit'
- 'requesthunt'
- 'seo-geo'
- 'twitter'
'semgrep/skills':
count: 3
skills:
- 'code-security'
- 'llm-security'
- 'semgrep'
'supabase/agent-skills':
count: 1
skills:
- 'supabase-postgres-best-practices'
'upstash/context7':
count: 1
skills:
- 'context7-mcp'
'vercel-labs/agent-skills':
count: 4
skills:
- 'deploy-to-vercel'
- 'vercel-composition-patterns'
- 'vercel-react-best-practices'
- 'web-design-guidelines'
'vercel-labs/skills':
count: 1
skills:
- 'find-skills'
'xixu-me/xdrop':
count: 1
skills:
- 'xdrop'
'xixu-me/xget':
count: 1
skills:
- 'xget'
+22
View File
@@ -0,0 +1,22 @@
{
"name": "@xixu-me/skills-vault",
"version": "0.1.0",
"bin": {
"skvlt": "./src/cli.ts"
},
"engines": {
"bun": ">=1.3.11"
},
"packageManager": "bun@1.3.11",
"private": false,
"files": [
"src",
"manifests"
],
"scripts": {
"backup": "bun run ./src/cli.ts backup",
"backup:dry-run": "bun run ./src/cli.ts backup --dry-run",
"restore": "bun run ./src/cli.ts restore",
"restore:dry-run": "bun run ./src/cli.ts restore --dry-run"
}
}
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bun
import { runBackup } from "./commands/backup";
import { runRestore } from "./commands/restore";
type CliResult = {
exitCode: number;
stdout: string;
stderr: string;
};
export async function runCli(argv: string[]): Promise<CliResult> {
const [command] = argv;
if (command === "backup") {
return runBackup(argv.slice(1));
}
if (command === "restore") {
return runRestore(argv.slice(1));
}
return {
exitCode: 1,
stdout: "",
stderr: "Usage: skvlt <backup|restore>\n",
};
}
export async function main(
argv: string[] = process.argv.slice(2),
): Promise<number> {
const result = await runCli(argv);
if (result.stdout) {
process.stdout.write(result.stdout);
}
if (result.stderr) {
console.error(result.stderr.trimEnd());
}
return result.exitCode;
}
if (import.meta.main) {
const exitCode = await main();
process.exit(exitCode);
}
+114
View File
@@ -0,0 +1,114 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import {
defaultBackupOutputPath,
defaultGlobalLockFilePath,
} from "../internal/paths/defaults";
import { parseBackupArgs } from "../internal/args/parse-backup-args";
import { buildManifest, type LockFile } from "../internal/manifest/build-manifest";
import { runBunx } from "../internal/process/run-bunx";
export type BackupRunResult = {
exitCode: number;
stdout: string;
stderr: string;
};
export type BackupDependencies = {
getInstalledSkillNames?: (projectScope: boolean) => Promise<string[]>;
readLockFile?: (lockFilePath: string) => LockFile;
writeManifest?: (outputPath: string, manifest: string) => void;
};
function printHelp(): string {
return `Backup installed skills into manifests/skills.yaml.
Usage:
skvlt backup [options]
Options:
--output <path> Output path for manifests/skills.yaml
--lock-file <path> Skills lock file to read sources from
--project-scope Backup project-scoped installs instead of global installs
--dry-run Print YAML to stdout instead of writing a file
--help Show this help
Notes:
- Output defaults to: ${defaultBackupOutputPath}
- Global scope defaults to lock file: ${defaultGlobalLockFilePath}
- Project scope currently requires --lock-file because the Skills CLI does not expose a discoverable project lock path here.
Examples:
skvlt backup
skvlt backup --dry-run
skvlt backup --output .\\manifests\\skills.yaml
skvlt backup --project-scope --lock-file .\\skills-lock.json --dry-run
`;
}
async function getInstalledSkillNames(
projectScope: boolean,
): Promise<string[]> {
const args = ["skills", "ls", "--json"];
if (!projectScope) {
args.splice(2, 0, "-g");
}
const result = await runBunx(args);
if (result.exitCode !== 0) {
throw new Error(
`Unable to list installed skills for ${projectScope ? "project" : "global"} scope.\n${result.stderr || result.stdout}`,
);
}
const items = JSON.parse(result.stdout) as Array<{ name?: string }>;
return items
.map((item) => item.name)
.filter((name): name is string => typeof name === "string")
.sort((left, right) => left.localeCompare(right));
}
function readLockFile(lockFilePath: string): LockFile {
if (!existsSync(lockFilePath)) {
throw new Error(`Lock file not found: ${lockFilePath}`);
}
return JSON.parse(readFileSync(lockFilePath, "utf8")) as LockFile;
}
export async function runBackup(
argv: string[],
dependencies: BackupDependencies = {},
): Promise<BackupRunResult> {
try {
const options = parseBackupArgs(argv);
if (options.help) {
return { exitCode: 0, stdout: printHelp(), stderr: "" };
}
const getInstalledSkillNamesImpl =
dependencies.getInstalledSkillNames ?? getInstalledSkillNames;
const readLockFileImpl = dependencies.readLockFile ?? readLockFile;
const writeManifestImpl = dependencies.writeManifest ?? writeFileSync;
const installedSkillNames = await getInstalledSkillNamesImpl(
options.projectScope,
);
const lockFile = readLockFileImpl(options.lockFilePath);
const manifest = buildManifest(installedSkillNames, lockFile);
if (options.dryRun) {
return { exitCode: 0, stdout: manifest.yaml, stderr: "" };
}
writeManifestImpl(options.outputPath, manifest.yaml);
return {
exitCode: 0,
stdout: `Backed up ${manifest.totalSkills} skill(s) across ${manifest.totalSources} source(s) to ${options.outputPath}\n`,
stderr: "",
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { exitCode: 1, stdout: "", stderr: `${message}\n` };
}
}
+346
View File
@@ -0,0 +1,346 @@
import { parseRestoreArgs } from "../internal/args/parse-restore-args";
import { resolveInstallConcurrency } from "../internal/install/resolve-install-concurrency";
import {
runWithConcurrency,
type ConcurrentFailure,
} from "../internal/install/run-with-concurrency";
import {
parseManifest,
type Manifest,
} from "../internal/manifest/parse-manifest";
import { runBunx, type BunxResult } from "../internal/process/run-bunx";
type SourceRestoreEntry = {
source: string;
count: number;
skills: string[];
pendingSkills: string[];
alreadyInstalledSkills: string[];
};
export type RestoreRunResult = {
exitCode: number;
stdout: string;
stderr: string;
};
export type RestoreDependencies = {
readManifest?: (manifestPath: string) => Manifest;
getInstalledSkillNames?: (projectScope: boolean) => Promise<Set<string>>;
runBunx?: (args: string[]) => Promise<BunxResult>;
};
function printHelp(): string {
return `Restore skills from manifests/skills.yaml.
Usage:
skvlt restore [options]
Options:
--manifest <path> Path to manifests/skills.yaml
--only-source <source> Restore only one source; repeatable
--project-scope Restore to project scope instead of global
--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
--dry-run Print manifest-derived bunx commands without running them
--help Show this help
`;
}
function encodeShellArg(value: string): string {
if (/^[A-Za-z0-9_./:-]+$/.test(value)) {
return value;
}
return JSON.stringify(value);
}
async function getInstalledSkillNames(
projectScope: boolean,
): Promise<Set<string>> {
const args = ["skills", "ls", "--json"];
if (!projectScope) {
args.splice(2, 0, "-g");
}
const result = await runBunx(args);
if (result.exitCode !== 0) {
throw new Error(
`Unable to list installed skills for ${projectScope ? "project" : "global"} scope.\n${result.stderr || result.stdout}`,
);
}
const items = JSON.parse(result.stdout) as Array<{ name?: string }>;
return new Set(
items
.map((item) => item.name)
.filter((name): name is string => typeof name === "string")
.sort(),
);
}
function selectSources(
manifest: Manifest,
onlySources: string[],
): SourceRestoreEntry[] {
const entries: SourceRestoreEntry[] = Array.from(
manifest.sources.entries(),
).map(([source, entry]) => ({
source,
count: entry.count,
skills: [...entry.skills],
pendingSkills: [],
alreadyInstalledSkills: [],
}));
if (onlySources.length === 0) {
return entries;
}
const selected = entries.filter((entry) => onlySources.includes(entry.source));
const selectedSources = new Set(selected.map((entry) => entry.source));
const missing = onlySources.filter((source) => !selectedSources.has(source));
if (missing.length > 0) {
throw new Error(
`Requested source(s) not found in manifest: ${missing.join(", ")}`,
);
}
return selected;
}
function buildInstallCommand(
source: string,
skills: string[],
projectScope: boolean,
): string[] {
const command = ["skills", "add", source];
if (!projectScope) {
command.push("-g");
}
command.push("-y", "--skill", ...skills);
return command;
}
async function runRestoreFromManifest(
manifest: Manifest,
options: ReturnType<typeof parseRestoreArgs>,
dependencies: Required<RestoreDependencies>,
): Promise<RestoreRunResult> {
const lines: string[] = [];
const errors: string[] = [];
const entries = selectSources(manifest, options.onlySources);
const requestedSkillTotal = entries.reduce(
(sum, entry) => sum + entry.count,
0,
);
const scopeLabel = options.projectScope ? "project" : "global";
if (options.dryRun) {
const previewConcurrency = resolveInstallConcurrency({
configuredConcurrency: options.concurrency,
taskCount: entries.length,
});
lines.push(
`Previewing ${requestedSkillTotal} skill(s) from ${entries.length} source(s) using '${scopeLabel}' scope.`,
);
lines.push(
`Parallel install concurrency: ${previewConcurrency}${options.concurrency ? " (configured)" : " (auto)"}.`,
);
lines.push(
"Dry run does not inspect locally installed skills; commands include every skill listed in the manifest.",
);
for (const entry of entries) {
const preview = [
"bunx",
...buildInstallCommand(entry.source, entry.skills, options.projectScope),
]
.map(encodeShellArg)
.join(" ");
lines.push(`[dry-run] ${preview}`);
}
return {
exitCode: 0,
stdout: `${lines.join("\n")}\n`,
stderr: "",
};
}
const installedLookup = await dependencies.getInstalledSkillNames(
options.projectScope,
);
let pendingSkillTotal = 0;
let alreadyInstalledTotal = 0;
const installTasks: Array<{
entry: SourceRestoreEntry;
skillsToInstall: string[];
command: string[];
}> = [];
for (const entry of entries) {
if (options.reinstallAll) {
entry.pendingSkills = [...entry.skills];
pendingSkillTotal += entry.count;
installTasks.push({
entry,
skillsToInstall: [...entry.skills],
command: buildInstallCommand(
entry.source,
entry.skills,
options.projectScope,
),
});
continue;
}
entry.pendingSkills = entry.skills.filter(
(skill) => !installedLookup.has(skill),
);
entry.alreadyInstalledSkills = entry.skills.filter((skill) =>
installedLookup.has(skill),
);
pendingSkillTotal += entry.pendingSkills.length;
alreadyInstalledTotal += entry.alreadyInstalledSkills.length;
if (entry.pendingSkills.length > 0) {
installTasks.push({
entry,
skillsToInstall: [...entry.pendingSkills],
command: buildInstallCommand(
entry.source,
entry.pendingSkills,
options.projectScope,
),
});
}
}
if (options.reinstallAll) {
lines.push(
`Reinstalling ${requestedSkillTotal} skill(s) from ${entries.length} source(s) using '${scopeLabel}' scope.`,
);
} else {
lines.push(
`Restoring up to ${requestedSkillTotal} skill(s) from ${entries.length} source(s) using '${scopeLabel}' scope.`,
);
lines.push(
`${pendingSkillTotal} pending, ${alreadyInstalledTotal} already installed.`,
);
}
for (const entry of entries) {
if (!options.reinstallAll && entry.pendingSkills.length === 0) {
lines.push(
`Skipping ${entry.source}: all ${entry.count} skill(s) already installed.`,
);
}
}
if (installTasks.length === 0) {
return {
exitCode: 0,
stdout: `${lines.join("\n")}\n`,
stderr: "",
};
}
const concurrency = resolveInstallConcurrency({
configuredConcurrency: options.concurrency,
taskCount: installTasks.length,
});
lines.push(
`Installing from ${installTasks.length} source(s) with concurrency ${concurrency}${options.concurrency ? " (configured)" : " (auto)"}.`,
);
let failures: ConcurrentFailure[] = [];
try {
failures = await runWithConcurrency(
installTasks,
concurrency,
async (task) => {
if (
!options.reinstallAll &&
task.entry.alreadyInstalledSkills.length > 0
) {
lines.push(
`Installing ${task.skillsToInstall.length} missing skill(s) from ${task.entry.source} and skipping ${task.entry.alreadyInstalledSkills.length} already installed.`,
);
} else {
lines.push(
`Installing ${task.skillsToInstall.length} skill(s) from ${task.entry.source}...`,
);
}
const result = await dependencies.runBunx(task.command);
if (result.stdout.trim()) {
lines.push(result.stdout.trimEnd());
}
if (result.stderr.trim()) {
errors.push(result.stderr.trimEnd());
}
if (result.exitCode !== 0) {
const message = `Install failed for source '${task.entry.source}' with exit code ${result.exitCode}`;
if (options.continueOnError) {
errors.push(message);
}
throw new Error(message);
}
},
{ continueOnError: options.continueOnError },
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
exitCode: 1,
stdout: lines.length > 0 ? `${lines.join("\n")}\n` : "",
stderr: `${[...errors, message].join("\n")}\n`,
};
}
if (options.continueOnError && failures.length > 0) {
errors.push(`Completed restore with ${failures.length} source failure(s).`);
return {
exitCode: 0,
stdout: lines.length > 0 ? `${lines.join("\n")}\n` : "",
stderr: `${errors.join("\n")}\n`,
};
}
return {
exitCode: 0,
stdout: lines.length > 0 ? `${lines.join("\n")}\n` : "",
stderr: errors.length > 0 ? `${errors.join("\n")}\n` : "",
};
}
export async function runRestore(
argv: string[],
dependencies: RestoreDependencies = {},
): Promise<RestoreRunResult> {
try {
const options = parseRestoreArgs(argv);
if (options.help) {
return { exitCode: 0, stdout: printHelp(), stderr: "" };
}
const resolvedDependencies: Required<RestoreDependencies> = {
readManifest: dependencies.readManifest ?? parseManifest,
getInstalledSkillNames:
dependencies.getInstalledSkillNames ?? getInstalledSkillNames,
runBunx: dependencies.runBunx ?? runBunx,
};
const manifest = resolvedDependencies.readManifest(options.manifestPath);
return runRestoreFromManifest(manifest, options, resolvedDependencies);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { exitCode: 1, stdout: "", stderr: `${message}\n` };
}
}
+77
View File
@@ -0,0 +1,77 @@
import {
defaultBackupOutputPath,
defaultGlobalLockFilePath,
} from "../paths/defaults";
export type BackupOptions = {
outputPath: string;
lockFilePath: string;
lockFileProvided: boolean;
projectScope: boolean;
dryRun: boolean;
help: boolean;
};
export function parseBackupArgs(argv: string[]): BackupOptions {
const options: BackupOptions = {
outputPath: defaultBackupOutputPath,
lockFilePath: defaultGlobalLockFilePath,
lockFileProvided: false,
projectScope: false,
dryRun: false,
help: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
switch (arg) {
case "--output": {
const value = argv[index + 1];
if (!value || value.startsWith("-")) {
throw new Error("--output requires a path");
}
options.outputPath = value;
index += 1;
break;
}
case "--lock-file": {
const value = argv[index + 1];
if (!value || value.startsWith("-")) {
throw new Error("--lock-file requires a path");
}
options.lockFilePath = value;
options.lockFileProvided = true;
index += 1;
break;
}
case "--project-scope":
options.projectScope = true;
break;
case "--dry-run":
options.dryRun = true;
break;
case "--help":
case "-h":
options.help = true;
break;
default:
throw new Error(`Unknown argument: ${arg}`);
}
}
if (options.help) {
return options;
}
if (
options.projectScope &&
!options.lockFileProvided
) {
throw new Error(
"--project-scope requires --lock-file because no project lock file could be auto-discovered in this workspace.",
);
}
return options;
}
+98
View File
@@ -0,0 +1,98 @@
import { defaultManifestPath } from "../paths/defaults";
export type RestoreOptions = {
manifestPath: string;
onlySources: string[];
projectScope: boolean;
reinstallAll: boolean;
continueOnError: boolean;
concurrency?: number;
dryRun: boolean;
help: boolean;
};
function parsePositiveIntegerOption(
value: string,
optionName: string,
): number {
if (!/^\d+$/.test(value)) {
throw new Error(`${optionName} requires a positive integer`);
}
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed < 1) {
throw new Error(`${optionName} requires a positive integer`);
}
return parsed;
}
export function parseRestoreArgs(argv: string[]): RestoreOptions {
const options: RestoreOptions = {
manifestPath: defaultManifestPath,
onlySources: [],
projectScope: false,
reinstallAll: false,
continueOnError: false,
concurrency: undefined,
dryRun: false,
help: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
switch (arg) {
case "--manifest": {
const value = argv[index + 1];
if (!value || value.startsWith("-")) {
throw new Error("--manifest requires a path");
}
options.manifestPath = value;
index += 1;
break;
}
case "--only-source": {
const value = argv[index + 1];
if (!value || value.startsWith("-")) {
throw new Error("--only-source requires a value");
}
options.onlySources.push(value);
index += 1;
break;
}
case "--project-scope":
options.projectScope = true;
break;
case "--reinstall-all":
options.reinstallAll = true;
break;
case "--continue-on-error":
options.continueOnError = true;
break;
case "--concurrency": {
const value = argv[index + 1];
if (!value || value.startsWith("-")) {
throw new Error("--concurrency requires a value");
}
options.concurrency = parsePositiveIntegerOption(
value,
"--concurrency",
);
index += 1;
break;
}
case "--dry-run":
options.dryRun = true;
break;
case "--help":
case "-h":
options.help = true;
break;
default:
throw new Error(`Unknown argument: ${arg}`);
}
}
return options;
}
@@ -0,0 +1,68 @@
import { availableParallelism } from "node:os";
export type ResolveInstallConcurrencyOptions = {
configuredConcurrency?: number;
taskCount: number;
availableParallelism?: number;
};
const maxAutoConcurrency = 8;
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
function normalizeTaskCount(taskCount: number): number {
return Number.isFinite(taskCount) && taskCount > 0
? Math.trunc(taskCount)
: 1;
}
function detectAvailableParallelism(): number {
try {
const detected = availableParallelism();
return Number.isFinite(detected) && detected > 0 ? detected : 1;
} catch {
return 1;
}
}
export function parsePositiveIntegerOption(
value: string,
optionName: string,
): number {
if (!/^\d+$/.test(value)) {
throw new Error(`${optionName} requires a positive integer`);
}
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed < 1) {
throw new Error(`${optionName} requires a positive integer`);
}
return parsed;
}
export function resolveInstallConcurrency(
options: ResolveInstallConcurrencyOptions,
): number {
const taskCount = normalizeTaskCount(options.taskCount);
const maxConcurrency = Math.min(taskCount, maxAutoConcurrency);
if (
typeof options.configuredConcurrency === "number" &&
Number.isFinite(options.configuredConcurrency)
) {
return clamp(Math.trunc(options.configuredConcurrency), 1, taskCount);
}
const detectedParallelism =
typeof options.availableParallelism === "number" &&
Number.isFinite(options.availableParallelism) &&
options.availableParallelism > 0
? options.availableParallelism
: detectAvailableParallelism();
const suggestedConcurrency = Math.ceil(detectedParallelism / 2);
return clamp(suggestedConcurrency, 1, maxConcurrency);
}
@@ -0,0 +1,71 @@
export type ConcurrentFailure = {
index: number;
error: unknown;
};
export type RunWithConcurrencyOptions = {
continueOnError: boolean;
};
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
function normalizeConcurrency(concurrency: number, taskCount: number): number {
const truncatedConcurrency = Math.trunc(concurrency);
if (!Number.isFinite(truncatedConcurrency)) {
return 1;
}
return clamp(truncatedConcurrency, 1, taskCount);
}
export async function runWithConcurrency<T>(
items: T[],
concurrency: number,
worker: (item: T, index: number) => Promise<void>,
options: RunWithConcurrencyOptions,
): Promise<ConcurrentFailure[]> {
if (items.length === 0) {
return [];
}
const failures: ConcurrentFailure[] = [];
let nextIndex = 0;
let firstError: unknown = null;
const workerCount = normalizeConcurrency(concurrency, items.length);
async function runWorker(): Promise<void> {
while (true) {
if (!options.continueOnError && firstError !== null) {
return;
}
const currentIndex = nextIndex;
if (currentIndex >= items.length) {
return;
}
nextIndex += 1;
try {
await worker(items[currentIndex] as T, currentIndex);
} catch (error: unknown) {
failures.push({ index: currentIndex, error });
if (!options.continueOnError && firstError === null) {
firstError = error;
}
}
}
}
await Promise.all(
Array.from({ length: workerCount }, () => runWorker()),
);
if (!options.continueOnError && firstError !== null) {
throw firstError;
}
return failures;
}
+76
View File
@@ -0,0 +1,76 @@
type LockSkillEntry = {
source?: string;
};
export type LockFile = {
skills?: Record<string, LockSkillEntry>;
};
export type ManifestBuildResult = {
yaml: string;
totalSources: number;
totalSkills: number;
};
function encodeYamlScalar(value: string): string {
return value.replaceAll("'", "''");
}
export function buildManifest(
installedSkillNames: string[],
lockFile: LockFile,
): ManifestBuildResult {
const lockSkills = lockFile.skills ?? {};
const missingSources: string[] = [];
const groups = new Map<string, string[]>();
for (const skillName of installedSkillNames) {
const source = lockSkills[skillName]?.source;
if (!source) {
missingSources.push(skillName);
continue;
}
const group = groups.get(source);
if (group) {
group.push(skillName);
} else {
groups.set(source, [skillName]);
}
}
if (missingSources.length > 0) {
throw new Error(
`Installed skill(s) missing tracked source in lock file: ${missingSources.join(", ")}`,
);
}
const sortedSources = Array.from(groups.keys()).sort((left, right) =>
left.localeCompare(right),
);
const lines: string[] = [];
lines.push(`total_sources: ${sortedSources.length}`);
lines.push(`total_skills: ${installedSkillNames.length}`);
lines.push("");
lines.push("sources:");
for (const source of sortedSources) {
const skills = [...(groups.get(source) ?? [])].sort((left, right) =>
left.localeCompare(right),
);
lines.push(` '${encodeYamlScalar(source)}':`);
lines.push(` count: ${skills.length}`);
lines.push(" skills:");
for (const skill of skills) {
lines.push(` - '${encodeYamlScalar(skill)}'`);
}
lines.push("");
}
return {
yaml: `${lines.join("\n")}\n`,
totalSources: sortedSources.length,
totalSkills: installedSkillNames.length,
};
}
+181
View File
@@ -0,0 +1,181 @@
import { existsSync, readFileSync } from "node:fs";
export type ManifestSourceEntry = {
count: number;
skills: string[];
};
export type Manifest = {
totalSources: number;
totalSkills: number;
sources: Map<string, ManifestSourceEntry>;
};
function decodeYamlScalar(value: string): string {
return value.replaceAll("''", "'");
}
function decodeDoubleQuotedYamlScalar(value: string): string {
return JSON.parse(`"${value}"`) as string;
}
function parseQuotedSourceLine(line: string): string | null {
let match = line.match(/^ '((?:[^']|'')*)':$/);
if (match) {
return decodeYamlScalar(match[1]);
}
match = line.match(/^ "((?:[^"\\]|\\.)*)":$/);
if (match) {
return decodeDoubleQuotedYamlScalar(match[1]);
}
return null;
}
function parseQuotedSkillLine(line: string): string | null {
let match = line.match(/^ - '((?:[^']|'')*)'$/);
if (match) {
return decodeYamlScalar(match[1]);
}
match = line.match(/^ - "((?:[^"\\]|\\.)*)"$/);
if (match) {
return decodeDoubleQuotedYamlScalar(match[1]);
}
return null;
}
export function parseManifestText(text: string, sourceLabel: string): Manifest {
const lines = text.split(/\r?\n/);
const sources = new Map<string, ManifestSourceEntry>();
let totalSources: number | null = null;
let totalSkills: number | null = null;
let currentSource: string | null = null;
lines.forEach((rawLine, index) => {
const line = rawLine.trimEnd();
const lineNumber = index + 1;
if (line.trim().length === 0) {
return;
}
let match = line.match(/^total_sources:\s+(\d+)$/);
if (match) {
totalSources = Number(match[1]);
return;
}
match = line.match(/^total_skills:\s+(\d+)$/);
if (match) {
totalSkills = Number(match[1]);
return;
}
if (line === "sources:") {
return;
}
const parsedSource = parseQuotedSourceLine(line);
if (parsedSource !== null) {
currentSource = parsedSource;
sources.set(currentSource, { count: Number.NaN, skills: [] });
return;
}
match = line.match(/^ count:\s+(\d+)$/);
if (match) {
if (!currentSource) {
throw new Error(
`Found count before source at ${sourceLabel}:${lineNumber}`,
);
}
const entry = sources.get(currentSource);
if (!entry) {
throw new Error(
`Internal parser error at ${sourceLabel}:${lineNumber}`,
);
}
entry.count = Number(match[1]);
return;
}
if (line === " skills:") {
if (!currentSource) {
throw new Error(
`Found skills block before source at ${sourceLabel}:${lineNumber}`,
);
}
return;
}
const parsedSkill = parseQuotedSkillLine(line);
if (parsedSkill !== null) {
if (!currentSource) {
throw new Error(
`Found skill entry before source at ${sourceLabel}:${lineNumber}`,
);
}
const entry = sources.get(currentSource);
if (!entry) {
throw new Error(
`Internal parser error at ${sourceLabel}:${lineNumber}`,
);
}
entry.skills.push(parsedSkill);
return;
}
throw new Error(
`Unsupported manifest line at ${sourceLabel}:${lineNumber} -> ${line}`,
);
});
if (totalSources === null || totalSkills === null) {
throw new Error(
`Manifest is missing total_sources or total_skills: ${sourceLabel}`,
);
}
let actualSkillCount = 0;
for (const [source, entry] of sources) {
actualSkillCount += entry.skills.length;
if (!Number.isFinite(entry.count)) {
throw new Error(`Source '${source}' is missing count in ${sourceLabel}`);
}
if (entry.count !== entry.skills.length) {
throw new Error(
`Source '${source}' count mismatch in ${sourceLabel}: declared ${entry.count}, actual ${entry.skills.length}`,
);
}
}
if (totalSources !== sources.size) {
throw new Error(
`Manifest total_sources mismatch in ${sourceLabel}: declared ${totalSources}, actual ${sources.size}`,
);
}
if (totalSkills !== actualSkillCount) {
throw new Error(
`Manifest total_skills mismatch in ${sourceLabel}: declared ${totalSkills}, actual ${actualSkillCount}`,
);
}
return {
totalSources,
totalSkills,
sources,
};
}
export function parseManifest(path: string): Manifest {
if (!existsSync(path)) {
throw new Error(`Manifest not found: ${path}`);
}
return parseManifestText(readFileSync(path, "utf8"), path);
}
+18
View File
@@ -0,0 +1,18 @@
import { homedir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
export const defaultManifestPath = fileURLToPath(
new URL("../../../manifests/skills.yaml", import.meta.url),
);
export const defaultBackupOutputPath = defaultManifestPath;
export const defaultRestoreManifestPath = defaultManifestPath;
export const defaultGlobalLockFilePath = join(
homedir(),
".agents",
".skill-lock.json",
);
export const cwd = process.cwd();
+24
View File
@@ -0,0 +1,24 @@
import { cwd } from "../paths/defaults";
export type BunxResult = {
stdout: string;
stderr: string;
exitCode: number;
};
export async function runBunx(args: string[]): Promise<BunxResult> {
const proc = Bun.spawn({
cmd: ["bunx", ...args],
cwd,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
return { stdout, stderr, exitCode };
}
+105
View File
@@ -0,0 +1,105 @@
import { expect, test } from "bun:test";
import { runBackup } from "../src/commands/backup";
import { parseBackupArgs } from "../src/internal/args/parse-backup-args";
import { buildManifest } from "../src/internal/manifest/build-manifest";
import { defaultGlobalLockFilePath } from "../src/internal/paths/defaults";
test("rejects flag-like values for backup paths", () => {
expect(() => parseBackupArgs(["--output", "--dry-run"])).toThrow(
"--output requires a path",
);
expect(() => parseBackupArgs(["--lock-file", "--dry-run"])).toThrow(
"--lock-file requires a path",
);
});
test("treats an explicit default lock file as provided", () => {
const options = parseBackupArgs([
"--project-scope",
"--lock-file",
defaultGlobalLockFilePath,
]);
expect(options.lockFilePath).toBe(defaultGlobalLockFilePath);
expect(options.lockFileProvided).toBe(true);
});
test("prints deterministic manifest YAML during backup dry-run", async () => {
let writeCalls = 0;
const result = await runBackup(["--dry-run"], {
getInstalledSkillNames: async () => ["zulu", "alpha", "beta"],
readLockFile: () => ({
skills: {
alpha: { source: "beta/source" },
beta: { source: "alpha/source" },
zulu: { source: "beta/source" },
},
}),
writeManifest: () => {
writeCalls += 1;
},
});
expect(result.exitCode).toBe(0);
expect(writeCalls).toBe(0);
expect(result.stdout).toBe(
[
"total_sources: 2",
"total_skills: 3",
"",
"sources:",
" 'alpha/source':",
" count: 1",
" skills:",
" - 'beta'",
"",
" 'beta/source':",
" count: 2",
" skills:",
" - 'alpha'",
" - 'zulu'",
"",
].join("\n") + "\n",
);
});
test("shows help before project-scope validation", async () => {
const result = await runBackup(["--help", "--project-scope"]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("Usage:");
});
test("builds a grouped manifest with stable source and skill ordering", () => {
const manifest = buildManifest(
["zulu", "alpha", "beta"],
{
skills: {
alpha: { source: "beta/source" },
beta: { source: "alpha/source" },
zulu: { source: "beta/source" },
},
},
);
expect(manifest.totalSources).toBe(2);
expect(manifest.totalSkills).toBe(3);
expect(manifest.yaml).toBe(
[
"total_sources: 2",
"total_skills: 3",
"",
"sources:",
" 'alpha/source':",
" count: 1",
" skills:",
" - 'beta'",
"",
" 'beta/source':",
" count: 2",
" skills:",
" - 'alpha'",
" - 'zulu'",
"",
].join("\n") + "\n",
);
});
+59
View File
@@ -0,0 +1,59 @@
import { expect, test } from "bun:test";
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { runCli } from "../src/cli";
test("declares a working skvlt bin target", async () => {
const repoRoot = resolve(import.meta.dir, "..");
const packageJsonPath = resolve(repoRoot, "package.json");
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
bin?: Record<string, string>;
};
expect(packageJson.bin?.skvlt).toBe("./src/cli.ts");
const targetPath = resolve(repoRoot, packageJson.bin!.skvlt);
expect(existsSync(targetPath)).toBe(true);
const result = Bun.spawnSync([process.execPath, targetPath, "backup"], {
cwd: repoRoot,
});
expect(result.exitCode).toBe(0);
});
test("dispatches the restore command", async () => {
const result = await runCli(["restore", "--help"]);
expect(result.exitCode).toBe(0);
});
test("dispatches the backup command", async () => {
const result = await runCli(["backup"]);
expect(result.exitCode).toBe(0);
});
test("uses process argv when invoked like the CLI", async () => {
const { main } = await import("../src/cli");
const processRef = process as unknown as { argv: string[] };
const consoleRef = console as unknown as {
error: (...args: unknown[]) => void;
};
const originalArgv = processRef.argv;
const originalError = consoleRef.error;
let usageText = "";
try {
processRef.argv = ["bun", "/tmp/skvlt/src/cli.ts"];
consoleRef.error = (...args: unknown[]) => {
usageText = args.join(" ");
};
const exitCode = await main();
expect(exitCode).toBe(1);
expect(usageText).toContain("Usage: skvlt <backup|restore>");
} finally {
processRef.argv = originalArgv;
consoleRef.error = originalError;
}
});
+147
View File
@@ -0,0 +1,147 @@
import { expect, test } from "bun:test";
import { resolveInstallConcurrency } from "../src/internal/install/resolve-install-concurrency";
import { runWithConcurrency } from "../src/internal/install/run-with-concurrency";
test("resolveInstallConcurrency caps auto concurrency at task count", () => {
expect(
resolveInstallConcurrency({ taskCount: 2, availableParallelism: 16 }),
).toBe(2);
});
test("resolveInstallConcurrency clamps configured concurrency to available work", () => {
expect(
resolveInstallConcurrency({ taskCount: 3, configuredConcurrency: 99 }),
).toBe(3);
});
test("resolveInstallConcurrency never returns NaN for invalid numeric input", () => {
expect(
resolveInstallConcurrency({
taskCount: 3,
configuredConcurrency: NaN,
availableParallelism: 4,
}),
).toBe(2);
const resolved = resolveInstallConcurrency({
taskCount: 3,
availableParallelism: NaN,
});
expect(Number.isNaN(resolved)).toBe(false);
expect(resolved).toBeGreaterThanOrEqual(1);
expect(resolved).toBeLessThanOrEqual(3);
});
test("runWithConcurrency keeps active workers within the requested limit", async () => {
const items = [0, 1, 2, 3];
let activeWorkers = 0;
let maxActiveWorkers = 0;
let startedWorkers = 0;
let releaseGate!: () => void;
const gate = new Promise<void>((resolve) => {
releaseGate = resolve;
});
const runPromise = runWithConcurrency(
items,
2,
async (_item, index) => {
startedWorkers += 1;
activeWorkers += 1;
maxActiveWorkers = Math.max(maxActiveWorkers, activeWorkers);
if (index < 2) {
await gate;
}
activeWorkers -= 1;
},
{ continueOnError: true },
);
while (startedWorkers < 2) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
expect(maxActiveWorkers).toBe(2);
releaseGate();
await runPromise;
});
test("runWithConcurrency clamps invalid concurrency input to at least one worker", async () => {
const started: number[] = [];
await runWithConcurrency(
[0, 1, 2],
NaN,
async (_item, index) => {
started.push(index);
},
{ continueOnError: true },
);
expect(started).toEqual([0, 1, 2]);
});
test("runWithConcurrency stops scheduling after the first failure in fail-fast mode", async () => {
const started: number[] = [];
let releaseGate!: () => void;
const gate = new Promise<void>((resolve) => {
releaseGate = resolve;
});
const runPromise = runWithConcurrency(
[0, 1, 2],
2,
async (_item, index) => {
started.push(index);
if (index === 0) {
await gate;
return;
}
throw new Error("boom");
},
{ continueOnError: false },
);
await new Promise((resolve) => setTimeout(resolve, 0));
releaseGate();
await expect(runPromise).rejects.toThrow("boom");
expect(started).toEqual([0, 1]);
});
test("runWithConcurrency collects every failure when continue-on-error is enabled", async () => {
const started: number[] = [];
let releaseGate!: () => void;
const gate = new Promise<void>((resolve) => {
releaseGate = resolve;
});
const failuresPromise = runWithConcurrency(
[0, 1, 2],
2,
async (_item, index) => {
started.push(index);
if (index === 0) {
await gate;
return;
}
throw new Error(index === 1 ? "first failure" : "second failure");
},
{ continueOnError: true },
);
await new Promise((resolve) => setTimeout(resolve, 0));
releaseGate();
const failures = await failuresPromise;
expect(started).toEqual([0, 1, 2]);
expect(failures).toHaveLength(2);
expect(failures.map(({ index }) => index).sort()).toEqual([1, 2]);
});
+33
View File
@@ -0,0 +1,33 @@
import { expect, test } from "bun:test";
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { resolve } from "node:path";
const repoRoot = resolve(import.meta.dir, "..");
test("publishes the expected package and skvlt binary metadata", () => {
const packageJsonPath = resolve(repoRoot, "package.json");
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
name?: string;
bin?: Record<string, string>;
};
expect(packageJson.name).toBe("@xixu-me/skills-vault");
expect(packageJson.bin?.skvlt).toBe("./src/cli.ts");
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");
expect(readme).toContain("# Skills Vault");
expect(readme).toContain("`@xixu-me/skills-vault`");
expect(readme).toContain("`skvlt`");
expect(readme).not.toContain("bun scripts/");
});
test("leaves no production files in scripts", () => {
const scriptsDir = resolve(repoRoot, "scripts");
const remainingEntries = readdirSync(scriptsDir);
expect(remainingEntries).toEqual([]);
});
+123
View File
@@ -0,0 +1,123 @@
import { expect, test } from "bun:test";
import {
parseManifestText,
type Manifest,
} from "../src/internal/manifest/parse-manifest";
import { runRestore } from "../src/commands/restore";
const sampleManifest: Manifest = {
totalSources: 2,
totalSkills: 3,
sources: new Map([
["anthropics/skills", { count: 2, skills: ["alpha", "beta"] }],
["github/awesome-copilot", { count: 1, skills: ["gamma"] }],
]),
};
test("shows restore help text", async () => {
const result = await runRestore(["--help"]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("Usage:");
expect(result.stdout).toContain("skvlt restore");
});
test("prints per-source install commands during restore dry-run", async () => {
const result = await runRestore(
["--dry-run", "--only-source", "anthropics/skills", "--concurrency", "4"],
{
readManifest: () => sampleManifest,
},
);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain(
"Parallel install concurrency: 1 (configured).",
);
expect(result.stdout).toContain(
"[dry-run] bunx skills add anthropics/skills -g -y --skill alpha beta",
);
expect(result.stdout).not.toContain("github/awesome-copilot");
});
test("restores only missing skills during live execution", async () => {
const commands: string[][] = [];
const result = await runRestore(["--manifest", "ignored.yaml"], {
readManifest: () => sampleManifest,
getInstalledSkillNames: async () => new Set(["alpha"]),
runBunx: async (args) => {
commands.push(args);
return { exitCode: 0, stdout: "", stderr: "" };
},
});
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("2 pending, 1 already installed.");
expect(result.stdout).toContain(
"Installing 1 missing skill(s) from anthropics/skills and skipping 1 already installed.",
);
expect(commands).toEqual([
["skills", "add", "anthropics/skills", "-g", "-y", "--skill", "beta"],
[
"skills",
"add",
"github/awesome-copilot",
"-g",
"-y",
"--skill",
"gamma",
],
]);
});
test("continues and returns success when continue-on-error is enabled", async () => {
const result = await runRestore(["--manifest", "ignored.yaml", "--continue-on-error"], {
readManifest: () => sampleManifest,
getInstalledSkillNames: async () => new Set(),
runBunx: async (args) => {
if (args[2] === "anthropics/skills") {
return { exitCode: 1, stdout: "", stderr: "source failed" };
}
return { exitCode: 0, stdout: "installed gamma", stderr: "" };
},
});
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain(
"Installing from 2 source(s) with concurrency 2 (auto).",
);
expect(result.stderr).toContain("source failed");
expect(result.stderr).toContain(
"Install failed for source 'anthropics/skills' with exit code 1",
);
expect(result.stderr).toContain(
"Completed restore with 1 source failure(s).",
);
});
test("parses manifest text with quoted source and skill names", () => {
const manifest = parseManifestText(
[
"total_sources: 1",
"total_skills: 2",
"",
"sources:",
" 'owner/repo':",
" count: 2",
" skills:",
" - 'simple'",
' - "needs \\"quotes\\""',
"",
].join("\n"),
"memory.yaml",
);
expect(manifest.totalSources).toBe(1);
expect(manifest.totalSkills).toBe(2);
expect(manifest.sources.get("owner/repo")).toEqual({
count: 2,
skills: ["simple", 'needs "quotes"'],
});
});