vskill 0.5.104 → 0.5.106

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.
Files changed (86) hide show
  1. package/README.md +123 -2
  2. package/agents.json +1 -1
  3. package/dist/agents/agents-registry.d.ts +0 -1
  4. package/dist/agents/agents-registry.js +10 -30
  5. package/dist/agents/agents-registry.js.map +1 -1
  6. package/dist/api/client.d.ts +21 -0
  7. package/dist/api/client.js +39 -5
  8. package/dist/api/client.js.map +1 -1
  9. package/dist/commands/diff.d.ts +2 -5
  10. package/dist/commands/diff.js +82 -117
  11. package/dist/commands/diff.js.map +1 -1
  12. package/dist/commands/eval/serve.js +4 -0
  13. package/dist/commands/eval/serve.js.map +1 -1
  14. package/dist/commands/keys.d.ts +14 -0
  15. package/dist/commands/keys.js +166 -0
  16. package/dist/commands/keys.js.map +1 -0
  17. package/dist/commands/masked-stdin.d.ts +14 -0
  18. package/dist/commands/masked-stdin.js +79 -0
  19. package/dist/commands/masked-stdin.js.map +1 -0
  20. package/dist/commands/skill.d.ts +3 -29
  21. package/dist/commands/skill.js +6 -32
  22. package/dist/commands/skill.js.map +1 -1
  23. package/dist/eval/anthropic-catalog.d.ts +49 -0
  24. package/dist/eval/anthropic-catalog.js +238 -0
  25. package/dist/eval/anthropic-catalog.js.map +1 -0
  26. package/dist/eval/llm.d.ts +1 -1
  27. package/dist/eval/llm.js +68 -10
  28. package/dist/eval/llm.js.map +1 -1
  29. package/dist/eval/mcp-detector.js +24 -3
  30. package/dist/eval/mcp-detector.js.map +1 -1
  31. package/dist/eval/model-resolver.d.ts +39 -0
  32. package/dist/eval/model-resolver.js +94 -0
  33. package/dist/eval/model-resolver.js.map +1 -0
  34. package/dist/eval/pricing.js +42 -16
  35. package/dist/eval/pricing.js.map +1 -1
  36. package/dist/eval-server/api-routes.d.ts +9 -0
  37. package/dist/eval-server/api-routes.js +241 -73
  38. package/dist/eval-server/api-routes.js.map +1 -1
  39. package/dist/eval-server/authoring-routes.js +21 -1
  40. package/dist/eval-server/authoring-routes.js.map +1 -1
  41. package/dist/eval-server/boot-preflight.js +8 -10
  42. package/dist/eval-server/boot-preflight.js.map +1 -1
  43. package/dist/eval-server/darwin-migrator.d.ts +57 -0
  44. package/dist/eval-server/darwin-migrator.js +169 -0
  45. package/dist/eval-server/darwin-migrator.js.map +1 -0
  46. package/dist/eval-server/eval-server.d.ts +1 -0
  47. package/dist/eval-server/eval-server.js +21 -0
  48. package/dist/eval-server/eval-server.js.map +1 -1
  49. package/dist/eval-server/integration-routes.js +7 -0
  50. package/dist/eval-server/integration-routes.js.map +1 -1
  51. package/dist/eval-server/platform-proxy.d.ts +18 -0
  52. package/dist/eval-server/platform-proxy.js +153 -0
  53. package/dist/eval-server/platform-proxy.js.map +1 -0
  54. package/dist/eval-server/providers.d.ts +7 -12
  55. package/dist/eval-server/providers.js +13 -15
  56. package/dist/eval-server/providers.js.map +1 -1
  57. package/dist/eval-server/settings-store.d.ts +31 -26
  58. package/dist/eval-server/settings-store.js +246 -143
  59. package/dist/eval-server/settings-store.js.map +1 -1
  60. package/dist/eval-server/skill-create-routes.js +18 -0
  61. package/dist/eval-server/skill-create-routes.js.map +1 -1
  62. package/dist/eval-server/skill-name-resolver.d.ts +35 -0
  63. package/dist/eval-server/skill-name-resolver.js +146 -0
  64. package/dist/eval-server/skill-name-resolver.js.map +1 -0
  65. package/dist/eval-ui/assets/{CommandPalette-DiPALzlG.js → CommandPalette-COqdrmRl.js} +1 -1
  66. package/dist/eval-ui/assets/CreateSkillPage-C3IjO8es.js +12 -0
  67. package/dist/eval-ui/assets/UpdateDropdown-DnKKMBBN.js +1 -0
  68. package/dist/eval-ui/assets/index-Dmja1p3A.css +1 -0
  69. package/dist/eval-ui/assets/index-KIcQ5e5a.js +102 -0
  70. package/dist/eval-ui/index.html +2 -2
  71. package/dist/first-run-onboarding.d.ts +19 -0
  72. package/dist/first-run-onboarding.js +104 -0
  73. package/dist/first-run-onboarding.js.map +1 -0
  74. package/dist/index.js +18 -0
  75. package/dist/index.js.map +1 -1
  76. package/dist/installer/canonical.js +12 -13
  77. package/dist/installer/canonical.js.map +1 -1
  78. package/dist/utils/resolve-binary.js +7 -8
  79. package/dist/utils/resolve-binary.js.map +1 -1
  80. package/package.json +5 -2
  81. package/dist/eval-ui/assets/CreateSkillPage-BMrFELep.js +0 -12
  82. package/dist/eval-ui/assets/UpdateDropdown-Bj8kZzuR.js +0 -1
  83. package/dist/eval-ui/assets/_shimNode-D3bBqrAh.js +0 -1
  84. package/dist/eval-ui/assets/index-BSPDkfZG.js +0 -102
  85. package/dist/eval-ui/assets/index-C0Gc_4KC.css +0 -1
  86. package/dist/eval-ui/assets/resolve-binary-DIxhrZ6O.js +0 -2
