vskill 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/agents.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "version": 1,
3
- "generatedAt": "2026-04-27T07:19:05.521Z",
3
+ "generatedAt": "2026-04-27T15:07:05.009Z",
4
4
  "agentPrefixes": [
5
5
  ".adal",
6
6
  ".agent",
@@ -0,0 +1,9 @@
1
+ import type { Command } from "commander";
2
+ interface PluginNewOpts {
3
+ description?: string;
4
+ withSkill?: string;
5
+ cwd?: string;
6
+ }
7
+ export declare function pluginNewCommand(pluginName: string, opts: PluginNewOpts): Promise<void>;
8
+ export declare function registerPluginCommand(program: Command): void;
9
+ export {};
@@ -0,0 +1,123 @@
1
+ // ---------------------------------------------------------------------------
2
+ // 0793 — `vskill plugin <subcommand>` family.
3
+ //
4
+ // Today: `new <name>` scaffolds a Claude Code plugin (`.claude-plugin/plugin.json`)
5
+ // with optional first skill (`--with-skill <slug>`). We delegate schema
6
+ // validation to `claude plugin validate <path>` so vskill never duplicates
7
+ // Claude Code's plugin schema. When `claude` is not on PATH, validation
8
+ // soft-skips with a warning so vskill stays usable in CI.
9
+ //
10
+ // This command exists because Claude Code's `claude plugin` CLI has no
11
+ // `new`/`create`/`init` subcommand — plugin authoring is otherwise unowned.
12
+ // ---------------------------------------------------------------------------
13
+ import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs";
14
+ import { resolve, join } from "node:path";
15
+ import { pluginJsonScaffold, skillMdScaffold, validateKebabName, } from "../eval-server/authoring-routes.js";
16
+ import { validateClaudePlugin } from "../core/plugin-validator.js";
17
+ import { bold, cyan, dim, green, red, yellow } from "../utils/output.js";
18
+ function exit(code) {
19
+ process.exit(code);
20
+ // satisfies TS in tests where exit is spied
21
+ throw new Error("process.exit did not terminate");
22
+ }
23
+ export async function pluginNewCommand(pluginName, opts) {
24
+ const nameErr = validateKebabName(pluginName, "plugin name");
25
+ if (nameErr) {
26
+ console.error(red("Error: ") + dim(nameErr));
27
+ exit(1);
28
+ }
29
+ const cwd = opts.cwd ? resolve(opts.cwd) : process.cwd();
30
+ const pluginDir = join(cwd, pluginName);
31
+ const manifestDir = join(pluginDir, ".claude-plugin");
32
+ const manifestPath = join(manifestDir, "plugin.json");
33
+ if (existsSync(manifestPath)) {
34
+ console.error(red("Error: ") +
35
+ dim(`plugin manifest already exists: ${manifestPath}`));
36
+ console.error(dim(" Pick a different name or delete the existing manifest first."));
37
+ exit(1);
38
+ }
39
+ let withSkill = null;
40
+ if (opts.withSkill !== undefined) {
41
+ const skillErr = validateKebabName(opts.withSkill, "--with-skill");
42
+ if (skillErr) {
43
+ console.error(red("Error: ") + dim(skillErr));
44
+ exit(1);
45
+ }
46
+ withSkill = opts.withSkill;
47
+ const skillDir = join(pluginDir, "skills", withSkill);
48
+ if (existsSync(skillDir)) {
49
+ console.error(red("Error: ") +
50
+ dim(`skill directory already exists: ${skillDir}`));
51
+ exit(1);
52
+ }
53
+ }
54
+ const description = (opts.description ?? "").trim() || `Plugin: ${pluginName}`;
55
+ // Write the manifest (and optional first skill) as an all-or-nothing block.
56
+ // If validation fails we roll back the manifest; we leave the (newly
57
+ // created) skill folder in place because deleting user-visible new files we
58
+ // just created is more surprising than leaving them.
59
+ try {
60
+ mkdirSync(manifestDir, { recursive: true });
61
+ writeFileSync(manifestPath, pluginJsonScaffold(pluginName, description), "utf8");
62
+ if (withSkill) {
63
+ const skillDir = join(pluginDir, "skills", withSkill);
64
+ mkdirSync(skillDir, { recursive: true });
65
+ writeFileSync(join(skillDir, "SKILL.md"), skillMdScaffold(withSkill, description), "utf8");
66
+ }
67
+ }
68
+ catch (err) {
69
+ const message = err instanceof Error ? err.message : String(err);
70
+ console.error(red("Error: ") + dim(`failed to write plugin files: ${message}`));
71
+ exit(1);
72
+ }
73
+ // Schema validation via `claude plugin validate` — single source of truth
74
+ // for the plugin schema.
75
+ const validation = validateClaudePlugin(pluginDir);
76
+ if (!validation.ok) {
77
+ try {
78
+ unlinkSync(manifestPath);
79
+ }
80
+ catch {
81
+ /* best-effort rollback */
82
+ }
83
+ console.error(red("Error: ") + dim("claude plugin validate rejected the generated manifest:"));
84
+ console.error(validation.stderr || "(no stderr captured)");
85
+ console.error(dim(" The manifest has been removed. Please file an issue if this is unexpected."));
86
+ exit(1);
87
+ }
88
+ if (validation.skipped) {
89
+ console.warn(yellow("Warning: ") +
90
+ dim("`claude` CLI not found on PATH; plugin schema was NOT validated."));
91
+ console.warn(dim(" Install Claude Code (https://claude.com/claude-code) to enable validation."));
92
+ }
93
+ // Success summary
94
+ console.log(green("✔ Plugin scaffolded"));
95
+ console.log(dim(" manifest: ") + manifestPath);
96
+ if (withSkill) {
97
+ console.log(dim(" skill: ") + join(pluginDir, "skills", withSkill, "SKILL.md"));
98
+ }
99
+ console.log("");
100
+ console.log(bold("Next steps:"));
101
+ console.log(" " + cyan(`cd ${pluginName}`));
102
+ if (!withSkill) {
103
+ console.log(" " +
104
+ cyan(`vskill skill new --prompt "..."`) +
105
+ dim(" # add your first skill"));
106
+ }
107
+ console.log(" " + cyan(`vskill studio`) + dim(" # open in Skill Studio"));
108
+ }
109
+ export function registerPluginCommand(program) {
110
+ const plugin = program
111
+ .command("plugin")
112
+ .description("Plugin authoring: scaffold a Claude Code plugin (.claude-plugin/plugin.json + skills/)");
113
+ plugin
114
+ .command("new <name>")
115
+ .description("Scaffold a new plugin folder with manifest and (optional) first skill")
116
+ .option("--description <text>", "Description written into plugin.json")
117
+ .option("--with-skill <slug>", "Also scaffold <name>/skills/<slug>/SKILL.md as the first skill")
118
+ .option("--cwd <path>", "Create the plugin under this directory (default: process.cwd())")
119
+ .action(async (name, opts) => {
120
+ await pluginNewCommand(name, opts);
121
+ });
122
+ }
123
+ //# sourceMappingURL=plugin.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin.js","sourceRoot":"","sources":["../../src/commands/plugin.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,17 @@
1
+ export interface PluginValidationResult {
2
+ ok: boolean;
3
+ skipped: boolean;
4
+ stderr: string;
5
+ }
6
+ export interface ValidateClaudePluginOptions {
7
+ /**
8
+ * Override the binary name (tests inject a fake "claude").
9
+ */
10
+ bin?: string;
11
+ /**
12
+ * Timeout in ms. Defaults to 10s — `claude plugin validate` is local-only
13
+ * and typically returns in <300ms; 10s is generous headroom.
14
+ */
15
+ timeoutMs?: number;
16
+ }
17
+ export declare function validateClaudePlugin(pluginPath: string, opts?: ValidateClaudePluginOptions): PluginValidationResult;
@@ -0,0 +1,45 @@
1
+ // ---------------------------------------------------------------------------
2
+ // 0793 — `claude plugin validate <path>` wrapper.
3
+ //
4
+ // vskill never duplicates Claude Code's plugin schema. Both `vskill plugin new`
5
+ // (CLI) and `POST /api/authoring/convert-to-plugin` (Studio) call this helper
6
+ // after writing a manifest to confirm the JSON we wrote conforms. When `claude`
7
+ // is not on PATH (e.g. CI without Claude Code installed), validation soft-skips
8
+ // with `{ ok: true, skipped: true }` so vskill stays usable; callers should
9
+ // surface that state to the user.
10
+ // ---------------------------------------------------------------------------
11
+ import { spawnSync } from "node:child_process";
12
+ export function validateClaudePlugin(pluginPath, opts = {}) {
13
+ const bin = opts.bin ?? "claude";
14
+ const result = spawnSync(bin, ["plugin", "validate", pluginPath], {
15
+ encoding: "utf8",
16
+ timeout: opts.timeoutMs ?? 10_000,
17
+ });
18
+ // ENOENT => `claude` not on PATH. Soft-skip so vskill remains usable in
19
+ // environments without Claude Code (CI, fresh dev boxes, etc).
20
+ // `result.error` is a NodeJS.ErrnoException when spawn itself failed.
21
+ const err = result.error;
22
+ if (err && err.code === "ENOENT") {
23
+ return { ok: true, skipped: true, stderr: "" };
24
+ }
25
+ if (err) {
26
+ // Any other spawn error (timeout, permission, etc): surface but don't
27
+ // treat as schema failure — caller decides what to do.
28
+ return {
29
+ ok: false,
30
+ skipped: false,
31
+ stderr: `failed to invoke ${bin}: ${err.message}`,
32
+ };
33
+ }
34
+ if (result.status === 0) {
35
+ return { ok: true, skipped: false, stderr: "" };
36
+ }
37
+ // `claude plugin validate` writes diagnostics to stderr (or stdout in some
38
+ // versions). Concatenate both so callers always see the message.
39
+ const stderr = [result.stderr ?? "", result.stdout ?? ""]
40
+ .filter((s) => s && s.trim().length > 0)
41
+ .join("\n")
42
+ .trim();
43
+ return { ok: false, skipped: false, stderr };
44
+ }
45
+ //# sourceMappingURL=plugin-validator.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin-validator.js","sourceRoot":"","sources":["../../src/core/plugin-validator.ts"],"names":[],"mappings":"AAAA,8EAA8E;AAC9E,kDAAkD;AAClD,EAAE;AACF,gFAAgF;AAChF,8EAA8E;AAC9E,gFAAgF;AAChF,gFAAgF;AAChF,4EAA4E;AAC5E,kCAAkC;AAClC,8EAA8E;AAE9E,OAAO,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAoB/C,MAAM,UAAU,oBAAoB,CAClC,UAAkB,EAClB,OAAoC,EAAE;IAEtC,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,IAAI,QAAQ,CAAC;IACjC,MAAM,MAAM,GAAG,SAAS,CAAC,GAAG,EAAE,CAAC,QAAQ,EAAE,UAAU,EAAE,UAAU,CAAC,EAAE;QAChE,QAAQ,EAAE,MAAM;QAChB,OAAO,EAAE,IAAI,CAAC,SAAS,IAAI,MAAM;KAClC,CAAC,CAAC;IAEH,wEAAwE;IACxE,+DAA+D;IAC/D,sEAAsE;IACtE,MAAM,GAAG,GAAG,MAAM,CAAC,KAA4C,CAAC;IAChE,IAAI,GAAG,IAAI,GAAG,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;QACjC,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC;IACjD,CAAC;IACD,IAAI,GAAG,EAAE,CAAC;QACR,sEAAsE;QACtE,uDAAuD;QACvD,OAAO;YACL,EAAE,EAAE,KAAK;YACT,OAAO,EAAE,KAAK;YACd,MAAM,EAAE,oBAAoB,GAAG,KAAK,GAAG,CAAC,OAAO,EAAE;SAClD,CAAC;IACJ,CAAC;IAED,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACxB,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC;IAClD,CAAC;IAED,2EAA2E;IAC3E,iEAAiE;IACjE,MAAM,MAAM,GAAG,CAAC,MAAM,CAAC,MAAM,IAAI,EAAE,EAAE,MAAM,CAAC,MAAM,IAAI,EAAE,CAAC;SACtD,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC,CAAC;SACvC,IAAI,CAAC,IAAI,CAAC;SACV,IAAI,EAAE,CAAC;IACV,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC;AAC/C,CAAC"}
@@ -951,6 +951,17 @@ function getEffectiveRawModel() {
951
951
  function getClient() {
952
952
  return createLlmClient(currentOverrides);
953
953
  }
954
+ // Per-request client: prefer body-supplied provider/model, then session globals.
955
+ // Lets the frontend send the user's selected model with each request so historical
956
+ // runs reliably reflect the picker value rather than a stale session default.
957
+ function clientFromBody(body) {
958
+ const reqProvider = typeof body?.provider === "string" ? body.provider : undefined;
959
+ const reqModel = typeof body?.model === "string" ? body.model : undefined;
960
+ const provider = (reqProvider || currentOverrides.provider || "claude-cli");
961
+ const model = reqModel || currentOverrides.model;
962
+ const client = createLlmClient({ provider, model });
963
+ return { client, provider };
964
+ }
954
965
  /** Derive sidebar badge status from benchmark + current eval IDs. */
955
966
  function computeBenchmarkStatus(benchmark, evalIds, hasEvals) {
956
967
  if (!benchmark)
@@ -2549,7 +2560,7 @@ export function registerRoutes(router, root, projectName) {
2549
2560
  const evals = loadAndValidateEvals(skillDir);
2550
2561
  const skillMdPath = join(skillDir, "SKILL.md");
2551
2562
  const skillContent = existsSync(skillMdPath) ? readFileSync(skillMdPath, "utf-8") : "";
2552
- const client = getClient();
2563
+ const { client, provider: effectiveProvider } = clientFromBody(body);
2553
2564
  const systemPrompt = buildEvalSystemPrompt(skillContent);
2554
2565
  // Create separate judge client if judgeModel is specified
2555
2566
  let judgeClient;
@@ -2570,7 +2581,7 @@ export function registerRoutes(router, root, projectName) {
2570
2581
  }
2571
2582
  await runBenchmarkSSE({
2572
2583
  res, skillDir, skillName: evals.skill_name, systemPrompt,
2573
- runType: "benchmark", provider: currentOverrides.provider || "claude-cli",
2584
+ runType: "benchmark", provider: effectiveProvider,
2574
2585
  evalCases: evals.evals, filterIds, client, judgeClient, judgeCache,
2575
2586
  isAborted: () => aborted, concurrency,
2576
2587
  });
@@ -2592,11 +2603,11 @@ export function registerRoutes(router, root, projectName) {
2592
2603
  initSSE(res, req);
2593
2604
  try {
2594
2605
  const evals = loadAndValidateEvals(skillDir);
2595
- const client = getClient();
2606
+ const { client, provider: effectiveProvider } = clientFromBody(body);
2596
2607
  await runBenchmarkSSE({
2597
2608
  res, skillDir, skillName: evals.skill_name,
2598
2609
  systemPrompt: "You are a helpful AI assistant.",
2599
- runType: "baseline", provider: currentOverrides.provider || "claude-cli",
2610
+ runType: "baseline", provider: effectiveProvider,
2600
2611
  evalCases: evals.evals, filterIds, client, isAborted: () => aborted,
2601
2612
  });
2602
2613
  }
@@ -2636,14 +2647,14 @@ export function registerRoutes(router, root, projectName) {
2636
2647
  }
2637
2648
  const skillMdPath = join(skillDir, "SKILL.md");
2638
2649
  const skillContent = existsSync(skillMdPath) ? readFileSync(skillMdPath, "utf-8") : "";
2639
- const client = getClient();
2650
+ const { client, provider: effectiveProvider } = clientFromBody(body);
2640
2651
  const systemPrompt = isBaseline
2641
2652
  ? buildBaselineSystemPrompt()
2642
2653
  : buildEvalSystemPrompt(skillContent);
2643
2654
  await sem.acquire();
2644
2655
  const benchCase = await runSingleCaseSSE({
2645
2656
  res, evalCase, systemPrompt, client, isAborted: () => aborted,
2646
- provider: currentOverrides.provider || "claude-cli",
2657
+ provider: effectiveProvider,
2647
2658
  });
2648
2659
  if (!released) {
2649
2660
  released = true;
@@ -2659,7 +2670,7 @@ export function registerRoutes(router, root, projectName) {
2659
2670
  cases: [benchCase],
2660
2671
  overall_pass_rate: benchCase.pass_rate,
2661
2672
  type: isBaseline ? "baseline" : "benchmark",
2662
- provider: currentOverrides.provider || "claude-cli",
2673
+ provider: effectiveProvider,
2663
2674
  totalDurationMs: benchCase.durationMs ?? 0,
2664
2675
  totalInputTokens: benchCase.inputTokens ?? null,
2665
2676
  totalOutputTokens: benchCase.outputTokens ?? null,