vskill 1.0.13 → 1.0.15

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 (102) hide show
  1. package/README.md +63 -2
  2. package/agents.json +1 -1
  3. package/dist/bin.js +0 -0
  4. package/dist/clone/github-scaffold.d.ts +38 -0
  5. package/dist/clone/github-scaffold.js +108 -0
  6. package/dist/clone/github-scaffold.js.map +1 -0
  7. package/dist/clone/provenance-fork.d.ts +34 -0
  8. package/dist/clone/provenance-fork.js +97 -0
  9. package/dist/clone/provenance-fork.js.map +1 -0
  10. package/dist/clone/reference-scanner.d.ts +19 -0
  11. package/dist/clone/reference-scanner.js +144 -0
  12. package/dist/clone/reference-scanner.js.map +1 -0
  13. package/dist/clone/skill-locator.d.ts +26 -0
  14. package/dist/clone/skill-locator.js +248 -0
  15. package/dist/clone/skill-locator.js.map +1 -0
  16. package/dist/clone/target-router.d.ts +73 -0
  17. package/dist/clone/target-router.js +200 -0
  18. package/dist/clone/target-router.js.map +1 -0
  19. package/dist/clone/types.d.ts +82 -0
  20. package/dist/clone/types.js +11 -0
  21. package/dist/clone/types.js.map +1 -0
  22. package/dist/commands/add.js +96 -32
  23. package/dist/commands/add.js.map +1 -1
  24. package/dist/commands/auth.d.ts +23 -0
  25. package/dist/commands/auth.js +273 -0
  26. package/dist/commands/auth.js.map +1 -0
  27. package/dist/commands/check.d.ts +55 -0
  28. package/dist/commands/check.js +279 -0
  29. package/dist/commands/check.js.map +1 -0
  30. package/dist/commands/clone-prompts.d.ts +13 -0
  31. package/dist/commands/clone-prompts.js +67 -0
  32. package/dist/commands/clone-prompts.js.map +1 -0
  33. package/dist/commands/clone.d.ts +70 -0
  34. package/dist/commands/clone.js +649 -0
  35. package/dist/commands/clone.js.map +1 -0
  36. package/dist/commands/eval/serve.js +8 -1
  37. package/dist/commands/eval/serve.js.map +1 -1
  38. package/dist/commands/keys.js +54 -2
  39. package/dist/commands/keys.js.map +1 -1
  40. package/dist/core/agent-prompts.d.ts +35 -0
  41. package/dist/core/agent-prompts.js +201 -0
  42. package/dist/core/agent-prompts.js.map +1 -0
  43. package/dist/core/skill-generator.d.ts +25 -3
  44. package/dist/core/skill-generator.js +131 -0
  45. package/dist/core/skill-generator.js.map +1 -1
  46. package/dist/eval/skill-scanner.d.ts +2 -12
  47. package/dist/eval/skill-scanner.js +27 -5
  48. package/dist/eval/skill-scanner.js.map +1 -1
  49. package/dist/eval-server/api-routes.d.ts +14 -0
  50. package/dist/eval-server/api-routes.js +376 -31
  51. package/dist/eval-server/api-routes.js.map +1 -1
  52. package/dist/eval-server/data-events.d.ts +1 -1
  53. package/dist/eval-server/data-events.js.map +1 -1
  54. package/dist/eval-server/install-engine-routes-helpers.d.ts +1 -3
  55. package/dist/eval-server/install-engine-routes-helpers.js +6 -14
  56. package/dist/eval-server/install-engine-routes-helpers.js.map +1 -1
  57. package/dist/eval-server/origin-resolver.d.ts +42 -0
  58. package/dist/eval-server/origin-resolver.js +168 -0
  59. package/dist/eval-server/origin-resolver.js.map +1 -0
  60. package/dist/eval-server/platform-proxy.d.ts +10 -0
  61. package/dist/eval-server/platform-proxy.js +58 -2
  62. package/dist/eval-server/platform-proxy.js.map +1 -1
  63. package/dist/eval-server/skill-create-routes.d.ts +8 -0
  64. package/dist/eval-server/skill-create-routes.js +96 -0
  65. package/dist/eval-server/skill-create-routes.js.map +1 -1
  66. package/dist/eval-server/skill-resolver.js +40 -0
  67. package/dist/eval-server/skill-resolver.js.map +1 -1
  68. package/dist/eval-server/utils/resolve-editor.d.ts +18 -0
  69. package/dist/eval-server/utils/resolve-editor.js +77 -0
  70. package/dist/eval-server/utils/resolve-editor.js.map +1 -0
  71. package/dist/eval-server/utils/scan-install-locations.d.ts +7 -0
  72. package/dist/eval-server/utils/scan-install-locations.js +20 -0
  73. package/dist/eval-server/utils/scan-install-locations.js.map +1 -1
  74. package/dist/eval-server/utils/which.d.ts +15 -0
  75. package/dist/eval-server/utils/which.js +76 -0
  76. package/dist/eval-server/utils/which.js.map +1 -0
  77. package/dist/eval-ui/assets/{CreateSkillPage-T0YWZWw-.js → CreateSkillPage-BmbvQEzE.js} +1 -1
  78. package/dist/eval-ui/assets/{FindSkillsPalette-KcFM32hZ.js → FindSkillsPalette-D0Zjhm31.js} +2 -2
  79. package/dist/eval-ui/assets/{SearchPaletteCore-EhBtr4Xx.js → SearchPaletteCore-EhcN1xEa.js} +1 -1
  80. package/dist/eval-ui/assets/SkillDetailPanel-B5J60ffv.js +1 -0
  81. package/dist/eval-ui/assets/{UpdateDropdown-pjFhHTi6.js → UpdateDropdown-Celf0_Cr.js} +1 -1
  82. package/dist/eval-ui/assets/index-BV7k6fdk.js +124 -0
  83. package/dist/eval-ui/assets/{index-BKAvJDDF.css → index-CKLqBL52.css} +1 -1
  84. package/dist/eval-ui/index.html +2 -2
  85. package/dist/index.js +47 -0
  86. package/dist/index.js.map +1 -1
  87. package/dist/installer/frontmatter.d.ts +26 -0
  88. package/dist/installer/frontmatter.js +90 -0
  89. package/dist/installer/frontmatter.js.map +1 -1
  90. package/dist/lib/github-fetch.d.ts +22 -0
  91. package/dist/lib/github-fetch.js +152 -0
  92. package/dist/lib/github-fetch.js.map +1 -0
  93. package/dist/lib/keychain.d.ts +41 -0
  94. package/dist/lib/keychain.js +232 -0
  95. package/dist/lib/keychain.js.map +1 -0
  96. package/dist/studio/types.d.ts +13 -0
  97. package/dist/utils/claude-plugin.d.ts +26 -0
  98. package/dist/utils/claude-plugin.js +60 -0
  99. package/dist/utils/claude-plugin.js.map +1 -1
  100. package/package.json +2 -1
  101. package/dist/eval-ui/assets/SkillDetailPanel-cyzLsLcK.js +0 -1
  102. package/dist/eval-ui/assets/index-C3S9iHnq.js +0 -122
