vskill 1.0.14 → 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 (88) hide show
  1. package/README.md +61 -0
  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/clone-prompts.d.ts +13 -0
  28. package/dist/commands/clone-prompts.js +67 -0
  29. package/dist/commands/clone-prompts.js.map +1 -0
  30. package/dist/commands/clone.d.ts +70 -0
  31. package/dist/commands/clone.js +649 -0
  32. package/dist/commands/clone.js.map +1 -0
  33. package/dist/commands/eval/serve.js +8 -1
  34. package/dist/commands/eval/serve.js.map +1 -1
  35. package/dist/commands/keys.js +54 -2
  36. package/dist/commands/keys.js.map +1 -1
  37. package/dist/eval/skill-scanner.d.ts +2 -12
  38. package/dist/eval/skill-scanner.js +27 -5
  39. package/dist/eval/skill-scanner.js.map +1 -1
  40. package/dist/eval-server/api-routes.js +338 -31
  41. package/dist/eval-server/api-routes.js.map +1 -1
  42. package/dist/eval-server/data-events.d.ts +1 -1
  43. package/dist/eval-server/data-events.js.map +1 -1
  44. package/dist/eval-server/install-engine-routes-helpers.d.ts +1 -3
  45. package/dist/eval-server/install-engine-routes-helpers.js +6 -14
  46. package/dist/eval-server/install-engine-routes-helpers.js.map +1 -1
  47. package/dist/eval-server/origin-resolver.d.ts +42 -0
  48. package/dist/eval-server/origin-resolver.js +168 -0
  49. package/dist/eval-server/origin-resolver.js.map +1 -0
  50. package/dist/eval-server/platform-proxy.d.ts +10 -0
  51. package/dist/eval-server/platform-proxy.js +58 -2
  52. package/dist/eval-server/platform-proxy.js.map +1 -1
  53. package/dist/eval-server/skill-resolver.js +40 -0
  54. package/dist/eval-server/skill-resolver.js.map +1 -1
  55. package/dist/eval-server/utils/resolve-editor.d.ts +6 -1
  56. package/dist/eval-server/utils/resolve-editor.js +11 -26
  57. package/dist/eval-server/utils/resolve-editor.js.map +1 -1
  58. package/dist/eval-server/utils/scan-install-locations.d.ts +7 -0
  59. package/dist/eval-server/utils/scan-install-locations.js +20 -0
  60. package/dist/eval-server/utils/scan-install-locations.js.map +1 -1
  61. package/dist/eval-server/utils/which.d.ts +15 -0
  62. package/dist/eval-server/utils/which.js +76 -0
  63. package/dist/eval-server/utils/which.js.map +1 -0
  64. package/dist/eval-ui/assets/{CreateSkillPage-CKvqAya0.js → CreateSkillPage-BmbvQEzE.js} +1 -1
  65. package/dist/eval-ui/assets/{FindSkillsPalette-B8pTa5NP.js → FindSkillsPalette-D0Zjhm31.js} +2 -2
  66. package/dist/eval-ui/assets/{SearchPaletteCore-CkVRvaZk.js → SearchPaletteCore-EhcN1xEa.js} +1 -1
  67. package/dist/eval-ui/assets/SkillDetailPanel-B5J60ffv.js +1 -0
  68. package/dist/eval-ui/assets/{UpdateDropdown-DA7OktXO.js → UpdateDropdown-Celf0_Cr.js} +1 -1
  69. package/dist/eval-ui/assets/{index-DCbohW6l.js → index-BV7k6fdk.js} +43 -41
  70. package/dist/eval-ui/assets/{index-BKAvJDDF.css → index-CKLqBL52.css} +1 -1
  71. package/dist/eval-ui/index.html +2 -2
  72. package/dist/index.js +39 -0
  73. package/dist/index.js.map +1 -1
  74. package/dist/installer/frontmatter.d.ts +26 -0
  75. package/dist/installer/frontmatter.js +90 -0
  76. package/dist/installer/frontmatter.js.map +1 -1
  77. package/dist/lib/github-fetch.d.ts +22 -0
  78. package/dist/lib/github-fetch.js +152 -0
  79. package/dist/lib/github-fetch.js.map +1 -0
  80. package/dist/lib/keychain.d.ts +41 -0
  81. package/dist/lib/keychain.js +232 -0
  82. package/dist/lib/keychain.js.map +1 -0
  83. package/dist/studio/types.d.ts +13 -0
  84. package/dist/utils/claude-plugin.d.ts +26 -0
  85. package/dist/utils/claude-plugin.js +60 -0
  86. package/dist/utils/claude-plugin.js.map +1 -1
  87. package/package.json +2 -1
  88. package/dist/eval-ui/assets/SkillDetailPanel-d4_LquVH.js +0 -1
