vskill 0.5.125 → 0.5.127

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/agents.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "version": 1,
3
- "generatedAt": "2026-04-26T05:43:23.914Z",
3
+ "generatedAt": "2026-04-26T07:01:09.639Z",
4
4
  "agentPrefixes": [
5
5
  ".adal",
6
6
  ".agent",
@@ -2,7 +2,7 @@
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 } from "node:child_process";
5
+ import { execSync, execFileSync } from "node:child_process";
6
6
  import { join, resolve, dirname, basename } from "node:path";
7
7
  import { homedir } from "node:os";
8
8
  import { sendJson, readBody } from "./router.js";
@@ -1510,20 +1510,54 @@ export function registerRoutes(router, root, projectName) {
1510
1510
  // directly instead of shelling out to whatever `vskill` is on PATH (which
1511
1511
  // may be a different version than the studio is bundling). This guarantees
1512
1512
  // the disk-version reconcile from outdated.ts is applied uniformly.
1513
+ //
1514
+ // 0747 T-002: enrich each row with `installLocations[]`, `localPlugin`,
1515
+ // `localSkill` so the Studio's bell dropdown can render tooltips and route
1516
+ // smart clicks via `revealSkill` instead of guessing local fs identifiers
1517
+ // from the canonical platform name.
1513
1518
  router.get("/api/skills/updates", async (req, res) => {
1514
1519
  try {
1515
1520
  const { getOutdatedJson } = await import("../commands/outdated.js");
1521
+ const { scanSkillInstallLocations } = await import("./utils/scan-install-locations.js");
1516
1522
  const programmatic = await getOutdatedJson();
1517
1523
  if (!programmatic) {
1518
1524
  sendJson(res, [], 200, req);
1519
1525
  return;
1520
1526
  }
1521
- const enriched = programmatic.results.map((r) => ({
1522
- ...r,
1523
- ...(programmatic.pinMap.has(r.name)
1524
- ? { pinned: true, pinnedVersion: programmatic.pinMap.get(r.name) }
1525
- : {}),
1526
- }));
1527
+ // 0747 AC-US4-06 + code-review F-004: per-request memoization keyed by
1528
+ // canonical skill name. Each unique name is scanned exactly once even
1529
+ // if it appears in multiple rows, so the syscall cost is O(unique
1530
+ // names × agents × scopes), not O(rows × agents × scopes). For a
1531
+ // typical /updates response every row has a distinct name, so this
1532
+ // matches the previous one-call-per-row behavior on the happy path
1533
+ // while giving a correct lower bound when names ever duplicate.
1534
+ const scanCache = new Map();
1535
+ const scanOnce = (name) => {
1536
+ const hit = scanCache.get(name);
1537
+ if (hit !== undefined)
1538
+ return hit;
1539
+ const fresh = scanSkillInstallLocations(name, root);
1540
+ scanCache.set(name, fresh);
1541
+ return fresh;
1542
+ };
1543
+ const enriched = programmatic.results.map((r) => {
1544
+ const locations = scanOnce(r.name);
1545
+ const localSkill = r.name.split("/").pop();
1546
+ // Highest-precedence install wins for the local fs pair the click
1547
+ // handler reveals: project > personal > plugin.
1548
+ const precedence = { project: 0, personal: 1, plugin: 2 };
1549
+ const winner = [...locations].sort((a, b) => precedence[a.scope] -
1550
+ precedence[b.scope])[0];
1551
+ return {
1552
+ ...r,
1553
+ ...(programmatic.pinMap.has(r.name)
1554
+ ? { pinned: true, pinnedVersion: programmatic.pinMap.get(r.name) }
1555
+ : {}),
1556
+ installLocations: locations,
1557
+ localSkill,
1558
+ ...(winner?.pluginSlug ? { localPlugin: winner.pluginSlug } : {}),
1559
+ };
1560
+ });
1527
1561
  sendJson(res, enriched, 200, req);
1528
1562
  }
1529
1563
  catch {
@@ -1637,18 +1671,66 @@ export function registerRoutes(router, root, projectName) {
1637
1671
  }
1638
1672
  });
1639
1673
  // T-011: Single-skill update SSE endpoint
1674
+ //
1675
+ // 0747 T-003: optional `?agent=<id>` query param scopes the update to a
1676
+ // single agent's install. The id MUST be in AGENTS_REGISTRY (allowlist) —
1677
+ // it is interpolated into the execSync command, so any non-allowlisted
1678
+ // value is rejected before reaching the shell. Without `?agent`, behavior
1679
+ // is unchanged and `vskill update` does its built-in cross-agent fan-out.
1640
1680
  router.post("/api/skills/:plugin/:skill/update", async (req, res, params) => {
1641
1681
  initSSE(res, req);
1642
1682
  const skillName = params.skill;
1643
- sendSSE(res, "progress", { status: "updating", skill: skillName });
1683
+ // Parse optional ?agent=<id>
1684
+ let agentId = null;
1644
1685
  try {
1645
- execSync(`vskill update ${skillName}`, {
1686
+ const reqUrl = req.url ?? "";
1687
+ const url = new URL(reqUrl, "http://localhost");
1688
+ const raw = url.searchParams.get("agent");
1689
+ if (raw !== null) {
1690
+ const allowed = AGENTS_REGISTRY.some((a) => a.id === raw);
1691
+ if (!allowed) {
1692
+ sendSSE(res, "error", {
1693
+ error: `Unknown agent id: ${raw}`,
1694
+ skill: skillName,
1695
+ });
1696
+ sendSSEDone(res, { status: "error", skill: skillName });
1697
+ return;
1698
+ }
1699
+ agentId = raw;
1700
+ }
1701
+ }
1702
+ catch {
1703
+ // URL parse error → behave as if ?agent was omitted
1704
+ }
1705
+ sendSSE(res, "progress", {
1706
+ status: "updating",
1707
+ skill: skillName,
1708
+ ...(agentId ? { agent: agentId } : {}),
1709
+ });
1710
+ try {
1711
+ // 0747 code-review F-001: use execFileSync with argv array — never
1712
+ // shell-interpolate user-controlled values. skillName comes from a
1713
+ // route param and could otherwise carry shell metacharacters via
1714
+ // decodeURIComponent. agentId is allowlisted above; we still pass it
1715
+ // as a separate arg for defense-in-depth.
1716
+ const args = ["update", skillName];
1717
+ if (agentId)
1718
+ args.push("--agent", agentId);
1719
+ execFileSync("vskill", args, {
1646
1720
  timeout: 60_000,
1647
1721
  encoding: "utf-8",
1648
1722
  stdio: ["ignore", "pipe", "pipe"],
1649
1723
  });
1650
- sendSSE(res, "progress", { status: "done", skill: skillName });
1651
- sendSSEDone(res, { status: "done", skill: skillName });
1724
+ sendSSE(res, "progress", {
1725
+ status: "done",
1726
+ skill: skillName,
1727
+ ...(agentId ? { agent: agentId } : {}),
1728
+ });
1729
+ sendSSEDone(res, {
1730
+ status: "done",
1731
+ skill: skillName,
1732
+ ...(agentId ? { agent: agentId } : {}),
1733
+ });
1652
1734
  }
1653
1735
  catch (err) {
1654
1736
  sendSSE(res, "error", { error: err.message, skill: skillName });
@@ -1672,7 +1754,10 @@ export function registerRoutes(router, root, projectName) {
1672
1754
  for (const skill of skills) {
1673
1755
  sendSSE(res, "skill:start", { skill });
1674
1756
  try {
1675
- execSync(`vskill update ${skill}`, {
1757
+ // 0747 code-review F-001 (defense-in-depth): batch endpoint also
1758
+ // shell-interpolated body.skills[]. Switch to execFileSync argv
1759
+ // form so request-body values can never reach the shell.
1760
+ execFileSync("vskill", ["update", skill], {
1676
1761
  timeout: 60_000,
1677
1762
  encoding: "utf-8",
1678
1763
  stdio: ["ignore", "pipe", "pipe"],