@@ -1,15 +1,16 @@
1
1
  // ---------------------------------------------------------------------------
2
2
  // api-routes.ts -- REST API route handlers for the eval UI
3
3
  // ---------------------------------------------------------------------------
4
- import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync, readdirSync, statSync } from "node:fs";
4
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync } from "node:fs";
5
5
  import { execSync } from "node:child_process";
6
6
  import { join, resolve, dirname } from "node:path";
7
+ import { homedir } from "node:os";
7
8
  import { sendJson, readBody } from "./router.js";
8
9
  import { initSSE, sendSSE, sendSSEDone, withHeartbeat, startDynamicHeartbeat } from "./sse-helpers.js";
9
10
  import { dataEventBus, emitDataEvent } from "./data-events.js";
10
11
  import { classifyError } from "./error-classifier.js";
11
12
  import { readLockfile } from "../lockfile/lockfile.js";
12
- import { parseSource } from "../resolvers/source-resolver.js";
13
+ import { resolveSkillApiName as resolveSkillApiNameImpl } from "./skill-name-resolver.js";
13
14
  import { runBenchmarkSSE, runSingleCaseSSE } from "./benchmark-runner.js";
14
15
  import { getSkillSemaphore } from "./concurrency.js";
15
16
  import { resolveSkillDir } from "./skill-resolver.js";
@@ -17,6 +18,7 @@ import { classifyOrigin, scanSkillsTriScope } from "../eval/skill-scanner.js";
17
18
  import { scanInstalledPluginSkills, scanAuthoredPluginSkills, } from "../eval/plugin-scanner.js";
18
19
  import { resolveGlobalSkillsDir } from "../eval/path-utils.js";
19
20
  import { loadAndValidateEvals, EvalValidationError } from "../eval/schema.js";
21
+ import { ANTHROPIC_CATALOG_SNAPSHOT, findAnthropicModel } from "../eval/anthropic-catalog.js";
20
22
  import { readBenchmark } from "../eval/benchmark.js";
21
23
  import { writeHistoryEntry, listHistory, readHistoryEntry, computeRegressions, deleteHistoryEntry, getCaseHistory, computeStats } from "../eval/benchmark-history.js";
22
24
  import { judgeAssertion } from "../eval/judge.js";
@@ -32,6 +34,8 @@ import { writeActivationRun, listActivationRuns, getActivationRun } from "../eva
32
34
  import { AGENTS_REGISTRY, detectInstalledAgents } from "../agents/agents-registry.js";
33
35
  import { resolveOllamaBaseUrl } from "../eval/env.js";
34
36
  import * as settingsStore from "./settings-store.js";