@@ -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";
@@ -1068,14 +1072,9 @@ export function detectProjectAgents(root) {
1068
1072
  return data;
1069
1073
  }
1070
1074
  function isBinaryOnPath(name) {
1071
- try {
1072
- const cmd = process.platform === "win32" ? `where ${name}` : `command -v ${name}`;
1073
- execSync(cmd, { stdio: "ignore", timeout: 1000 });
1074
- return true;
1075
- }
1076
- catch {
1077
- return false;
1078
- }
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);
1079
1078
  }
1080
1079
  // 0701 — Read the active Claude Code model from ~/.claude/settings.json so the
1081
1080
  // Studio picker can surface "routing to claude-opus-4-7[1m]" under the generic
@@ -1681,9 +1680,7 @@ export function registerRoutes(router, root, projectName) {
1681
1680
  const localSkill = r.name.split("/").pop();
1682
1681
  // Highest-precedence install wins for the local fs pair the click
1683
1682
  // handler reveals: project > personal > plugin.
1684
- const precedence = { project: 0, personal: 1, plugin: 2 };
1685
- const winner = [...locations].sort((a, b) => precedence[a.scope] -
1686
- precedence[b.scope])[0];
1683
+ const winner = pickHighestPrecedenceLocation(locations);
1687
1684
  return {
1688
1685
  ...r,
1689
1686
  ...(programmatic.pinMap.has(r.name)
@@ -1724,17 +1721,41 @@ export function registerRoutes(router, root, projectName) {
1724
1721
  // "no version history" without treating it as an error.
1725
1722
  //
1726
1723
  // Never returns 5xx for the "no VCS surface" case — that is normal empty state.
1727
- router.get("/api/skills/:plugin/:skill/versions", async (req, res, params) => {
1728
- // 0765: pass plugin so installed-agent views (.claude/, .cursor/, ...)
1729
- // resolve via lockfile instead of source-tree probe.
1730
- 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);
1731
1740
  const parts = fullName.split("/");
1732
- const apiPath = parts.length === 3
1733
- ? `/api/v1/skills/${parts.map(encodeURIComponent).join("/")}/versions`
1734
- : `/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`;
1735
1750
  const emptyEnvelope = () => {
1736
1751
  res.setHeader("X-Skill-VCS", "unavailable");
1737
- 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);
1738
1759
  };
1739
1760
  let fetchResp;
1740
1761
  try {
@@ -1794,7 +1815,23 @@ export function registerRoutes(router, root, projectName) {
1794
1815
  ...v,
1795
1816
  isInstalled: installedVersion ? v.version === installedVersion : false,
1796
1817
  }));
1797
- 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);
1798
1835
  });
1799
1836
  // T-010: Diff proxy route
