vskill 1.2.3 → 1.2.6

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 (103) hide show
  1. package/README.md +49 -1
  2. package/agents.json +6 -1
  3. package/dist/agents/agents-registry.d.ts +43 -16
  4. package/dist/agents/agents-registry.js +119 -33
  5. package/dist/agents/agents-registry.js.map +1 -1
  6. package/dist/agents/global-dirs.d.ts +19 -0
  7. package/dist/agents/global-dirs.js +53 -0
  8. package/dist/agents/global-dirs.js.map +1 -0
  9. package/dist/commands/add-github-bundle.d.ts +29 -0
  10. package/dist/commands/add-github-bundle.js +135 -0
  11. package/dist/commands/add-github-bundle.js.map +1 -0
  12. package/dist/commands/add-lockfile.d.ts +2 -0
  13. package/dist/commands/add-lockfile.js +1 -1
  14. package/dist/commands/add-lockfile.js.map +1 -1
  15. package/dist/commands/add.d.ts +0 -7
  16. package/dist/commands/add.js +197 -107
  17. package/dist/commands/add.js.map +1 -1
  18. package/dist/commands/audit-webmcp.d.ts +25 -0
  19. package/dist/commands/audit-webmcp.js +167 -0
  20. package/dist/commands/audit-webmcp.js.map +1 -0
  21. package/dist/commands/doctor.d.ts +69 -0
  22. package/dist/commands/doctor.js +760 -0
  23. package/dist/commands/doctor.js.map +1 -0
  24. package/dist/commands/remove.js +43 -8
  25. package/dist/commands/remove.js.map +1 -1
  26. package/dist/commands/update.js +12 -4
  27. package/dist/commands/update.js.map +1 -1
  28. package/dist/discovery/github-skill-bundle.d.ts +74 -0
  29. package/dist/discovery/github-skill-bundle.js +323 -0
  30. package/dist/discovery/github-skill-bundle.js.map +1 -0
  31. package/dist/eval/skill-scanner.js +7 -1
  32. package/dist/eval/skill-scanner.js.map +1 -1
  33. package/dist/eval-server/install-skill-routes.js +10 -3
  34. package/dist/eval-server/install-skill-routes.js.map +1 -1
  35. package/dist/eval-server/remove-skill-routes.js +262 -29
  36. package/dist/eval-server/remove-skill-routes.js.map +1 -1
  37. package/dist/eval-server/studio-privacy.js +2 -3
  38. package/dist/eval-server/studio-privacy.js.map +1 -1
  39. package/dist/eval-server/utils/scan-install-locations.js +22 -3
  40. package/dist/eval-server/utils/scan-install-locations.js.map +1 -1
  41. package/dist/eval-ui/assets/{AdvancedTab-CSWhj_MZ.js → AdvancedTab-6VlfqCuD.js} +1 -1
  42. package/dist/eval-ui/assets/{CreateSkillPage-lecFvrLO.js → CreateSkillPage-CXDd6gDN.js} +1 -1
  43. package/dist/eval-ui/assets/{FindSkillsPalette-Dtyd7VrA.js → FindSkillsPalette-DQ6uGhFP.js} +2 -2
  44. package/dist/eval-ui/assets/{GeneralTab-Gbc-K6UI.js → GeneralTab-BpFlJ2ue.js} +1 -1
  45. package/dist/eval-ui/assets/{PrivacyTab-BXCIX8cQ.js → PrivacyTab-UfO7QLy_.js} +1 -1
  46. package/dist/eval-ui/assets/{SearchPaletteCore-CneELTjb.js → SearchPaletteCore-DpHCf0mf.js} +1 -1
  47. package/dist/eval-ui/assets/{SkillDetailPanel-BMfMN6Wy.js → SkillDetailPanel-BjHFjGxg.js} +1 -1
  48. package/dist/eval-ui/assets/{UpdateDropdown-BlAAI6ah.js → UpdateDropdown-Cn4Vsq5d.js} +1 -1
  49. package/dist/eval-ui/assets/{UpdatesTab-BFGUaUtu.js → UpdatesTab-C38-n7V3.js} +1 -1
  50. package/dist/eval-ui/assets/globals-BWCNQsMa.css +1 -0
  51. package/dist/eval-ui/assets/globals-DmzftdEG.js +49 -0
  52. package/dist/eval-ui/assets/{index-DhhmQddr.js → index-DlQYQHDO.js} +1 -1
  53. package/dist/eval-ui/assets/{lifecycle-o_IRibOa.js → lifecycle-n00yue6r.js} +1 -1
  54. package/dist/eval-ui/assets/main-BmB7Zj9I.js +86 -0
  55. package/dist/eval-ui/assets/{preferences-D2BPT0OT.js → preferences-BN-jNWS0.js} +2 -2
  56. package/dist/eval-ui/assets/{useDesktopBridge-BKz-gcRg.js → useDesktopBridge-Cm4zPght.js} +1 -1
  57. package/dist/eval-ui/index.html +4 -4
  58. package/dist/eval-ui/lifecycle.html +3 -3
  59. package/dist/eval-ui/preferences.html +4 -4
  60. package/dist/index.js +34 -0
  61. package/dist/index.js.map +1 -1
  62. package/dist/installer/bundle-files.d.ts +39 -2
  63. package/dist/installer/bundle-files.js +80 -10
  64. package/dist/installer/bundle-files.js.map +1 -1
  65. package/dist/installer/canonical.d.ts +39 -4
  66. package/dist/installer/canonical.js +178 -24
  67. package/dist/installer/canonical.js.map +1 -1
  68. package/dist/installer/multi-install.d.ts +11 -2
  69. package/dist/installer/multi-install.js +41 -9
  70. package/dist/installer/multi-install.js.map +1 -1
  71. package/dist/installer/transformers/index.d.ts +8 -0
  72. package/dist/installer/yaml-safe-mutate.js +48 -2
  73. package/dist/installer/yaml-safe-mutate.js.map +1 -1
  74. package/dist/lib/github-fetch.js +2 -0
  75. package/dist/lib/github-fetch.js.map +1 -1
  76. package/dist/lib/private-source.d.ts +8 -0
  77. package/dist/lib/private-source.js +16 -2
  78. package/dist/lib/private-source.js.map +1 -1
  79. package/dist/webmcp/args.d.ts +34 -0
  80. package/dist/webmcp/args.js +87 -0
  81. package/dist/webmcp/args.js.map +1 -0
  82. package/dist/webmcp/attribution.d.ts +38 -0
  83. package/dist/webmcp/attribution.js +141 -0
  84. package/dist/webmcp/attribution.js.map +1 -0
  85. package/dist/webmcp/enumerate.d.ts +81 -0
  86. package/dist/webmcp/enumerate.js +436 -0
  87. package/dist/webmcp/enumerate.js.map +1 -0
  88. package/dist/webmcp/format.d.ts +9 -0
  89. package/dist/webmcp/format.js +105 -0
  90. package/dist/webmcp/format.js.map +1 -0
  91. package/dist/webmcp/merge.d.ts +49 -0
  92. package/dist/webmcp/merge.js +167 -0
  93. package/dist/webmcp/merge.js.map +1 -0
  94. package/dist/webmcp/score.d.ts +70 -0
  95. package/dist/webmcp/score.js +586 -0
  96. package/dist/webmcp/score.js.map +1 -0
  97. package/dist/webmcp/types.d.ts +92 -0
  98. package/dist/webmcp/types.js +8 -0
  99. package/dist/webmcp/types.js.map +1 -0
  100. package/package.json +6 -6
  101. package/dist/eval-ui/assets/globals-Dpf9KmYH.css +0 -1
  102. package/dist/eval-ui/assets/globals-hm1COkXX.js +0 -49
  103. package/dist/eval-ui/assets/main-DRhfwCeX.js +0 -86