37
+ import { isProviderId, getProviderById } from "./providers.js";
38
+ import { DarwinKeychainMigrator } from "./darwin-migrator.js";
35
39
  import { loadStudioSelection, saveStudioSelection } from "./studio-json.js";
36
40
  /**
37
41
  * Build the response for GET /api/agents/installed.
@@ -474,18 +478,41 @@ function computeBenchmarkStatus(benchmark, evalIds, hasEvals) {
474
478
  // Use overall_pass_rate as single source of truth
475
479
  return (benchmark.overall_pass_rate ?? 0) >= 1 ? "pass" : "fail";
476
480
  }
477
- const PROVIDER_MODELS = {
481
+ // 0711 Anthropic models + pricing now derive from the dated catalog
482
+ // snapshot at `src/eval/anthropic-catalog.ts`. Manual maintenance of this
483
+ // list led to stale prices (Opus 4.7 shown at $15/$75 instead of $5/$25)
484
+ // because three different files held copies of the same fact. The catalog
485
+ // file is the single source of truth; CI fails if its snapshotDate is
486
+ // older than 6 months.
487
+ function buildAnthropicProviderModels() {
488
+ return ANTHROPIC_CATALOG_SNAPSHOT.models
489
+ .filter((m) => m.status === "active")
490
+ .map((m) => ({
491
+ id: m.id,
492
+ label: `${m.displayName} (API)`,
493
+ pricing: {
494
+ prompt: m.pricing.promptUsdPer1M,
495
+ completion: m.pricing.completionUsdPer1M,
496
+ },
497
+ }));
498
+ }
499
+ function aliasInfo(alias, fallbackLabel) {
500
+ const entry = findAnthropicModel(alias);
501
+ if (!entry)
502
+ return { label: fallbackLabel };
503
+ return { label: entry.displayName, resolvedId: entry.id };
504
+ }
505
+ export const PROVIDER_MODELS = {
506
+ // Opus first so it is the default when no override is set
507
+ // (getEffectiveRawModel returns models[0]). Labels come from the catalog so
508
+ // the picker shows the exact dated version (e.g. "Claude Opus 4.7"), not the
509
+ // bare family name — keeps the Studio truthful when a model is bumped.
478
510
  "claude-cli": [
479
- { id: "sonnet", label: "Claude Sonnet" },
480
- { id: "opus", label: "Claude Opus" },
481
- { id: "haiku", label: "Claude Haiku" },
482
- ],
483
- "anthropic": [
484
- { id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6 (API)" },
485
- { id: "claude-opus-4-7", label: "Claude Opus 4.7 (API)" },
486
- { id: "claude-opus-4-6", label: "Claude Opus 4.6 (API)" },
487
- { id: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5 (API)" },
511
+ { id: "opus", ...aliasInfo("opus", "Claude Opus") },
512
+ { id: "sonnet", ...aliasInfo("sonnet", "Claude Sonnet") },
513
+ { id: "haiku", ...aliasInfo("haiku", "Claude Haiku") },
488
514
  ],
515
+ "anthropic": buildAnthropicProviderModels(),
489
516
  "ollama": [
490
517
  { id: "llama3.1:8b", label: "Llama 3.1 8B" },
491
518
  { id: "qwen2.5:32b", label: "Qwen 2.5 32B" },
@@ -500,6 +527,13 @@ const PROVIDER_MODELS = {
500
527
  { id: "o3", label: "OpenAI o3" },
501
528
  { id: "o4-mini", label: "OpenAI o4-mini" },
502
529
  ],
530
+ "openai": [
531
+ { id: "gpt-4o-mini", label: "GPT-4o mini (API)", pricing: { prompt: 0.15, completion: 0.60 } },
532
+ { id: "gpt-4o", label: "GPT-4o (API)", pricing: { prompt: 2.50, completion: 10 } },
533
+ { id: "gpt-4.1", label: "GPT-4.1 (API)", pricing: { prompt: 2, completion: 8 } },
534
+ { id: "gpt-4.1-mini", label: "GPT-4.1 mini (API)", pricing: { prompt: 0.40, completion: 1.60 } },
535
+ { id: "o4-mini", label: "o4-mini (API)", pricing: { prompt: 1.10, completion: 4.40 } },
536
+ ],
503
537
  "openrouter": [
504
538
  // Anthropic via OpenRouter
505
539
  { id: "anthropic/claude-opus-4", label: "Claude Opus 4 (via OpenRouter)" },
@@ -647,6 +681,25 @@ function isBinaryOnPath(name) {
647
681
  return false;
648
682
  }
649
683
  }
684
+ // 0701 — Read the active Claude Code model from ~/.claude/settings.json so the
685
+ // Studio picker can surface "routing to claude-opus-4-7[1m]" under the generic
686
+ // Claude Code rows. Returns null on any read/parse failure — callers fall back
687
+ // to generic aliases. Re-read on every call (no caching) so toggling /model in
688
+ // Claude Code is reflected on the next picker open.
689
+ export function resolveClaudeCodeModel() {
690
+ try {
691
+ const path = join(homedir(), ".claude", "settings.json");
692
+ const raw = readFileSync(path, "utf8");
693
+ const parsed = JSON.parse(raw);
694
+ if (!parsed || typeof parsed !== "object")
695
+ return null;
696
+ const model = parsed.model;
697
+ return typeof model === "string" && model.length > 0 ? model : null;
698
+ }
699
+ catch {
700
+ return null;
701
+ }
702
+ }
650
703
  export async function detectAvailableProviders() {
651
704
  const providers = [];
652
705
  // Claude CLI — delegates to the `claude` binary; the CLI owns session auth.
@@ -656,9 +709,10 @@ export async function detectAvailableProviders() {
656
709
  label: "Use current Claude Code session",
657
710
  available: true,
658
711
  models: PROVIDER_MODELS["claude-cli"],
712
+ resolvedModel: resolveClaudeCodeModel(),
659
713
  });
660
714
  // Anthropic API — available if ANTHROPIC_API_KEY is set OR a key is in the
661
- // settings-store (browser tier or Darwin keychain).
715
+ // settings-store. After 0702 the tier concept is gone; storage is file-only.
662
716
  providers.push({
663
717
  id: "anthropic",
664
718
  label: "Anthropic API",
@@ -666,6 +720,14 @@ export async function detectAvailableProviders() {
666
720
  settingsStore.hasKeySync("anthropic"),
667
721
  models: PROVIDER_MODELS["anthropic"],
668
722
  });
723
+ // OpenAI API — available if OPENAI_API_KEY is set OR a key is stored (0702 T-023).
724
+ providers.push({
725
+ id: "openai",
726
+ label: "OpenAI API",
727
+ available: !!process.env.OPENAI_API_KEY ||
728
+ settingsStore.hasKeySync("openai"),
729
+ models: PROVIDER_MODELS["openai"],
730
+ });
669
731
  // OpenRouter — available if OPENROUTER_API_KEY is set OR a key is stored.
670
732
  providers.push({
671
733
  id: "openrouter",
@@ -788,9 +850,12 @@ export function registerRoutes(router, root, projectName) {
788
850
  id: m.id,
789
851
  name: m.name || m.id,
790
852
  contextWindow: typeof m.context_length === "number" ? m.context_length : undefined,
853
+ // 0710 — OpenRouter publishes USD per token; canonicalize to USD per 1M
854
+ // tokens so the wire contract matches PROVIDER_MODELS["anthropic"]
855
+ // (3, 15, 75, …) and every consumer can assume one unit.
791
856
  pricing: {
792
- prompt: parseFloat(m.pricing?.prompt || "0"),
793
- completion: parseFloat(m.pricing?.completion || "0"),
857
+ prompt: parseFloat(m.pricing?.prompt || "0") * 1_000_000,
858
+ completion: parseFloat(m.pricing?.completion || "0") * 1_000_000,
794
859
  },
795
860
  }));
796
861
  OPENROUTER_CACHE.set(cacheKey, { value: models, fetchedAt: now });
@@ -813,6 +878,11 @@ export function registerRoutes(router, root, projectName) {
813
878
  router.get("/api/settings/keys", async (_req, res) => {
814
879
  sendJson(res, settingsStore.listKeys());
815
880
  });
881
+ // 0702 T-024: expose the absolute keys.env path for the Settings footer +
882
+ // "Copy path" button. Key contents are NEVER returned — path only.
883
+ router.get("/api/settings/storage-path", async (_req, res) => {
884
+ sendJson(res, { path: settingsStore.getKeysFilePath() });
885
+ });
816
886
  router.post("/api/settings/keys", async (req, res) => {
817
887
  // Reject any request that smuggles the key in a query-string — JSON body only.
818
888
  const url = req.url || "";
@@ -825,24 +895,22 @@ export function registerRoutes(router, root, projectName) {
825
895
  sendJson(res, { error: "key must be non-empty string" }, 400);
826
896
  return;
827
897
  }
828
- if (body.provider !== "anthropic" && body.provider !== "openrouter") {
898
+ if (typeof body.provider !== "string" || !isProviderId(body.provider)) {
829
899
  sendJson(res, { error: `unknown provider: ${String(body.provider)}` }, 400);
830
900
  return;
831
901
  }
902
+ const providerId = body.provider;
832
903
  try {
833
- const saved = await settingsStore.saveKey(body.provider, body.key.trim(), body.tier ?? "browser");
834
- // Prefix hint — non-blocking, purely informational
835
- let warning;
836
- if (body.provider === "anthropic" && !body.key.startsWith("sk-ant-")) {
837
- warning = "key doesn't match typical Anthropic prefix sk-ant-";
838
- }
839
- else if (body.provider === "openrouter" && !body.key.startsWith("sk-or-")) {
840
- warning = "key doesn't match typical OpenRouter prefix sk-or-";
841
- }
904
+ const saved = await settingsStore.saveKey(providerId, body.key.trim());
905
+ // Prefix hint — non-blocking, purely informational. Sourced from PROVIDERS
906
+ // so adding a provider later automatically extends the warning.
907
+ const descriptor = getProviderById(providerId);
908
+ const warning = descriptor.keyPrefix && !body.key.startsWith(descriptor.keyPrefix)
909
+ ? `key doesn't match typical ${descriptor.label} prefix ${descriptor.keyPrefix}`
910
+ : undefined;
842
911
  sendJson(res, {
843
912
  ok: true,
844
913
  updatedAt: saved.updatedAt,
845
- tier: saved.tier,
846
914
  available: true,
847
915
  ...(warning ? { warning } : {}),
848
916
  });
@@ -851,15 +919,51 @@ export function registerRoutes(router, root, projectName) {
851
919
  sendJson(res, { error: err.message }, 500);
852
920
  }
853
921
  });
854
- router.delete("/api/settings/keys/:provider", async (req, res) => {
855
- const provider = req.params?.provider;
856
- if (provider !== "anthropic" && provider !== "openrouter") {
922
+ router.delete("/api/settings/keys/:provider", async (_req, res, params) => {
923
+ const provider = params.provider;
924
+ if (typeof provider !== "string" || !isProviderId(provider)) {
857
925
  sendJson(res, { error: `unknown provider: ${String(provider)}` }, 400);
858
926
  return;
859
927
  }
860
928
  await settingsStore.removeKey(provider);
861
929
  sendJson(res, { ok: true });
862
930
  });
931
+ // Migration — one-shot copy from pre-0702 macOS Keychain into the file store.
932
+ // Non-Darwin platforms short-circuit inside the migrator (no spawn).
933
+ router.get("/api/settings/migration-status", async (_req, res) => {
934
+ try {
935
+ const migrator = new DarwinKeychainMigrator();
936
+ const availability = await migrator.available();
937
+ sendJson(res, {
938
+ hasLegacyKeys: availability.hasLegacyKeys,
939
+ providers: availability.providers,
940
+ ackStatus: availability.ackStatus ?? null,
941
+ });
942
+ }
943
+ catch (err) {
944
+ sendJson(res, { error: err.message }, 500);
945
+ }
946
+ });
947
+ router.post("/api/settings/migration/perform", async (_req, res) => {
948
+ try {
949
+ const migrator = new DarwinKeychainMigrator();
950
+ const result = await migrator.migrate();
951
+ sendJson(res, { migrated: result.migrated });
952
+ }
953
+ catch (err) {
954
+ sendJson(res, { error: err.message }, 500);
955
+ }
956
+ });
957
+ router.post("/api/settings/migration/acknowledge", async (_req, res) => {
958
+ try {
959
+ const migrator = new DarwinKeychainMigrator();
960
+ await migrator.acknowledge();
961
+ sendJson(res, { ok: true });
962
+ }
963
+ catch (err) {
964
+ sendJson(res, { error: err.message }, 500);
965
+ }
966
+ });
863
967
  // Config — expose current provider/model + available providers + project
864
968
  // IMPORTANT: Return raw model IDs (e.g. "sonnet"), NOT display models
865
969
  // (e.g. "claude-sonnet"). The frontend round-trips config.model back to
@@ -1060,49 +1164,67 @@ export function registerRoutes(router, root, projectName) {
1060
1164
  // MUST be registered BEFORE the /:plugin/:skill catch-all below.
1061
1165
  // -------------------------------------------------------------------------
1062
1166
  const PLATFORM_BASE = "https://verified-skill.com";
1063
- /** Resolve plugin/skill to full hierarchical API name using lockfile source. */
1167
+ /**
1168
+ * Resolve plugin/skill to full hierarchical API name. Lockfile path first
1169
+ * (installed skills); falls back to authored-skill discovery on disk +
1170
+ * git remote parse for skills authored in this repo. See skill-name-resolver.ts.
1171
+ */
1064
1172
  function resolveSkillApiName(skill) {
1065
- const lock = readLockfile();
1066
- if (!lock)
1067
- return skill;
1068
- const entry = lock.skills[skill];
1069
- if (!entry?.source)
1070
- return skill;
1071
- const parsed = parseSource(entry.source);
1072
- if (parsed.type === "github" || parsed.type === "github-plugin" || parsed.type === "marketplace") {
1073
- return `${parsed.owner}/${parsed.repo}/${skill}`;
1074
- }
1075
- return skill;
1173
+ return resolveSkillApiNameImpl(skill, root);
1076
1174
  }