@@ -2,8 +2,8 @@
2
2
  // api-routes.ts -- REST API route handlers for the eval UI
3
3
  // ---------------------------------------------------------------------------
4
4
  import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync } from "node:fs";
5
- import { execSync, execFileSync } from "node:child_process";
6
- import { join, resolve, dirname } from "node:path";
5
+ import { execFileSync, spawn } from "node:child_process";
6
+ import { join, resolve, dirname, basename } from "node:path";
7
7
  import { homedir } from "node:os";
8
8
  import { sendJson, readBody } from "./router.js";
9
9
  import { initSSE, sendSSE, sendSSEDone, withHeartbeat, startDynamicHeartbeat } from "./sse-helpers.js";
@@ -11,9 +11,13 @@ import { dataEventBus, emitDataEvent } from "./data-events.js";
11
11
  import { classifyError } from "./error-classifier.js";
12
12
  import { readLockfile, writeLockfile } from "../lockfile/lockfile.js";
13
13
  import { resolveSkillApiName as resolveSkillApiNameImpl } from "./skill-name-resolver.js";
14
+ import { resolveSkillOrigin } from "./origin-resolver.js";
14
15
  import { runBenchmarkSSE, runSingleCaseSSE } from "./benchmark-runner.js";
15
16
  import { getSkillSemaphore } from "./concurrency.js";
16
17
  import { resolveSkillDir, resolveAllowedSkillDir } from "./skill-resolver.js";
18
+ import { scanSkillInstallLocations, pickHighestPrecedenceLocation } from "./utils/scan-install-locations.js";
19
+ import { whichSync } from "./utils/which.js";
20
+ import { resolveEditorCommand, NoEditorError } from "./utils/resolve-editor.js";
17
21
  import { setSkillDirEntry, ensurePluginCacheEntry } from "./skill-dir-registry.js";
18
22
  import { pickInstalledVersion, readSkillMd, sha256Hex, } from "./installed-version.js";
19
23
  import { extractFrontmatterVersion } from "../utils/version.js";
@@ -375,6 +379,9 @@ const EMPTY_METADATA = {
375
379
  sourceAgent: null,
376
380
  repoUrl: null,
377
381
  skillPath: null,
382
+ secrets: null,
383
+ runtime: null,
384
+ integrationTests: null,
378
385
  };