@@ -1,15 +1,17 @@
1
1
  // ---------------------------------------------------------------------------
2
2
  // vskill install -- install a skill from GitHub or local plugin directory
3
3
  // ---------------------------------------------------------------------------
4
- import { mkdirSync, writeFileSync, readFileSync, existsSync, statSync, chmodSync, readdirSync, rmSync, } from "node:fs";
4
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, statSync, chmodSync, readdirSync, rmSync, lstatSync, } from "node:fs";
5
5
  import { join, resolve, basename } from "node:path";
6
6
  import os from "node:os";
7
7
  import { resolveTilde } from "../utils/paths.js";
8
8
  import { reportInstall, reportInstallBatch, submitSkill } from "../api/client.js";
9
9
  import { filterAgents } from "../utils/agent-filter.js";
10
10
  import { detectInstalledAgents, AGENTS_REGISTRY } from "../agents/agents-registry.js";
11
+ import { agentGlobalInstallDirs, resolveAgentGlobalSkillsDir } from "../agents/global-dirs.js";
11
12
  import { ensureLockfile, writeLockfile, readLockfile, removeSkillFromLock } from "../lockfile/index.js";
12
13
  import { buildGitHubInstallLockEntry } from "./add-lockfile.js";
14
+ import { prepareGitHubSkillBundle } from "./add-github-bundle.js";
13
15
  import { runTier1Scan } from "../scanner/index.js";
14
16
  import { getAvailablePlugins, getPluginSource, getPluginVersion, hasPlugin, discoverUnregisteredPlugins } from "../marketplace/index.js";
15
17
  import { checkInstallSafety, checkLocalInstallSafety } from "../blocklist/blocklist.js";
@@ -36,30 +38,9 @@ import { extractFrontmatterVersion } from "../utils/version.js";
36
38
  import { claudePluginInstall, claudePluginUninstall, claudePluginMarketplaceAdd, claudePluginMarketplaceList, } from "../utils/claude-plugin.js";