1077
- // T-009: Versions proxy route
1175
+ // T-009 (proxy) + 0707 T-021 (harden): Versions endpoint
1176
+ //
1177
+ // Envelope: { versions: VersionEntry[], count: number, source: "platform" | "none" }
1178
+ // - source:"platform" → remote Verified-Skill platform responded
1179
+ // - source:"none" → skill has no VCS surface (local fixture, platform
1180
+ // unreachable, or platform returned non-OK). In this
1181
+ // case the `X-Skill-VCS: unavailable` response header
1182
+ // is also emitted so the UI can badge the skill as
1183
+ // "no version history" without treating it as an error.
1184
+ //
1185
+ // Never returns 5xx for the "no VCS surface" case — that is normal empty state.
1078
1186
  router.get("/api/skills/:plugin/:skill/versions", async (req, res, params) => {
1079
- const fullName = resolveSkillApiName(params.skill);
1187
+ const fullName = await resolveSkillApiName(params.skill);
1080
1188
  const parts = fullName.split("/");
1081
1189
  const apiPath = parts.length === 3
1082
1190
  ? `/api/v1/skills/${parts.map(encodeURIComponent).join("/")}/versions`
1083
1191
  : `/api/v1/skills/${encodeURIComponent(fullName)}/versions`;
1192
+ const emptyEnvelope = () => {
1193
+ res.setHeader("X-Skill-VCS", "unavailable");
1194
+ sendJson(res, { versions: [], count: 0, source: "none" }, 200, req);
1195
+ };
1196
+ let fetchResp;
1084
1197
  try {
1085
- const resp = await fetch(`${PLATFORM_BASE}${apiPath}`, {
1198
+ fetchResp = await fetch(`${PLATFORM_BASE}${apiPath}`, {
1086
1199
  signal: AbortSignal.timeout(10_000),
1087
1200
  });
1088
- if (!resp.ok) {
1089
- sendJson(res, { error: "Platform API unavailable" }, 502, req);
1090
- return;
1091
- }
1092
- const data = (await resp.json());
1093
- const versions = Array.isArray(data.versions) ? data.versions : [];
1094
- // Enrich with isInstalled from lockfile
1095
- const lock = readLockfile();
1096
- const installedVersion = lock?.skills[params.skill]?.version;
1097
- const enriched = versions.map((v) => ({
1098
- ...v,
1099
- isInstalled: installedVersion ? v.version === installedVersion : undefined,
1100
- }));
1101
- sendJson(res, enriched, 200, req);
1102
1201
  }
1103
1202
  catch {
1104
- sendJson(res, { error: "Platform API unavailable" }, 502, req);
1203
+ // Network failure / timeout / no VCS surface → empty envelope, not 502.
1204
+ emptyEnvelope();
1205
+ return;
1206
+ }
1207
+ if (!fetchResp.ok) {
1208
+ emptyEnvelope();
1209
+ return;
1210
+ }
1211
+ let data;
1212
+ try {
1213
+ data = (await fetchResp.json());
1214
+ }
1215
+ catch {
1216
+ emptyEnvelope();
1217
+ return;
1105
1218
  }
1219
+ const versions = Array.isArray(data.versions) ? data.versions : [];
1220
+ // Enrich with isInstalled from lockfile
1221
+ const lock = readLockfile();
1222
+ const installedVersion = lock?.skills[params.skill]?.version;
1223
+ const enriched = versions.map((v) => ({
1224
+ ...v,
1225
+ isInstalled: installedVersion ? v.version === installedVersion : undefined,
1226
+ }));
1227
+ sendJson(res, { versions: enriched, count: enriched.length, source: "platform" }, 200, req);
1106
1228
  });
1107
1229
  // T-010: Diff proxy route
1108
1230
  router.get("/api/skills/:plugin/:skill/versions/diff", async (req, res, params) => {
@@ -1113,11 +1235,11 @@ export function registerRoutes(router, root, projectName) {
1113
1235
  sendJson(res, { error: "Missing required query params: from and to" }, 400, req);
1114
1236
  return;
1115
1237
  }
1116
- const fullName = resolveSkillApiName(params.skill);
1238
+ const fullName = await resolveSkillApiName(params.skill);
1117
1239
  const parts = fullName.split("/");
1118
1240
  const basePath = parts.length === 3
1119
- ? `/api/v1/skills/${parts.map(encodeURIComponent).join("/")}/versions`
1120
- : `/api/v1/skills/${encodeURIComponent(fullName)}/versions`;
1241
+ ? `/api/v1/skills/${parts.map(encodeURIComponent).join("/")}/versions/diff`
1242
+ : `/api/v1/skills/${encodeURIComponent(fullName)}/versions/diff`;
1121
1243
  try {
1122
1244
  const resp = await fetch(`${PLATFORM_BASE}${basePath}?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`, { signal: AbortSignal.timeout(10_000) });
1123
1245
  if (!resp.ok) {
@@ -1357,7 +1479,10 @@ export function registerRoutes(router, root, projectName) {
1357
1479
  return;
1358
1480
  }
1359
1481
  try {
1360
- rmSync(skillDir, { recursive: true, force: true });
1482
+ // 0722: route to OS trash (Trash / Recycle Bin / XDG) instead of hard delete
1483
+ // so accidental deletes are recoverable from the user's native Trash app.
1484
+ const trash = (await import("trash")).default;
1485
+ await trash([skillDir]);
1361
1486
  sendJson(res, { ok: true, deleted: `${params.plugin}/${params.skill}` }, 200, req);
1362
1487
  }
1363
1488
  catch (err) {
@@ -1378,20 +1503,35 @@ export function registerRoutes(router, root, projectName) {
1378
1503
  sendJson(res, { description, rawContent: skillContent }, 200, req);
1379
1504
  });
1380
1505
  // Get evals.json
1506
+ //
1507
+ // Envelope (0707 T-023 / T-025):
1508
+ // 200 { exists: false, evals: [] } when evals.json is missing
1509
+ // 200 { exists: true, ...EvalsFile } when valid (evals[], skill_name, …)
1510
+ // 422 { error, errors[] } when malformed / fails schema
1511
+ // 500 { error } only for unexpected I/O failures
1512
+ //
1513
+ // "missing" must be distinguishable from "malformed" so the UI can render
1514
+ // the former as an empty-state CTA ("Create evals") and the latter as a
1515
+ // validation error panel. The earlier 400 status conflated malformed with
1516
+ // generic client errors — 422 Unprocessable Entity is the correct semantic
1517
+ // for well-formed requests whose payload fails validation.
1381
1518
  router.get("/api/skills/:plugin/:skill/evals", async (req, res, params) => {
1382
1519
  const skillDir = resolveSkillDir(root, params.plugin, params.skill);
1383
1520
  const evalsPath = join(skillDir, "evals", "evals.json");
1384
1521
  if (!existsSync(evalsPath)) {
1385
- sendJson(res, { error: "No evals.json found" }, 404, req);
1522
+ // 0704: 200 empty-state sentinel (was 404) "no evals.json yet" is
1523
+ // authoring empty state, not an error the client has to filter.
1524
+ sendJson(res, { exists: false, evals: [] }, 200, req);
1386
1525
  return;
1387
1526
  }
1388
1527
  try {
1389
1528
  const evals = loadAndValidateEvals(skillDir);
1390
- sendJson(res, evals, 200, req);
1529
+ sendJson(res, { exists: true, ...evals }, 200, req);
1391
1530
  }
1392
1531
  catch (err) {
1393
1532
  if (err instanceof EvalValidationError) {
1394
- sendJson(res, { error: err.message, errors: err.errors }, 400, req);
1533
+ // 0707 T-023: malformed 422 (was 400).
1534
+ sendJson(res, { error: err.message, errors: err.errors }, 422, req);
1395
1535
  }
1396
1536
  else {
1397
1537
  sendJson(res, { error: String(err.message) }, 500, req);
@@ -2022,14 +2162,19 @@ export function registerRoutes(router, root, projectName) {
2022
2162
  sendJson(res, stats, 200, req);
2023
2163
  });
2024
2164
  // Get latest benchmark
2165
+ //
2166
+ // Envelope (0707 T-022 / T-025):
2167
+ // 200 null when no benchmark has been persisted
2168
+ // 200 <BenchmarkResult> when a benchmark exists
2169
+ //
2170
+ // Always 200 — a missing benchmark is normal empty state, not an error.
2171
+ // Works for any plugin slug (including dashes like `google-workspace`)
2172
+ // because routing uses `[^/]+` groups (see router.ts T-020).
2025
2173
  router.get("/api/skills/:plugin/:skill/benchmark/latest", async (req, res, params) => {
2174
+ // 0704: always 200; body null = no benchmark persisted yet.
2026
2175
  const skillDir = resolveSkillDir(root, params.plugin, params.skill);
2027
2176
  const benchmark = await readBenchmark(skillDir);
2028
- if (!benchmark) {
2029
- sendJson(res, { error: "No benchmark found" }, 404, req);
2030
- return;
2031
- }
2032
- sendJson(res, benchmark, 200, req);
2177
+ sendJson(res, benchmark ?? null, 200, req);
2033
2178
  });
2034
2179
  // Run activation test (SSE)
2035
2180
  router.post("/api/skills/:plugin/:skill/activation-test", async (req, res, params) => {
@@ -2148,10 +2293,33 @@ Return ONLY the JSON lines, no other text.`;
2148
2293
  }
2149
2294
  });
2150
2295
  // List activation test history (summaries only)
2296
+ //
2297
+ // Envelope (0707 T-024 / T-025):
2298
+ // 200 { runs: [], count: 0 } when the history log doesn't
2299
+ // exist / the skill has never been
2300
+ // activation-tested. listActivation-
2301
+ // Runs() catches ENOENT internally
2302
+ // and returns [] — see activation-
2303
+ // history.ts readHistoryFile().
2304
+ // 200 { runs: [...], count: <N> } when entries exist
2305
+ // 500 { error } only for unexpected I/O failures
2306
+ // (ENOENT is explicitly not one)
2151
2307
  router.get("/api/skills/:plugin/:skill/activation-history", async (req, res, params) => {
2152
2308
  const skillDir = resolveSkillDir(root, params.plugin, params.skill);
2153
- const runs = await listActivationRuns(skillDir);
2154
- sendJson(res, { runs }, 200, req);
2309
+ try {
2310
+ const runs = await listActivationRuns(skillDir);
2311
+ sendJson(res, { runs, count: runs.length }, 200, req);
2312
+ }
2313
+ catch (err) {
2314
+ const code = err?.code;
2315
+ if (code === "ENOENT") {
2316
+ // Defensive — listActivationRuns already swallows ENOENT, but in case
2317
+ // a future refactor propagates it we still return the empty envelope.
2318
+ sendJson(res, { runs: [], count: 0 }, 200, req);
2319
+ return;
2320
+ }
2321
+ sendJson(res, { error: err.message }, 500, req);
2322
+ }
2155
2323
  });
2156
2324
  // Get full activation test run by ID
2157
2325
  router.get("/api/skills/:plugin/:skill/activation-history/:runId", async (req, res, params) => {