379
386
  /**
380
387
  * Allow-list of metadata children that may be surfaced at the top level
@@ -407,6 +414,20 @@ const SURFACED_METADATA_KEYS = new Set([
407
414
  // Path / file metadata
408
415
  "entryPoint",
409
416
  "entry-point",
417
+ // 0815: multi-file manifest fields (kebab-case canonical, camelCase tolerated).
418
+ "secrets",
419
+ "runtime-python",
420
+ "runtimePython",
421
+ "runtime-pip",
422
+ "runtimePip",
423
+ "runtime-node",
424
+ "runtimeNode",
425
+ "integration-runner",
426
+ "integrationRunner",
427
+ "integration-file",
428
+ "integrationFile",
429
+ "integration-requires",
430
+ "integrationRequires",
410
431
  ]);
411
432
  /**
412
433
  * Minimal YAML frontmatter parser — handles scalars and arrays (inline [a, b]
@@ -716,6 +737,24 @@ export function buildSkillMetadata(skillDir, origin, root) {
716
737
  const deps = toStringArrayOrNull(fm["skill-deps"] ?? fm.skillDeps ?? fm.deps);
717
738
  const mcpDeps = toStringArrayOrNull(fm["mcp-deps"] ?? fm.mcpDeps ?? fm.mcpDependencies);
718
739
  const tags = toStringArrayOrNull(fm.tags);
740
+ // 0815: assemble runtime + integrationTests from flat fields. Secrets stays
741
+ // a string[] (env-var names); purposes live in the skill's `.env.example`.
742
+ const secrets = toStringArrayOrNull(fm.secrets);
743
+ const runtimePython = toStringOrNull(fm["runtime-python"] ?? fm.runtimePython);
744
+ const runtimePip = toStringArrayOrNull(fm["runtime-pip"] ?? fm.runtimePip);
745
+ const runtimeNode = toStringOrNull(fm["runtime-node"] ?? fm.runtimeNode);
746
+ const runtime = runtimePython || runtimePip || runtimeNode
747
+ ? { python: runtimePython, pip: runtimePip, node: runtimeNode }
748
+ : null;
749
+ const rawRunner = toStringOrNull(fm["integration-runner"] ?? fm.integrationRunner);
750
+ const integrationRunner = rawRunner === "vitest" || rawRunner === "pytest" || rawRunner === "none"
751
+ ? rawRunner
752
+ : null;
753
+ const integrationFile = toStringOrNull(fm["integration-file"] ?? fm.integrationFile);
754
+ const integrationRequires = toStringArrayOrNull(fm["integration-requires"] ?? fm.integrationRequires);
755
+ const integrationTests = integrationRunner
756
+ ? { runner: integrationRunner, file: integrationFile, requires: integrationRequires }
757
+ : null;
719
758
  return {
720
759
  description: toStringOrNull(fm.description),
721
760
  version: toStringOrNull(fm.version),
@@ -732,6 +771,9 @@ export function buildSkillMetadata(skillDir, origin, root) {
732
771
  sourceAgent: deriveSourceAgent(skillDir, root, origin),
733
772
  repoUrl: sourceLink.repoUrl,
734
773
  skillPath: sourceLink.skillPath,
774
+ secrets,
775
+ runtime,
776
+ integrationTests,
735
777
  };
736
778
  }
737
779
  // ---------------------------------------------------------------------------
@@ -1030,14 +1072,9 @@ export function detectProjectAgents(root) {
1030
1072
  return data;
1031
1073
  }
1032
1074
  function isBinaryOnPath(name) {
1033
- try {
1034
- const cmd = process.platform === "win32" ? `where ${name}` : `command -v ${name}`;
1035
- execSync(cmd, { stdio: "ignore", timeout: 1000 });
1036
- return true;
1037
- }
1038
- catch {
1039
- return false;
1040
- }
1075
+ // 0820 follow-up: delegate to the shared utils/which.ts helper. Cached
1076
+ // there per-process, so the outer `detectionCache` is now belt-and-braces.
1077
+ return whichSync(name);
1041
1078
  }
1042
1079
  // 0701 — Read the active Claude Code model from ~/.claude/settings.json so the
1043
1080
  // Studio picker can surface "routing to claude-opus-4-7[1m]" under the generic
@@ -1643,9 +1680,7 @@ export function registerRoutes(router, root, projectName) {
1643
1680
  const localSkill = r.name.split("/").pop();
1644
1681
  // Highest-precedence install wins for the local fs pair the click
1645
1682
  // handler reveals: project > personal > plugin.
1646
- const precedence = { project: 0, personal: 1, plugin: 2 };
1647
- const winner = [...locations].sort((a, b) => precedence[a.scope] -
1648
- precedence[b.scope])[0];
1683
+ const winner = pickHighestPrecedenceLocation(locations);
1649
1684
  return {
1650
1685
  ...r,
1651
1686
  ...(programmatic.pinMap.has(r.name)
@@ -1686,17 +1721,41 @@ export function registerRoutes(router, root, projectName) {
1686
1721
  // "no version history" without treating it as an error.
1687
1722
  //
1688
1723
  // Never returns 5xx for the "no VCS surface" case — that is normal empty state.
1689
- router.get("/api/skills/:plugin/:skill/versions", async (req, res, params) => {
1690
- // 0765: pass plugin so installed-agent views (.claude/, .cursor/, ...)
1691
- // resolve via lockfile instead of source-tree probe.
1692
- const fullName = await resolveSkillApiName(params.skill, params.plugin);
1724
+ // 0823 F-001: shared apiPath builder used by /versions, /versions/diff,
1725
+ // and the rescan endpoint so all three resolve via the same origin chain
1726
+ // (project lockfile ~/.agents → Anthropic registry → git-remote fallback).
1727
+ // Returns `{ apiPathRoot, origin }` — callers append `/versions` or
1728
+ // `/versions/diff` as needed.
1729
+ async function buildSkillApiPath(skill, plugin) {
1730
+ const origin = await resolveSkillOrigin(skill, plugin, root);
1731
+ if (origin.owner && origin.repo) {
1732
+ return {
1733
+ apiPathRoot: `/api/v1/skills/${encodeURIComponent(origin.owner)}/${encodeURIComponent(origin.repo)}/${encodeURIComponent(skill)}`,
1734
+ origin,
1735
+ };
1736
+ }
1737
+ // 0765 fallback: legacy resolver covers source-tree authored skills
1738
+ // whose origin comes from a git remote, not a lockfile.
1739
+ const fullName = await resolveSkillApiName(skill, plugin);
1693
1740
  const parts = fullName.split("/");
1694
- const apiPath = parts.length === 3
1695
- ? `/api/v1/skills/${parts.map(encodeURIComponent).join("/")}/versions`
1696
- : `/api/v1/skills/${encodeURIComponent(fullName)}/versions`;
1741
+ const apiPathRoot = parts.length === 3
1742
+ ? `/api/v1/skills/${parts.map(encodeURIComponent).join("/")}`
1743
+ : `/api/v1/skills/${encodeURIComponent(fullName)}`;
1744
+ return { apiPathRoot, origin };
1745
+ }
1746
+ router.get("/api/skills/:plugin/:skill/versions", async (req, res, params) => {
1747
+ // 0823: comprehensive origin resolver via shared buildSkillApiPath.
1748
+ const { apiPathRoot, origin } = await buildSkillApiPath(params.skill, params.plugin);
1749
+ const apiPath = `${apiPathRoot}/versions`;
1697
1750
  const emptyEnvelope = () => {
1698
1751
  res.setHeader("X-Skill-VCS", "unavailable");
1699
- sendJson(res, { versions: [], count: 0, source: "none" }, 200, req);
1752
+ sendJson(res, {
1753
+ versions: [],
1754
+ count: 0,
1755
+ source: "none",
1756
+ provider: origin.provider,
1757
+ trackedForUpdates: false,
1758
+ }, 200, req);
1700
1759
  };
1701
1760
  let fetchResp;
1702
1761
  try {
@@ -1756,7 +1815,23 @@ export function registerRoutes(router, root, projectName) {
1756
1815
  ...v,
1757
1816
  isInstalled: installedVersion ? v.version === installedVersion : false,
1758
1817
  }));
1759
- sendJson(res, { versions: enriched, count: enriched.length, source: "platform" }, 200, req);
1818
+ // 0823 F-003 (iter 4): runtime-verify Anthropic-registry hits via
1819
+ // trackedForUpdates. If a registry-mapped skill returns no upstream
1820
+ // versions (404 or empty), downgrade the provider chip to "local" so the
1821
+ // UI doesn't claim "Anthropic" for a skill that isn't actually indexed
1822
+ // upstream. Lockfile-derived providers stay regardless of count (the
1823
+ // lockfile entry IS the proof the user installed it from somewhere real).
1824
+ const tracked = origin.trackedForUpdates && enriched.length > 0;
1825
+ const reportedProvider = origin.source === "anthropic-registry" && !tracked ? "local" : origin.provider;
1826
+ sendJson(res, {
1827
+ versions: enriched,
1828
+ count: enriched.length,
1829
+ source: "platform",
1830
+ // 0823: provider classification + tracking flag drive UI chip
1831
+ // and CheckNowButton visibility.
1832
+ provider: reportedProvider,
1833
+ trackedForUpdates: tracked,
1834
+ }, 200, req);
1760
1835
  });
1761
1836
  // T-010: Diff proxy route
1762
1837
  router.get("/api/skills/:plugin/:skill/versions/diff", async (req, res, params) => {
@@ -1767,12 +1842,11 @@ export function registerRoutes(router, root, projectName) {
1767
1842
  sendJson(res, { error: "Missing required query params: from and to" }, 400, req);
1768
1843
  return;
1769
1844
  }
1770
- // 0765: same plugin-aware resolution as /versions.
1771
- const fullName = await resolveSkillApiName(params.skill, params.plugin);
1772
- const parts = fullName.split("/");
1773
- const basePath = parts.length === 3
1774
- ? `/api/v1/skills/${parts.map(encodeURIComponent).join("/")}/versions/diff`
1775
- : `/api/v1/skills/${encodeURIComponent(fullName)}/versions/diff`;
1845
+ // 0823 F-001: use the same origin-resolver-aware path builder as /versions
1846
+ // so Anthropic-shipped skills (slack-messaging, pptx, etc.) get the
1847
+ // matching upstream URL for diff requests, not the legacy bare-name path.
1848
+ const { apiPathRoot } = await buildSkillApiPath(params.skill, params.plugin);
1849
+ const basePath = `${apiPathRoot}/versions/diff`;
1776
1850
  try {
1777
1851
  const resp = await fetch(`${PLATFORM_BASE}${basePath}?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`, { signal: AbortSignal.timeout(10_000) });
1778
1852
  // 0746: pass through upstream status + body. Legitimate 4xx from the
@@ -1817,6 +1891,106 @@ export function registerRoutes(router, root, projectName) {
1817
1891
  s.length <= 200 &&
1818
1892
  !s.startsWith("-") &&
1819
1893
  SKILL_SLUG_RE.test(s);
1894
+ // 0823 — POST /api/v1/skills/:id/rescan — single-skill upstream version
1895
+ // check. The client (CheckNowButton) calls this to verify whether a tracked
1896
+ // installed skill has new versions available. The :id param is URL-encoded
1897
+ // `<plugin>/<skill>` (e.g. `.claude%2Fnanobanana`).
1898
+ //
1899
+ // Flow:
1900
+ // 1. URL-decode :id → (plugin, skill); validate slug.
1901
+ // 2. resolveSkillOrigin → upstream owner/repo (or local fallback).
1902
+ // 3. Fetch upstream versions; emit `skill.updated` via dataEventBus so
1903
+ // any local listeners (and the SSE stream once bridged) can react.
1904
+ // 4. Return { jobId } synchronously. The actual update install (if any)
1905
+ // goes through the existing `/update` route — rescan is read-only.
1906
+ //
1907
+ // Idempotent + side-effect free on disk.
1908
+ router.post("/api/v1/skills/:id/rescan", async (req, res, params) => {
1909
+ const rawId = String(params.id ?? "");
1910
+ const decoded = (() => {
1911
+ try {
1912
+ return decodeURIComponent(rawId);
1913
+ }
1914
+ catch {
1915
+ return rawId;
1916
+ }
1917
+ })();
1918
+ const slashIdx = decoded.indexOf("/");
1919
+ if (slashIdx <= 0 || slashIdx === decoded.length - 1) {
1920
+ sendJson(res, { error: `Invalid skill id: ${rawId}` }, 400, req);
1921
+ return;
1922
+ }
1923
+ const plugin = decoded.slice(0, slashIdx);
1924
+ const skill = decoded.slice(slashIdx + 1);
1925
+ if (!isSafeSkillName(skill) || !isSafeSkillName(plugin)) {
1926
+ sendJson(res, { error: `Invalid skill id: ${rawId}` }, 400, req);
1927
+ return;
1928
+ }
1929
+ // 0823 F-002 (security iter 4): isSafeSkillName regex permits `/` (legal
1930
+ // for owner/repo/skill triples) so a doubly-encoded `.claude%2F.claude%2Fx`
1931
+ // would split to plugin=.claude, skill=.claude/x. The skill segment after
1932
+ // the first split MUST be a single slug — reject any embedded slash.
1933
+ if (plugin.includes("/") || skill.includes("/")) {
1934
+ sendJson(res, { error: `Invalid skill id (nested slash): ${rawId}` }, 400, req);
1935
+ return;
1936
+ }
1937
+ // 0823 F-005 (low): use crypto.randomUUID per FR-005 spec — collision-
1938
+ // free + auditable. Falls back to a Math.random suffix if the runtime
1939
+ // is too old to expose the global API (Node 14 / very old browsers).
1940
+ const jobId = (() => {
1941
+ try {
1942
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1943
+ const c = globalThis.crypto;
1944
+ if (c?.randomUUID)
1945
+ return `rescan-${c.randomUUID()}`;
1946
+ }
1947
+ catch { /* fall through */ }
1948
+ return `rescan-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
1949
+ })();
1950
+ let versions = [];
1951
+ try {
1952
+ const origin = await resolveSkillOrigin(skill, plugin, root);
1953
+ if (origin.owner && origin.repo) {
1954
+ const apiPath = `/api/v1/skills/${encodeURIComponent(origin.owner)}/${encodeURIComponent(origin.repo)}/${encodeURIComponent(skill)}/versions`;
1955
+ try {
1956
+ const fetchResp = await fetch(`${PLATFORM_BASE}${apiPath}`, {
1957
+ signal: AbortSignal.timeout(10_000),
1958
+ });
1959
+ if (fetchResp.ok) {
1960
+ const data = (await fetchResp.json());
1961
+ versions = Array.isArray(data.versions) ? data.versions : [];
1962
+ }
1963
+ else {
1964
+ // 0823 F-009: surface non-OK responses for ops debugging — the
1965
+ // user-facing button still treats the rescan as "no change", but
1966
+ // the operator can see why upstream said no in the studio log.
1967
+ console.warn(`[rescan] upstream ${PLATFORM_BASE}${apiPath} → ${fetchResp.status} ${fetchResp.statusText}`);
1968
+ }
1969
+ }
1970
+ catch (fetchErr) {
1971
+ // 0823 F-009: log network failures instead of silently swallowing.
1972
+ console.warn(`[rescan] upstream fetch failed for ${plugin}/${skill}:`, fetchErr.message);
1973
+ }
1974
+ }
1975
+ }
1976
+ catch (resolveErr) {
1977
+ // 0823 F-009: resolveSkillOrigin should never throw, but if it does the
1978
+ // operator needs to know — silent failure here would look like "no
1979
+ // upstream changes" to the user forever.
1980
+ console.error(`[rescan] resolveSkillOrigin threw for ${plugin}/${skill}:`, resolveErr.message);
1981
+ }
1982
+ // Emit local event so any in-process listeners (and the SSE bridge once
1983
+ // wired) can push to clients. CheckNowButton's existing 30s timeout
1984
+ // serves as the fallback if no SSE event reaches `updatesById`.
1985
+ emitDataEvent("skill.updated", {
1986
+ plugin,
1987
+ skill,
1988
+ versions,
1989
+ jobId,
1990
+ timestamp: new Date().toISOString(),
1991
+ });
1992
+ sendJson(res, { jobId }, 200, req);
1993
+ });
1820
1994
  router.post("/api/skills/:plugin/:skill/update", async (req, res, params) => {
1821
1995
  initSSE(res, req);
1822
1996
  const skillName = params.skill;
@@ -1950,6 +2124,18 @@ export function registerRoutes(router, root, projectName) {
1950
2124
  // file-read routes need to reach into ~/.claude/plugins/{cache,marketplaces}
1951
2125
  // and ~/.claude/skills. Path traversal is rejected by validateAgainstAllowlist
1952
2126
  // inside resolveAllowedSkillDir.
2127
+ // 0823 simplify: shared trailing-separator-aware containment check used by
2128
+ // /file (read) and /file (PUT/save). Plain `startsWith()` is unsafe because
2129
+ // two skills with shared prefixes (e.g. `foo` + `foo-secret`) collide. This
2130
+ // helper appends the separator and explicitly accepts the exact-match case.
2131
+ // Returns true when `candidate` is at or below `root` after normalization.
2132
+ const isContainedIn = (candidate, root) => {
2133
+ const r = resolve(root);
2134
+ if (candidate === r)
2135
+ return true;
2136
+ const rWithSep = r.endsWith("/") ? r : r + "/";
2137
+ return candidate.startsWith(rWithSep);
2138
+ };
1953
2139
  const skillFsAllowedRoots = () => {
1954
2140
  const home = homedir();
1955
2141
  return [
@@ -1957,6 +2143,10 @@ export function registerRoutes(router, root, projectName) {
1957
2143
  join(home, ".claude/plugins/cache"),
1958
2144
  join(home, ".claude/plugins/marketplaces"),
1959
2145
  join(home, ".claude/skills"),
2146
+ // 0823: vskill install --global writes to ~/.agents/skills, and many
2147
+ // ~/.claude/skills/* entries are symlinks INTO that dir. Without this
2148
+ // allowlist entry the realpath check rejects them as "outside allowlist".
2149
+ join(home, ".agents/skills"),
1960
2150
  ];
1961
2151
  };
1962
2152
  // 0769 F-002: cold-server deep links to /api/skills/:plugin/:skill/files (or
@@ -2059,12 +2249,15 @@ export function registerRoutes(router, root, projectName) {
2059
2249
  }
2060
2250
  const url = new URL(req.url ?? "", "http://localhost");
2061
2251
  const filePath = url.searchParams.get("path") ?? "";
2252
+ const raw = url.searchParams.get("raw") === "1";
2062
2253
  if (!filePath) {
2063
2254
  sendJson(res, { error: "Missing path query parameter" }, 400, req);
2064
2255
  return;
2065
2256
  }
2066
2257
  const fullPath = resolve(join(skillDir, filePath));
2067
- if (!fullPath.startsWith(resolve(skillDir))) {
2258
+ // 0823 F-002 (security): trailing-separator-aware containment via shared
2259
+ // helper — prevents prefix-confusion (e.g. `foo` vs `foo-secret`).
2260
+ if (!isContainedIn(fullPath, skillDir)) {
2068
2261
  sendJson(res, { error: "Access denied" }, 403, req);
2069
2262
  return;
2070
2263
  }
@@ -2083,7 +2276,6 @@ export function registerRoutes(router, root, projectName) {
2083
2276
  sendJson(res, { error: "File too large", path: filePath, size }, 413, req);
2084
2277
  return;
2085
2278
  }
2086
- // Binary detection: check first 8KB for null bytes
2087
2279
  let buf;
2088
2280
  try {
2089
2281
  buf = readFileSync(fullPath);
@@ -2092,6 +2284,51 @@ export function registerRoutes(router, root, projectName) {
2092
2284
  sendJson(res, { error: `Unable to read file: ${err.message}` }, 500, req);
2093
2285
  return;
2094
2286
  }
2287
+ // 0823 F-004: raw=1 returns the file bytes with a sniffed Content-Type so
2288
+ // <img src=...&raw=1> works for inline preview. Binary detection still
2289
+ // runs; raw mode only changes how the bytes are delivered, not which
2290
+ // files are accepted.
2291
+ if (raw) {
2292
+ const ext = filePath.toLowerCase().slice(filePath.lastIndexOf(".") + 1);
2293
+ // 0823 F-002 (security): SVG is REFUSED via raw because it can carry
2294
+ // <script> that runs in the studio origin (localhost full-API access)
2295
+ // when a user navigates the URL directly. SVGs still preview as
2296
+ // "binary" placeholder via the JSON path.
2297
+ if (ext === "svg") {
2298
+ sendJson(res, {
2299
+ error: "SVG raw rendering disabled — script-injection risk. Use the binary placeholder.",
2300
+ }, 415, req);
2301
+ return;
2302
+ }
2303
+ const mime = {
2304
+ png: "image/png",
2305
+ jpg: "image/jpeg",
2306
+ jpeg: "image/jpeg",
2307
+ gif: "image/gif",
2308
+ webp: "image/webp",
2309
+ ico: "image/x-icon",
2310
+ pdf: "application/pdf",
2311
+ };
2312
+ const contentType = mime[ext];
2313
+ if (!contentType) {
2314
+ // Unknown ext + raw=1 — refuse rather than serve as octet-stream
2315
+ // (which the browser may sniff into a script context).
2316
+ sendJson(res, { error: `raw=1 not supported for extension: ${ext || "<none>"}` }, 415, req);
2317
+ return;
2318
+ }
2319
+ res.setHeader("Content-Type", contentType);
2320
+ res.setHeader("Content-Length", String(buf.length));
2321
+ res.setHeader("Cache-Control", "private, max-age=60");
2322
+ // 0823 F-002 (defense in depth): block content-type sniffing AND force
2323
+ // an inline-only Content-Disposition so the browser cannot reinterpret
2324
+ // the response as HTML/script.
2325
+ res.setHeader("X-Content-Type-Options", "nosniff");
2326
+ res.setHeader("Content-Disposition", "inline");
2327
+ res.writeHead(200);
2328
+ res.end(buf);
2329
+ return;
2330
+ }
2331
+ // Binary detection: check first 8KB for null bytes
2095
2332
  const probe = buf.subarray(0, Math.min(8192, buf.length));
2096
2333
  for (let i = 0; i < probe.length; i++) {
2097
2334
  if (probe[i] === 0) {
@@ -2121,7 +2358,9 @@ export function registerRoutes(router, root, projectName) {
2121
2358
  return;
2122
2359
  }
2123
2360
  const fullPath = resolve(join(skillDir, filePath));
2124
- if (!fullPath.startsWith(resolve(skillDir))) {
2361
+ // 0823 F-002 (security): trailing-separator-aware containment via shared
2362
+ // helper — same guard as /file (read).
2363
+ if (!isContainedIn(fullPath, skillDir)) {
2125
2364
  sendJson(res, { error: "Path traversal denied" }, 403, req);
2126
2365
  return;
2127
2366
  }
@@ -3124,6 +3363,112 @@ Return ONLY the JSON lines, no other text.`;
3124
3363
  const skillDependencies = detectSkillDependencies(content);