37
39
  import { buildPerAgentReport, resolvePluginId, } from "../lib/skill-lifecycle.js";
38
40
  // ---------------------------------------------------------------------------
39
- /**
40
- * Subdirectories fetched alongside SKILL.md when installing a skill that lives
41
- * in a repo subdirectory. Must stay a subset of the prefixes the installer
42
- * accepts in installer/bundle-files.ts, or the write is rejected.
43
- */
44
- export const BUNDLE_SUBDIRS = ["agents", "references", "scripts", "assets", "tests"];
45
- /**
46
- * Extensions carried in a skill bundle. Bundle contents are transported as
47
- * UTF-8 strings, so this is a text-only allowlist — binaries would be corrupted.
48
- */
49
- const BUNDLE_TEXT_EXTENSIONS = [
50
- ".md", ".txt", ".json", ".jsonc", ".yaml", ".yml", ".toml", ".csv",
51
- ".py", ".sh", ".bash", ".zsh", ".js", ".mjs", ".cjs", ".ts", ".sql",
52
- ".excalidraw", ".svg", ".css", ".html",
53
- ];
54
- /** Per-file and per-skill ceilings, so one fat repo cannot stall an install. */
55
- const MAX_BUNDLE_FILE_BYTES = 512 * 1024;
56
- const MAX_BUNDLE_FILES = 100;
57
- export function isBundleTextFile(name) {
58
- const lower = name.toLowerCase();
59
- if (lower.startsWith("."))
60
- return false; // .DS_Store, dotfiles
61
- return BUNDLE_TEXT_EXTENSIONS.some((ext) => lower.endsWith(ext));
62
- }
41
+ // 0894: a GitHub skill's complete folder is fetched by
42
+ // prepareGitHubSkillBundle (./add-github-bundle.ts) — every nested file,
43
+ // binaries as bytes, exec bits kept, limits that fail instead of truncating.
63
44
  /** Validate that a download_url from GitHub Contents API points to a trusted GitHub domain. */