1800
1837
  router.get("/api/skills/:plugin/:skill/versions/diff", async (req, res, params) => {
@@ -1805,12 +1842,11 @@ export function registerRoutes(router, root, projectName) {
1805
1842
  sendJson(res, { error: "Missing required query params: from and to" }, 400, req);
1806
1843
  return;
1807
1844
  }
1808
- // 0765: same plugin-aware resolution as /versions.
1809
- const fullName = await resolveSkillApiName(params.skill, params.plugin);
1810
- const parts = fullName.split("/");
1811
- const basePath = parts.length === 3
1812
- ? `/api/v1/skills/${parts.map(encodeURIComponent).join("/")}/versions/diff`
1813
- : `/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`;
1814
1850
  try {
1815
1851
  const resp = await fetch(`${PLATFORM_BASE}${basePath}?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`, { signal: AbortSignal.timeout(10_000) });
1816
1852
  // 0746: pass through upstream status + body. Legitimate 4xx from the
@@ -1855,6 +1891,106 @@ export function registerRoutes(router, root, projectName) {
1855
1891
  s.length <= 200 &&
1856
1892
  !s.startsWith("-") &&
1857
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
+ });
1858
1994
  router.post("/api/skills/:plugin/:skill/update", async (req, res, params) => {
1859
1995
  initSSE(res, req);
1860
1996
  const skillName = params.skill;
@@ -1988,6 +2124,18 @@ export function registerRoutes(router, root, projectName) {
1988
2124
  // file-read routes need to reach into ~/.claude/plugins/{cache,marketplaces}
1989
2125
  // and ~/.claude/skills. Path traversal is rejected by validateAgainstAllowlist
1990
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
+ };
1991
2139
  const skillFsAllowedRoots = () => {
1992
2140
  const home = homedir();
1993
2141
  return [
@@ -1995,6 +2143,10 @@ export function registerRoutes(router, root, projectName) {
1995
2143
  join(home, ".claude/plugins/cache"),
1996
2144
  join(home, ".claude/plugins/marketplaces"),
1997
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"),
1998
2150
  ];
1999
2151
  };
2000
2152
  // 0769 F-002: cold-server deep links to /api/skills/:plugin/:skill/files (or
@@ -2097,12 +2249,15 @@ export function registerRoutes(router, root, projectName) {
2097
2249
  }
2098
2250
  const url = new URL(req.url ?? "", "http://localhost");
2099
2251
  const filePath = url.searchParams.get("path") ?? "";
2252
+ const raw = url.searchParams.get("raw") === "1";
2100
2253
  if (!filePath) {
2101
2254
  sendJson(res, { error: "Missing path query parameter" }, 400, req);
2102
2255
  return;
2103
2256
  }
2104
2257
  const fullPath = resolve(join(skillDir, filePath));
2105
- 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)) {
2106
2261
  sendJson(res, { error: "Access denied" }, 403, req);
2107
2262
  return;
2108
2263
  }
@@ -2121,7 +2276,6 @@ export function registerRoutes(router, root, projectName) {
2121
2276
  sendJson(res, { error: "File too large", path: filePath, size }, 413, req);
2122
2277
  return;
2123
2278
  }
2124
- // Binary detection: check first 8KB for null bytes
2125
2279
  let buf;
2126
2280
  try {
2127
2281
  buf = readFileSync(fullPath);
@@ -2130,6 +2284,51 @@ export function registerRoutes(router, root, projectName) {
2130
2284
  sendJson(res, { error: `Unable to read file: ${err.message}` }, 500, req);
2131
2285
  return;
2132
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
2133
2332
  const probe = buf.subarray(0, Math.min(8192, buf.length));
2134
2333
  for (let i = 0; i < probe.length; i++) {
2135
2334
  if (probe[i] === 0) {
@@ -2159,7 +2358,9 @@ export function registerRoutes(router, root, projectName) {
2159
2358
  return;
2160
2359
  }
2161
2360
  const fullPath = resolve(join(skillDir, filePath));
2162
- 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)) {
2163
2364
  sendJson(res, { error: "Path traversal denied" }, 403, req);
2164
2365
  return;
2165
2366
  }
@@ -3162,6 +3363,112 @@ Return ONLY the JSON lines, no other text.`;
3162
3363
  const skillDependencies = detectSkillDependencies(content);
3163
3364
  sendJson(res, { mcpDependencies, skillDependencies }, 200, req);
3164
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
+ });
3165
3472
  // Handle CORS preflight
3166
3473
  router.options = (req, res) => {
3167
3474
  const origin = req.headers.origin;