refactor(package): publish skvlt cli
This commit is contained in:
commit
32545773c9
22 files changed
+2548
No files matched your search
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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'
|
||||||
|
|
||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
@@ -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` };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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` };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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]);
|
||||||
|
});
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
@@ -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"'],
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user