64
45
  function isGitHubDownloadUrl(url) {
65
46
  try {
@@ -340,24 +321,45 @@ async function installMarketplaceRepo(owner, repo, manifestContent, opts, preSel
340
321
  const toUninstall = usedInteractiveCheckbox
341
322
  ? [...installedSet].filter((name) => !selectedNames.has(name))
342
323
  : [];
324
+ // 0894: --dry-run stops here, before the deselected-plugin uninstall, the
325
+ // registry submissions and every file or lockfile write below.
326
+ if (opts.dryRun) {
327
+ const dry = await promptInstallOptions(await detectInstalledAgents(), opts);
328
+ if (dry.global)
329
+ opts.global = true;
330
+ printDryRunPlan([...selectedNames], dry.agents, opts, toUninstall);
331
+ for (const p of selectedPlugins) {
332
+ enableAfterInstall(p.name, { marketplace: marketplaceName || undefined }, opts);
333
+ }
334
+ return;
335
+ }
343
336
  if (toUninstall.length > 0) {
344
337
  console.log(dim(`\nUninstalling ${toUninstall.length} deselected plugin${toUninstall.length === 1 ? "" : "s"}...\n`));
345
338
  const agents = await detectInstalledAgents();
346
339
  for (const skillName of toUninstall) {
347
340
  // Remove skill directories from all agents
348
341
  let removedCount = 0;
342
+ const dirs = new Set();
349
343
  for (const agent of agents) {
350
- const localDir = join(process.cwd(), agent.localSkillsDir, skillName);
351
- const globalDir = resolveTilde(join(agent.globalSkillsDir, skillName));
352
- for (const dir of [localDir, globalDir]) {
353
- if (existsSync(dir)) {
354
- try {
355
- rmSync(dir, { recursive: true, force: true });
356
- removedCount++;
357
- }
358
- catch (err) {
359
- console.warn(`[vskill] Could not remove ${dir}: ${err instanceof Error ? err.message : String(err)}`);
360
- }
344
+ dirs.add(join(process.cwd(), agent.localSkillsDir, skillName));
345
+ // 0894: every global dir vskill may have installed into
346
+ // (Antigravity's legacy ~/.gemini/antigravity/skills included).
347
+ for (const globalDir of agentGlobalInstallDirs(agent, resolveTilde)) {
348
+ dirs.add(join(globalDir, skillName));
349
+ }
350
+ }
351
+ // 0894: shared readers are installed through the canonical
352
+ // .agents/skills folder alone, so the canonical copy for the scope this
353
+ // lockfile tracks goes too (symmetric with install).
354
+ dirs.add(join(lockDir, lockDir === join(os.homedir(), ".agents") ? "" : ".agents", "skills", skillName));
355
+ for (const dir of dirs) {
356
+ if (pathPresent(dir)) {
357
+ try {
358
+ rmSync(dir, { recursive: true, force: true });
359
+ removedCount++;
360
+ }
361
+ catch (err) {
362
+ console.warn(`[vskill] Could not remove ${dir}: ${err instanceof Error ? err.message : String(err)}`);
361
363
  }
362
364
  }
363
365
  }
@@ -518,8 +520,7 @@ async function installMarketplaceRepo(owner, repo, manifestContent, opts, preSel
518
520
  // not mutate already-valid frontmatter (no-touch principle).
519
521
  const isRenaming = sd.name !== plugin.name;
520
522
  const processedContent = ensureFrontmatter(content, namespacedName, isRenaming);
521
- for (const agent of agents) {
522
- const baseDir = resolveInstallBase(opts, agent);
523
+ for (const baseDir of uniqueInstallBases(opts, agents)) {
523
524
  const skillDir = join(baseDir, sd.name);
524
525
  mkdirSync(skillDir, { recursive: true });
525
526
  writeFileSync(join(skillDir, "SKILL.md"), processedContent, "utf-8");
@@ -535,8 +536,7 @@ async function installMarketplaceRepo(owner, repo, manifestContent, opts, preSel
535
536
  if (contentRes.ok) {
536
537
  const content = await contentRes.text();
537
538
  const processedContent = ensureFrontmatter(content, plugin.name);
538
- for (const agent of agents) {
539
- const baseDir = resolveInstallBase(opts, agent);
539
+ for (const baseDir of uniqueInstallBases(opts, agents)) {
540
540
  const skillDir = join(baseDir, plugin.name);
541
541
  mkdirSync(skillDir, { recursive: true });
542
542
  writeFileSync(join(skillDir, "SKILL.md"), processedContent, "utf-8");
@@ -613,9 +613,9 @@ async function installMarketplaceRepo(owner, repo, manifestContent, opts, preSel
613
613
  // unenabled. The user saw the skill on disk but Claude Code didn't load
614
614
  // it. Treat --global as opt-in to the enable hook (scope = user, since
615
615
  // --global is per-machine, not per-repo).
616
+ // (--dry-run, also an opt-in, returned earlier with its own preview.)
616
617
  const userOptedIn = opts.scope !== undefined ||
617
618
  opts.global === true ||
618
- opts.dryRun === true ||
619
619
  opts.enable === false;
620
620
  if (userOptedIn) {
621
621
  const enabledSoFar = [];
@@ -861,15 +861,46 @@ function resolveSkillsPath(base, localSkillsDir) {
861
861
  * Resolve the base directory for local skill installation.
862
862
  *
863
863
  * Priority:
864
- * 1. `--global` -> agent's globalSkillsDir
865
- * 2. default -> process.cwd() + agent's localSkillsDir
864
+ * 1. 0894: an agent that reads the shared `.agents/skills` in this scope ->
865
+ * that shared folder (same rule as the canonical installer)
866
+ * 2. `--global` -> agent's global skills dir (Antigravity: legacy-aware)
867
+ * 3. default -> process.cwd() + agent's localSkillsDir
866
868
  */
867
869
  function resolveInstallBase(opts, agent) {
868
- if (opts.global) {
869
- return resolveTilde(agent.globalSkillsDir);
870
+ const global = !!opts.global;
871
+ if (agent.readsSharedSkills?.[global ? "global" : "project"] === true) {
872
+ return global ? resolveTilde("~/.agents/skills") : join(safeProjectRoot(), ".agents", "skills");
873
+ }
874
+ if (global) {
875
+ return resolveAgentGlobalSkillsDir({ id: agent.id ?? "", globalSkillsDir: agent.globalSkillsDir }, resolveTilde);
870
876
  }
871
877
  return resolveSkillsPath(safeProjectRoot(), agent.localSkillsDir);
872
878
  }
879
+ /** 0894: each distinct install base once — shared readers (Cursor, Codex,
880
+ * ...) all resolve to `.agents/skills`, which must get one copy, not one
881
+ * per agent. Keeps the first agent seen for each base, in order. */
882
+ function uniqueInstallBases(opts, agents) {
883
+ return [...new Set(agents.map((a) => resolveInstallBase(opts, a)))];
884
+ }
885
+ /** 0894: true for anything at `p`, including a link whose target is gone. */
886
+ function pathPresent(p) {
887
+ if (existsSync(p))
888
+ return true;
889
+ try {
890
+ return lstatSync(p).isSymbolicLink();
891
+ }
892
+ catch {
893
+ return false;
894
+ }
895
+ }
896
+ /** 0894: the --dry-run report every install path prints instead of writing. */
897
+ function printDryRunPlan(names, agents, opts, uninstall = []) {
898
+ if (uninstall.length > 0) {
899
+ console.log(`\nDry run: would uninstall ${uninstall.map((n) => bold(n)).join(", ")}.`);
900
+ }
901
+ console.log(`\nDry run: would install ${names.map((n) => bold(n)).join(", ")} ` +
902
+ `(${opts.global ? "global" : "project"}) for ${agents.map((a) => a.id).join(", ")}. Nothing was written.`);
903
+ }
873
904
  /**
874
905
  * Prompt the user for agent selection and install method.
875
906
  *
@@ -963,9 +994,8 @@ async function promptInstallOptions(agents, opts) {
963
994
  console.log(dim(` Scope: ${bold(scopeLabel)} | Method: ${bold(methodLabel)}`));
964
995
  console.log(dim(` Agents (${selectedAgents.length}):`));
965
996
  for (const agent of selectedAgents) {
966
- const base = useGlobal
967
- ? resolveTilde(agent.globalSkillsDir)
968
- : resolveSkillsPath(safeProjectRoot(), agent.localSkillsDir);
997
+ // 0894: same resolver the installs use, so the preview cannot drift.
998
+ const base = resolveInstallBase({ ...opts, global: useGlobal }, agent);
969
999
  console.log(dim(` ${agent.displayName}: ${base}/`));
970
1000
  }
971
1001
  console.log(dim(" ─────────────────────────────────────────────────"));
@@ -1220,11 +1250,23 @@ async function installPluginDir(basePath, pluginName, opts, repoUrl) {
1220
1250
  const version = existsSync(mktPath)
1221
1251
  ? getPluginVersion(pluginName, readFileSync(mktPath, "utf-8")) || "0.0.0"
1222
1252
  : "0.0.0";
1253
+ if (opts.dryRun) {
1254
+ printDryRunPlan([pluginName], selectedAgents, opts);
1255
+ return;
1256
+ }
1223
1257
  // Install: recursively copy plugin directory to each agent
1224
1258
  const sha = computeSha(content);
1225
1259
  const locations = [];
1260
+ const doneBases = new Set();
1226
1261
  for (const agent of selectedAgents) {
1227
- const cacheDir = join(resolveInstallBase(opts, agent), pluginName);
1262
+ const base = resolveInstallBase(opts, agent);
1263
+ const cacheDir = join(base, pluginName);
1264
+ // 0894: shared readers share one copy in .agents/skills.
1265
+ if (doneBases.has(base)) {
1266
+ locations.push(`${agent.displayName}: ${cacheDir}`);
1267
+ continue;
1268
+ }
1269
+ doneBases.add(base);
1228
1270
  try {
1229
1271
  // Full clean before copy: removes stale files from older installs
1230
1272
  if (existsSync(cacheDir)) {
@@ -1294,7 +1336,17 @@ async function installOneGitHubSkill(owner, repo, skillName, rawUrl, opts, agent
1294
1336
  * through to the lockfile so the studio detail-header anchor resolves to
1295
1337
  * the actual file (not /blob/HEAD/SKILL.md at the repo root).
1296
1338
  */
1297
- sourceSkillPath) {
1339
+ sourceSkillPath,
1340
+ /**
1341
+ * 0894: the ONE ref (commit SHA when known) that SKILL.md, the bundle and
1342
+ * the lockfile all use, so they can never come from different commits.
1343
+ */
1344
+ ref,
1345
+ /** 0894: lockfile `files` of the previous install of this skill. */
1346
+ previousFiles) {
1347
+ if (ref && sourceSkillPath) {
1348
+ rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${sourceSkillPath}`;
1349
+ }
1298
1350
  // Blocklist + rejection check BEFORE fetching (prevents misleading 404)
1299
1351
  const repoUrl = `https://github.com/${owner}/${repo}`;
1300
1352
  const safety = await checkRepoInstallSafety(skillName, owner, repo);
@@ -1360,9 +1412,29 @@ sourceSkillPath) {
1360
1412
  }
1361
1413
  return { skillName, installed: false, verdict: scanResult.verdict, score: scanResult.score };
1362
1414
  }
1363
- // Fetch agent files (agents/*.md) if discovered
1415
+ // 0894: the complete skill folder (nested dirs, binaries, exec bits).
1416
+ let bundleFiles;
1417
+ let bundleExecutables;
1418
+ let installedFiles;
1419
+ if (sourceSkillPath) {
1420
+ const prepared = await prepareGitHubSkillBundle({
1421
+ owner,
1422
+ repo,
1423
+ ref: ref || (await getDefaultBranch(owner, repo)),
1424
+ skillMdPath: sourceSkillPath,
1425
+ force: !!opts.force,
1426
+ });
1427
+ if (!prepared.ok) {
1428
+ console.log(red(` ${prepared.message}`));
1429
+ return { skillName, installed: false, verdict: prepared.verdict, score: scanResult.score };
1430
+ }
1431
+ bundleFiles = Object.keys(prepared.bundle.files).length > 0 ? prepared.bundle.files : undefined;
1432
+ bundleExecutables = prepared.bundle.executables;
1433
+ installedFiles = prepared.bundle.fileList;
1434
+ }
1435
+ // Fetch agent files (agents/*.md) if discovered and the path is unknown
1364
1436
  let agentFiles;
1365
- if (agentRawUrls && Object.keys(agentRawUrls).length > 0) {
1437
+ if (!sourceSkillPath && agentRawUrls && Object.keys(agentRawUrls).length > 0) {
1366
1438
  agentFiles = {};
1367
1439
  const fetches = Object.entries(agentRawUrls).map(async ([relPath, url]) => {
1368
1440
  try {
@@ -1389,7 +1461,9 @@ sourceSkillPath) {
1389
1461
  scope: opts.global ? "user" : "project",
1390
1462
  projectRoot,
1391
1463
  copy: !!opts.copy,
1392
- bundleFiles: agentFiles,
1464
+ bundleFiles: bundleFiles ?? agentFiles,
1465
+ bundleExecutables,
1466
+ previousFiles,
1393
1467
  });
1394
1468
  }
1395
1469
  catch (err) {
@@ -1397,7 +1471,7 @@ sourceSkillPath) {
1397
1471
  return { skillName, installed: false, verdict: "WRITE_ERROR", score: scanResult.score };
1398
1472
  }
1399
1473
  const skillVersion = extractFrontmatterVersion(content) || "1.0.0";
1400
- return { skillName, installed: true, verdict: scanResult.verdict, score: scanResult.score, sha, version: skillVersion, sourceSkillPath };
1474
+ return { skillName, installed: true, verdict: scanResult.verdict, score: scanResult.score, sha, version: skillVersion, sourceSkillPath, files: installedFiles };
1401
1475
  }
1402
1476
  // ---------------------------------------------------------------------------
1403
1477
  // Bulk repo plugin installation: --repo with --all
@@ -1649,9 +1723,29 @@ async function installRepoPlugin(ownerRepo, pluginName, opts, overrideSource) {
1649
1723
  const sha = computeSha(combined);
1650
1724
  const skillFileNames = skills.map((s) => s.name === pluginName ? "SKILL.md" : `${s.name}/SKILL.md`).sort();
1651
1725
  const locations = [];
1726
+ if (opts.dryRun) {
1727
+ printDryRunPlan([pluginName], selectedAgents, opts);
1728
+ const enableResult = enableAfterInstall(pluginName, {}, opts);
1729
+ const report = buildPerAgentReport({
1730
+ skill: pluginName,
1731
+ scope: enableResult.scope,
1732
+ action: "not-applicable",
1733
+ agents: selectedAgents,
1734
+ });
1735
+ for (const line of report)
1736
+ console.log(` ${dim(">")} ${line.line}`);
1737
+ return;
1738
+ }
1739
+ const doneBases = new Set();
1652
1740
  for (const agent of selectedAgents) {
1653
1741
  const baseDir = resolveInstallBase(opts, agent);
1654
1742
  const plugDir = basename(baseDir) === pluginName ? baseDir : join(baseDir, pluginName);
1743
+ // 0894: shared readers share one copy in .agents/skills.
1744
+ if (doneBases.has(baseDir)) {
1745
+ locations.push(`${agent.displayName}: ${plugDir}`);
1746
+ continue;
1747
+ }
1748
+ doneBases.add(baseDir);
1655
1749
  try {
1656
1750
  // Skills: {agent-dir}/{plugin-name}/{skill-name}/SKILL.md
1657
1751
  for (const skill of skills) {
@@ -1702,9 +1796,9 @@ async function installRepoPlugin(ownerRepo, pluginName, opts, overrideSource) {
1702
1796
  // hence no rollback wiring here) — the gate only surfaces the same
1703
1797
  // acknowledgement + per-agent report as the marketplace path. The identical
1704
1798
  // opt-in condition keeps the default flow unchanged (NFR-005).
1799
+ // (--dry-run, also an opt-in, returned earlier with its own preview.)
1705
1800
  const userOptedIn = opts.scope !== undefined ||
1706
1801
  opts.global === true ||
1707
- opts.dryRun === true ||
1708
1802
  opts.enable === false;
1709
1803
  if (userOptedIn) {
1710
1804
  const enableResult = enableAfterInstall(pluginName, lock.skills[pluginName], opts);
@@ -1989,12 +2083,20 @@ async function addCommandInner(source, opts) {
1989
2083
  return process.exit(0);
1990
2084
  }
1991
2085
  }
2086
+ if (opts.dryRun) {
2087
+ printDryRunPlan(selectedSkills.map((s) => s.name), selectedAgents, opts);
2088
+ return;
2089
+ }
2090
+ // 0894: resolve the commit ONCE; SKILL.md, bundle and lockfile all use it.
2091
+ const sourceBranch = await getDefaultBranch(owner, repo);
2092
+ const sourceCommitSha = await getBranchHeadSha(owner, repo, sourceBranch);
2093
+ const priorSkills = readLockfile(lockfileRoot(opts))?.skills ?? {};
1992
2094
  // Install selected skills
1993
2095
  const results = [];
1994
2096
  for (const skill of selectedSkills) {
1995
2097
  console.log(dim(`\nInstalling skill: ${bold(skill.name)}...`));
1996
2098
  try {
1997
- const result = await installOneGitHubSkill(owner, repo, skill.name, skill.rawUrl, opts, selectedAgents, projectRoot, skill.agentRawUrls, skill.path);
2099
+ const result = await installOneGitHubSkill(owner, repo, skill.name, skill.rawUrl, opts, selectedAgents, projectRoot, skill.agentRawUrls, skill.path, sourceCommitSha || sourceBranch, priorSkills[skill.name]?.files);
1998
2100
  results.push(result);
1999
2101
  }
2000
2102
  catch (err) {
@@ -2007,8 +2109,6 @@ async function addCommandInner(source, opts) {
2007
2109
  // `sourceSkillPath` are persisted alongside the legacy `source` string.
2008
2110
  const lockDir = lockfileRoot(opts);
2009
2111
  const lock = ensureLockfile(lockDir);
2010
- const sourceBranch = await getDefaultBranch(owner, repo);
2011
- const sourceCommitSha = await getBranchHeadSha(owner, repo, sourceBranch);
2012
2112
  for (const r of results) {
2013
2113
  if (r.installed && r.sha) {
2014
2114
  lock.skills[r.skillName] = buildGitHubInstallLockEntry({
@@ -2021,6 +2121,7 @@ async function addCommandInner(source, opts) {
2021
2121
  commitSha: sourceCommitSha,
2022
2122
  pluginName: pluginNameFromSkillPath(r.sourceSkillPath),
2023
2123
  global: !!opts.global,
2124
+ files: r.files,
2024
2125
  });
2025
2126
  }
2026
2127
  }
@@ -2384,10 +2485,21 @@ async function installFromRegistry(skillName, opts) {
2384
2485
  // Install to each agent
2385
2486
  const sha = computeSha(content);
2386
2487
  const locations = [];
2488
+ if (opts.dryRun) {
2489
+ printDryRunPlan([skillName], selectedAgents, opts);
2490
+ return;
2491
+ }
2387
2492
  const processedContent = ensureFrontmatter(content, skillName);
2493
+ const doneBases = new Set();
2388
2494
  for (const agent of selectedAgents) {
2389
2495
  const baseDir = resolveInstallBase(opts, agent);
2390
2496
  const skillDir = join(baseDir, skillName);
2497
+ // 0894: shared readers share one copy in .agents/skills.
2498
+ if (doneBases.has(baseDir)) {
2499
+ locations.push(`${agent.displayName}: ${skillDir}`);
2500
+ continue;
2501
+ }
2502
+ doneBases.add(baseDir);
2391
2503
  try {
2392
2504
  mkdirSync(skillDir, { recursive: true });
2393
2505
  writeFileSync(join(skillDir, "SKILL.md"), processedContent, "utf-8");
@@ -2465,6 +2577,8 @@ async function installSingleSkillLegacy(owner, repo, skill, opts, skillSubpathOv
2465
2577
  }
2466
2578
  const branch = refOverride || (await getDefaultBranch(owner, repo));
2467
2579
  const sourceCommitSha = await getBranchHeadSha(owner, repo, branch);
2580
+ // 0894: SKILL.md, the bundle and the lockfile all read this one commit.
2581
+ const contentRef = sourceCommitSha || branch;
2468
2582
  // Candidate subpaths: explicit override wins; otherwise try the conventional
2469
2583
  // skills/{name}/ layout first, then root-level {name}/ (e.g. higgsfield-ai/skills).
2470
2584
  const subpathCandidates = skillSubpathOverride
@@ -2477,7 +2591,7 @@ async function installSingleSkillLegacy(owner, repo, skill, opts, skillSubpathOv
2477
2591
  let content;
2478
2592
  for (let i = 0; i < subpathCandidates.length; i++) {
2479
2593
  const candidate = subpathCandidates[i];
2480
- const candidateUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${candidate}`;
2594
+ const candidateUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${contentRef}/${candidate}`;
2481
2595
  const isLast = i === subpathCandidates.length - 1;
2482
2596
  if (isLast) {
2483
2597
  content = await fetchSkillContent(candidateUrl);
@@ -2493,53 +2607,6 @@ async function installSingleSkillLegacy(owner, repo, skill, opts, skillSubpathOv
2493
2607
  }
2494
2608
  if (content === undefined)
2495
2609
  return process.exit(1);
2496
- // Fetch the skill's bundled subdirectories when the skill lives in a
2497
- // subdirectory of the repo. These are the skill-authoring convention and the
2498
- // same prefixes the installer accepts (see installer/bundle-files.ts):
2499
- // agents/ holds sub-agent prompts, references/ the docs SKILL.md links to,
2500
- // scripts/ the executable tooling, assets/ static files, tests/ its tests.
2501
- // A skill whose SKILL.md says "run scripts/foo.py" and installs without
2502
- // scripts/ is not degraded — it is broken.
2503
- let legacyAgentFiles;
2504
- if (skill) {
2505
- let fetched = 0;
2506
- for (const subdir of BUNDLE_SUBDIRS) {
2507
- try {
2508
- const basePath = skillSubpath.replace(/\/SKILL\.md$/, `/${subdir}`);
2509
- // ?ref= keeps the listing on the same branch as SKILL.md
2510
- // (deep links may pin a non-default branch).
2511
- const dirUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${basePath}?ref=${encodeURIComponent(branch)}`;
2512
- const dirRes = await githubFetch(dirUrl, { headers: { Accept: "application/vnd.github.v3+json" } });
2513
- if (!dirRes.ok)
2514
- continue;
2515
- const entries = (await dirRes.json());
2516
- const wanted = entries.filter((e) => e.type !== "dir" &&
2517
- isBundleTextFile(e.name) &&
2518
- (e.size ?? 0) <= MAX_BUNDLE_FILE_BYTES &&
2519
- e.download_url &&
2520
- isGitHubDownloadUrl(e.download_url));
2521
- if (wanted.length === 0)
2522
- continue;
2523
- legacyAgentFiles ??= {};
2524
- const budget = wanted.slice(0, Math.max(0, MAX_BUNDLE_FILES - fetched));
2525
- fetched += budget.length;
2526
- const fetches = budget.map(async (entry) => {
2527
- try {
2528
- const res = await githubFetch(entry.download_url);
2529
- if (res.ok)
2530
- legacyAgentFiles[`${subdir}/${entry.name}`] = await res.text();
2531
- }
2532
- catch { /* skip */ }
2533
- });
2534
- await Promise.allSettled(fetches);
2535
- if (fetched >= MAX_BUNDLE_FILES)
2536
- break;
2537
- }
2538
- catch { /* subdir doesn't exist or API error — fine */ }
2539
- }
2540
- if (legacyAgentFiles && Object.keys(legacyAgentFiles).length === 0)
2541
- legacyAgentFiles = undefined;
2542
- }
2543
2610
  // Platform security check (best-effort, non-blocking on network error).
2544
2611
  // Skipped for private repos: the registry looks skills up by name, so it
2545
2612
  // would leak the name and could match an unrelated public skill.
@@ -2624,6 +2691,26 @@ async function installSingleSkillLegacy(owner, repo, skill, opts, skillSubpathOv
2624
2691
  opts.global = true;
2625
2692
  if (!selections.symlink)
2626
2693
  opts.copy = true;
2694
+ if (opts.dryRun) {
2695
+ printDryRunPlan([skillName], selectedAgents, opts);
2696
+ return;
2697
+ }
2698
+ // 0894: the complete skill folder — every nested file, binaries as bytes,
2699
+ // exec bits kept — read from the same commit, scanned, never truncated; not
2700
+ // downloaded for --dry-run.
2701
+ const prepared = await prepareGitHubSkillBundle({
2702
+ owner,
2703
+ repo,
2704
+ ref: contentRef,
2705
+ skillMdPath: skillSubpath,
2706
+ force: !!opts.force,
2707
+ });
2708
+ if (!prepared.ok) {
2709
+ console.error(red(`\n${prepared.message}`));
2710
+ return process.exit(1);
2711
+ }
2712
+ const bundle = prepared.bundle;
2713
+ const previousFiles = readLockfile(lockfileRoot(opts))?.skills[skillName]?.files;
2627
2714
  // Install to each agent using canonical installer (flat directory, namespaced frontmatter name)
2628
2715
  const namespacedName = pluginNamespace && skillName !== pluginNamespace
2629
2716
  ? `${pluginNamespace}/${skillName}`
@@ -2648,7 +2735,9 @@ async function installSingleSkillLegacy(owner, repo, skill, opts, skillSubpathOv
2648
2735
  scope: opts.global ? "user" : "project",
2649
2736
  projectRoot,
2650
2737
  copy: !!opts.copy,
2651
- bundleFiles: legacyAgentFiles,
2738
+ bundleFiles: Object.keys(bundle.files).length > 0 ? bundle.files : undefined,
2739
+ bundleExecutables: bundle.executables,
2740
+ previousFiles,
2652
2741
  });
2653
2742
  // Update lockfile (global → ~/.agents/, project → project root).
2654
2743
  // 0743: persist `sourceSkillPath` from the locally-derived `skillSubpath`
@@ -2667,6 +2756,7 @@ async function installSingleSkillLegacy(owner, repo, skill, opts, skillSubpathOv
2667
2756
  commitSha: sourceCommitSha,
2668
2757
  pluginName: pluginNamespace ?? pluginNameFromSkillPath(skillSubpath),
2669
2758
  global: !!opts.global,
2759
+ files: bundle.fileList,
2670
2760
  });
2671
2761
  lock.agents = [...new Set([...(lock.agents || []), ...selectedAgents.map((a) => a.id)])];
2672
2762
  writeLockfile(lock, lockDir);