3125
3364
  sendJson(res, { mcpDependencies, skillDependencies }, 200, req);
3126
3365
  });
3366
+ // 0820 — Reveal in Editor / Open. Spawns the user's preferred editor on
3367
+ // the skill folder (Open) or its SKILL.md (Reveal/Edit). Path-traversal
3368
+ // hardened: never trusts a client-supplied dir; resolves (plugin, skill)
3369
+ // server-side via scanSkillInstallLocations and rejects any non-basename
3370
+ // `file`. Spawn is detached + unref'd so the response returns immediately.
3371
+ router.post("/api/skills/reveal-in-editor", async (req, res) => {
3372
+ let body;
3373
+ try {
3374
+ body = (await readBody(req));
3375
+ }
3376
+ catch {
3377
+ // Malformed JSON or oversized body — surface as 400 invalid_body
3378
+ // rather than letting the router emit a generic 500.
3379
+ sendJson(res, { error: "invalid_body" }, 400, req);
3380
+ return;
3381
+ }
3382
+ const plugin = typeof body.plugin === "string" ? body.plugin : undefined;
3383
+ const skill = typeof body.skill === "string" ? body.skill : undefined;
3384
+ if (plugin === undefined || skill === undefined || skill.length === 0) {
3385
+ sendJson(res, { error: "invalid_body" }, 400, req);
3386
+ return;
3387
+ }
3388
+ let file;
3389
+ if (body.file !== undefined) {
3390
+ // Reject: non-string, empty, embeds either separator (POSIX `/` or
3391
+ // Windows `\`), `.` / `..` as filename, or any control char. We check
3392
+ // both separators explicitly since path.basename on POSIX doesn't
3393
+ // recognize `\` as a separator, and the eval-server may run on either.
3394
+ const f = body.file;
3395
+ const invalid = typeof f !== "string" ||
3396
+ f.length === 0 ||
3397
+ f === "." ||
3398
+ f === ".." ||
3399
+ f.includes("/") ||
3400
+ f.includes("\\") ||
3401
+ // eslint-disable-next-line no-control-regex
3402
+ /[\x00-\x1f]/.test(f);
3403
+ if (invalid) {
3404
+ sendJson(res, { error: "invalid_file" }, 400, req);
3405
+ return;
3406
+ }
3407
+ file = f;
3408
+ }
3409
+ const canonical = plugin ? `${plugin}/${skill}` : skill;
3410
+ const winner = pickHighestPrecedenceLocation(scanSkillInstallLocations(canonical, root));
3411
+ if (!winner) {
3412
+ sendJson(res, { error: "skill_not_found" }, 404, req);
3413
+ return;
3414
+ }
3415
+ const dir = winner.dir;
3416
+ // Defense in depth: scanSkillInstallLocations already restricts dirs to
3417
+ // known scope roots, but a final basename check rejects anything that
3418
+ // doesn't end in the requested skill slug — protects against a future
3419
+ // scanner regression that returned a parent or sibling dir.
3420
+ if (basename(dir) !== skill) {
3421
+ sendJson(res, { error: "skill_not_found" }, 404, req);
3422
+ return;
3423
+ }
3424
+ if (file) {
3425
+ const finalPath = join(dir, file);
3426
+ // TOCTOU: file may be deleted between this check and spawn. We accept
3427
+ // that — editors handle a vanished path by opening an empty buffer, and
3428
+ // the alternative (no pre-check) would leak filesystem details via
3429
+ // spawn ENOENT for normal "this skill has no SKILL.md" cases.
3430
+ if (!existsSync(finalPath)) {
3431
+ sendJson(res, { error: "file_not_found" }, 404, req);
3432
+ return;
3433
+ }
3434
+ }
3435
+ let launch;
3436
+ try {
3437
+ launch = resolveEditorCommand({ dir, file });
3438
+ }
3439
+ catch (err) {
3440
+ if (err instanceof NoEditorError) {
3441
+ sendJson(res, { error: "no_editor" }, 500, req);
3442
+ }
3443
+ else {
3444
+ // Distinguish "resolver blew up" from "spawn blew up". Same 500
3445
+ // shape, but the error code points to the right phase.
3446
+ sendJson(res, { error: "resolve_failed" }, 500, req);
3447
+ }
3448
+ return;
3449
+ }
3450
+ try {
3451
+ await new Promise((resolvePromise, rejectPromise) => {
3452
+ const child = spawn(launch.command, launch.args, {
3453
+ detached: true,
3454
+ stdio: "ignore",
3455
+ });
3456
+ child.once("error", rejectPromise);
3457
+ child.once("spawn", () => {
3458
+ child.unref();
3459
+ resolvePromise();
3460
+ });
3461
+ });
3462
+ }
3463
+ catch (err) {
3464
+ // Log so operators can diagnose ENOENT / permission-denied / etc.;
3465
+ // the user-facing toast is the generic 500.
3466
+ console.warn("[reveal-in-editor] spawn failed:", err);
3467
+ sendJson(res, { error: "spawn_failed" }, 500, req);
3468
+ return;
3469
+ }
3470
+ sendJson(res, { ok: true, command: launch.command, args: launch.args }, 200, req);
3471
+ });
3127
3472
  // Handle CORS preflight
3128
3473
  router.options = (req, res) => {
3129
3474
  const origin = req.headers.origin;