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.
- package/README.md +63 -2
- package/agents.json +1 -1
- package/dist/bin.js +0 -0
- package/dist/clone/github-scaffold.d.ts +38 -0
- package/dist/clone/github-scaffold.js +108 -0
- package/dist/clone/github-scaffold.js.map +1 -0
- package/dist/clone/provenance-fork.d.ts +34 -0
- package/dist/clone/provenance-fork.js +97 -0
- package/dist/clone/provenance-fork.js.map +1 -0
- package/dist/clone/reference-scanner.d.ts +19 -0
- package/dist/clone/reference-scanner.js +144 -0
- package/dist/clone/reference-scanner.js.map +1 -0
- package/dist/clone/skill-locator.d.ts +26 -0
- package/dist/clone/skill-locator.js +248 -0
- package/dist/clone/skill-locator.js.map +1 -0
- package/dist/clone/target-router.d.ts +73 -0
- package/dist/clone/target-router.js +200 -0
- package/dist/clone/target-router.js.map +1 -0
- package/dist/clone/types.d.ts +82 -0
- package/dist/clone/types.js +11 -0
- package/dist/clone/types.js.map +1 -0
- package/dist/commands/add.js +96 -32
- package/dist/commands/add.js.map +1 -1
- package/dist/commands/auth.d.ts +23 -0
- package/dist/commands/auth.js +273 -0
- package/dist/commands/auth.js.map +1 -0
- package/dist/commands/check.d.ts +55 -0
- package/dist/commands/check.js +279 -0
- package/dist/commands/check.js.map +1 -0
- package/dist/commands/clone-prompts.d.ts +13 -0
- package/dist/commands/clone-prompts.js +67 -0
- package/dist/commands/clone-prompts.js.map +1 -0
- package/dist/commands/clone.d.ts +70 -0
- package/dist/commands/clone.js +649 -0
- package/dist/commands/clone.js.map +1 -0
- package/dist/commands/eval/serve.js +8 -1
- package/dist/commands/eval/serve.js.map +1 -1
- package/dist/commands/keys.js +54 -2
- package/dist/commands/keys.js.map +1 -1
- package/dist/core/agent-prompts.d.ts +35 -0
- package/dist/core/agent-prompts.js +201 -0
- package/dist/core/agent-prompts.js.map +1 -0
- package/dist/core/skill-generator.d.ts +25 -3
- package/dist/core/skill-generator.js +131 -0
- package/dist/core/skill-generator.js.map +1 -1
- package/dist/eval/skill-scanner.d.ts +2 -12
- package/dist/eval/skill-scanner.js +27 -5
- package/dist/eval/skill-scanner.js.map +1 -1
- package/dist/eval-server/api-routes.d.ts +14 -0
- package/dist/eval-server/api-routes.js +376 -31
- package/dist/eval-server/api-routes.js.map +1 -1
- package/dist/eval-server/data-events.d.ts +1 -1
- package/dist/eval-server/data-events.js.map +1 -1
- package/dist/eval-server/install-engine-routes-helpers.d.ts +1 -3
- package/dist/eval-server/install-engine-routes-helpers.js +6 -14
- package/dist/eval-server/install-engine-routes-helpers.js.map +1 -1
- package/dist/eval-server/origin-resolver.d.ts +42 -0
- package/dist/eval-server/origin-resolver.js +168 -0
- package/dist/eval-server/origin-resolver.js.map +1 -0
- package/dist/eval-server/platform-proxy.d.ts +10 -0
- package/dist/eval-server/platform-proxy.js +58 -2
- package/dist/eval-server/platform-proxy.js.map +1 -1
- package/dist/eval-server/skill-create-routes.d.ts +8 -0
- package/dist/eval-server/skill-create-routes.js +96 -0
- package/dist/eval-server/skill-create-routes.js.map +1 -1
- package/dist/eval-server/skill-resolver.js +40 -0
- package/dist/eval-server/skill-resolver.js.map +1 -1
- package/dist/eval-server/utils/resolve-editor.d.ts +18 -0
- package/dist/eval-server/utils/resolve-editor.js +77 -0
- package/dist/eval-server/utils/resolve-editor.js.map +1 -0
- package/dist/eval-server/utils/scan-install-locations.d.ts +7 -0
- package/dist/eval-server/utils/scan-install-locations.js +20 -0
- package/dist/eval-server/utils/scan-install-locations.js.map +1 -1
- package/dist/eval-server/utils/which.d.ts +15 -0
- package/dist/eval-server/utils/which.js +76 -0
- package/dist/eval-server/utils/which.js.map +1 -0
- package/dist/eval-ui/assets/{CreateSkillPage-T0YWZWw-.js → CreateSkillPage-BmbvQEzE.js} +1 -1
- package/dist/eval-ui/assets/{FindSkillsPalette-KcFM32hZ.js → FindSkillsPalette-D0Zjhm31.js} +2 -2
- package/dist/eval-ui/assets/{SearchPaletteCore-EhBtr4Xx.js → SearchPaletteCore-EhcN1xEa.js} +1 -1
- package/dist/eval-ui/assets/SkillDetailPanel-B5J60ffv.js +1 -0
- package/dist/eval-ui/assets/{UpdateDropdown-pjFhHTi6.js → UpdateDropdown-Celf0_Cr.js} +1 -1
- package/dist/eval-ui/assets/index-BV7k6fdk.js +124 -0
- package/dist/eval-ui/assets/{index-BKAvJDDF.css → index-CKLqBL52.css} +1 -1
- package/dist/eval-ui/index.html +2 -2
- package/dist/index.js +47 -0
- package/dist/index.js.map +1 -1
- package/dist/installer/frontmatter.d.ts +26 -0
- package/dist/installer/frontmatter.js +90 -0
- package/dist/installer/frontmatter.js.map +1 -1
- package/dist/lib/github-fetch.d.ts +22 -0
- package/dist/lib/github-fetch.js +152 -0
- package/dist/lib/github-fetch.js.map +1 -0
- package/dist/lib/keychain.d.ts +41 -0
- package/dist/lib/keychain.js +232 -0
- package/dist/lib/keychain.js.map +1 -0
- package/dist/studio/types.d.ts +13 -0
- package/dist/utils/claude-plugin.d.ts +26 -0
- package/dist/utils/claude-plugin.js +60 -0
- package/dist/utils/claude-plugin.js.map +1 -1
- package/package.json +2 -1
- package/dist/eval-ui/assets/SkillDetailPanel-cyzLsLcK.js +0 -1
- 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 {
|
|
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
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
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
|
|
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
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
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
|
|
1695
|
-
? `/api/v1/skills/${parts.map(encodeURIComponent).join("/")}
|
|
1696
|
-
: `/api/v1/skills/${encodeURIComponent(fullName)}
|
|
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, {
|
|
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
|
-
|
|
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
|
-
//
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
const
|
|
1774
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|