vskill 1.2.4 → 1.2.7

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 (114) hide show
  1. package/README.md +51 -3
  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/audit/fix-suggestions.js +6 -0
  10. package/dist/audit/fix-suggestions.js.map +1 -1
  11. package/dist/commands/add-github-bundle.d.ts +29 -0
  12. package/dist/commands/add-github-bundle.js +135 -0
  13. package/dist/commands/add-github-bundle.js.map +1 -0
  14. package/dist/commands/add-lockfile.d.ts +2 -0
  15. package/dist/commands/add-lockfile.js +1 -1
  16. package/dist/commands/add-lockfile.js.map +1 -1
  17. package/dist/commands/add.d.ts +5 -7
  18. package/dist/commands/add.js +207 -111
  19. package/dist/commands/add.js.map +1 -1
  20. package/dist/commands/audit-webmcp.d.ts +25 -0
  21. package/dist/commands/audit-webmcp.js +167 -0
  22. package/dist/commands/audit-webmcp.js.map +1 -0
  23. package/dist/commands/doctor.d.ts +69 -0
  24. package/dist/commands/doctor.js +760 -0
  25. package/dist/commands/doctor.js.map +1 -0
  26. package/dist/commands/remove.js +43 -8
  27. package/dist/commands/remove.js.map +1 -1
  28. package/dist/commands/scan.js +1 -1
  29. package/dist/commands/scan.js.map +1 -1
  30. package/dist/commands/update.js +12 -4
  31. package/dist/commands/update.js.map +1 -1
  32. package/dist/discovery/github-skill-bundle.d.ts +74 -0
  33. package/dist/discovery/github-skill-bundle.js +323 -0
  34. package/dist/discovery/github-skill-bundle.js.map +1 -0
  35. package/dist/eval/skill-scanner.js +7 -1
  36. package/dist/eval/skill-scanner.js.map +1 -1
  37. package/dist/eval-server/install-skill-routes.js +10 -3
  38. package/dist/eval-server/install-skill-routes.js.map +1 -1
  39. package/dist/eval-server/remove-skill-routes.js +262 -29
  40. package/dist/eval-server/remove-skill-routes.js.map +1 -1
  41. package/dist/eval-server/studio-privacy.js +2 -3
  42. package/dist/eval-server/studio-privacy.js.map +1 -1
  43. package/dist/eval-server/utils/scan-install-locations.js +22 -3
  44. package/dist/eval-server/utils/scan-install-locations.js.map +1 -1
  45. package/dist/eval-ui/assets/{CreateSkillPage-BUrgworx.js → CreateSkillPage-CXDd6gDN.js} +1 -1
  46. package/dist/eval-ui/assets/{FindSkillsPalette-BstJ22TI.js → FindSkillsPalette-DQ6uGhFP.js} +2 -2
  47. package/dist/eval-ui/assets/{SearchPaletteCore-Dagnc-E7.js → SearchPaletteCore-DpHCf0mf.js} +1 -1
  48. package/dist/eval-ui/assets/{SkillDetailPanel-DnujVk6g.js → SkillDetailPanel-BjHFjGxg.js} +1 -1
  49. package/dist/eval-ui/assets/{UpdateDropdown-B7D2vXrm.js → UpdateDropdown-Cn4Vsq5d.js} +1 -1
  50. package/dist/eval-ui/assets/main-BmB7Zj9I.js +86 -0
  51. package/dist/eval-ui/index.html +1 -1
  52. package/dist/index.js +34 -0
  53. package/dist/index.js.map +1 -1
  54. package/dist/installer/bundle-files.d.ts +39 -2
  55. package/dist/installer/bundle-files.js +80 -10
  56. package/dist/installer/bundle-files.js.map +1 -1
  57. package/dist/installer/canonical.d.ts +39 -4
  58. package/dist/installer/canonical.js +178 -24
  59. package/dist/installer/canonical.js.map +1 -1
  60. package/dist/installer/multi-install.d.ts +11 -2
  61. package/dist/installer/multi-install.js +41 -9
  62. package/dist/installer/multi-install.js.map +1 -1
  63. package/dist/installer/transformers/index.d.ts +8 -0
  64. package/dist/installer/yaml-safe-mutate.js +48 -2
  65. package/dist/installer/yaml-safe-mutate.js.map +1 -1
  66. package/dist/lib/github-fetch.js +2 -0
  67. package/dist/lib/github-fetch.js.map +1 -1
  68. package/dist/lib/private-source.d.ts +8 -0
  69. package/dist/lib/private-source.js +16 -2
  70. package/dist/lib/private-source.js.map +1 -1
  71. package/dist/scanner/hidden-content.d.ts +47 -0
  72. package/dist/scanner/hidden-content.js +904 -0
  73. package/dist/scanner/hidden-content.js.map +1 -0
  74. package/dist/scanner/installer-hosts.d.ts +26 -0
  75. package/dist/scanner/installer-hosts.js +92 -0
  76. package/dist/scanner/installer-hosts.js.map +1 -0
  77. package/dist/scanner/judge-guard.d.ts +119 -0
  78. package/dist/scanner/judge-guard.js +481 -0
  79. package/dist/scanner/judge-guard.js.map +1 -0
  80. package/dist/scanner/patterns.d.ts +12 -0
  81. package/dist/scanner/patterns.js +353 -113
  82. package/dist/scanner/patterns.js.map +1 -1
  83. package/dist/scanner/repo-scanner.d.ts +4 -0
  84. package/dist/scanner/repo-scanner.js +4 -1
  85. package/dist/scanner/repo-scanner.js.map +1 -1
  86. package/dist/scanner/tier1.js +37 -7
  87. package/dist/scanner/tier1.js.map +1 -1
  88. package/dist/scanner/tier2-llm.d.ts +22 -4
  89. package/dist/scanner/tier2-llm.js +163 -23
  90. package/dist/scanner/tier2-llm.js.map +1 -1
  91. package/dist/scanner/types.d.ts +8 -0
  92. package/dist/webmcp/args.d.ts +34 -0
  93. package/dist/webmcp/args.js +87 -0
  94. package/dist/webmcp/args.js.map +1 -0
  95. package/dist/webmcp/attribution.d.ts +38 -0
  96. package/dist/webmcp/attribution.js +141 -0
  97. package/dist/webmcp/attribution.js.map +1 -0
  98. package/dist/webmcp/enumerate.d.ts +81 -0
  99. package/dist/webmcp/enumerate.js +436 -0
  100. package/dist/webmcp/enumerate.js.map +1 -0
  101. package/dist/webmcp/format.d.ts +9 -0
  102. package/dist/webmcp/format.js +105 -0
  103. package/dist/webmcp/format.js.map +1 -0
  104. package/dist/webmcp/merge.d.ts +49 -0
  105. package/dist/webmcp/merge.js +167 -0
  106. package/dist/webmcp/merge.js.map +1 -0
  107. package/dist/webmcp/score.d.ts +70 -0
  108. package/dist/webmcp/score.js +586 -0
  109. package/dist/webmcp/score.js.map +1 -0
  110. package/dist/webmcp/types.d.ts +92 -0
  111. package/dist/webmcp/types.js +8 -0
  112. package/dist/webmcp/types.js.map +1 -0
  113. package/package.json +1 -1
  114. package/dist/eval-ui/assets/main-DjA5gpjV.js +0 -86
@@ -1,16 +1,19 @@
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";
16
+ import { isBinary } from "../scanner/repo-scanner.js";
14
17
  import { getAvailablePlugins, getPluginSource, getPluginVersion, hasPlugin, discoverUnregisteredPlugins } from "../marketplace/index.js";
15
18
  import { checkInstallSafety, checkLocalInstallSafety } from "../blocklist/blocklist.js";
16
19
  import { getSkill, searchSkills } from "../api/client.js";
@@ -36,30 +39,9 @@ import { extractFrontmatterVersion } from "../utils/version.js";
36
39
  import { claudePluginInstall, claudePluginUninstall, claudePluginMarketplaceAdd, claudePluginMarketplaceList, } from "../utils/claude-plugin.js";
37
40
  import { buildPerAgentReport, resolvePluginId, } from "../lib/skill-lifecycle.js";
38
41
  // ---------------------------------------------------------------------------
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
- }
42
+ // 0894: a GitHub skill's complete folder is fetched by
43
+ // prepareGitHubSkillBundle (./add-github-bundle.ts) — every nested file,
44
+ // binaries as bytes, exec bits kept, limits that fail instead of truncating.
63
45
  /** Validate that a download_url from GitHub Contents API points to a trusted GitHub domain. */
64
46
  function isGitHubDownloadUrl(url) {
65
47
  try {
@@ -340,24 +322,45 @@ async function installMarketplaceRepo(owner, repo, manifestContent, opts, preSel
340
322
  const toUninstall = usedInteractiveCheckbox
341
323
  ? [...installedSet].filter((name) => !selectedNames.has(name))
342
324
  : [];
325
+ // 0894: --dry-run stops here, before the deselected-plugin uninstall, the
326
+ // registry submissions and every file or lockfile write below.
327
+ if (opts.dryRun) {
328
+ const dry = await promptInstallOptions(await detectInstalledAgents(), opts);
329
+ if (dry.global)
330
+ opts.global = true;
331
+ printDryRunPlan([...selectedNames], dry.agents, opts, toUninstall);
332
+ for (const p of selectedPlugins) {
333
+ enableAfterInstall(p.name, { marketplace: marketplaceName || undefined }, opts);
334
+ }
335
+ return;
336
+ }
343
337
  if (toUninstall.length > 0) {
344
338
  console.log(dim(`\nUninstalling ${toUninstall.length} deselected plugin${toUninstall.length === 1 ? "" : "s"}...\n`));
345
339
  const agents = await detectInstalledAgents();
346
340
  for (const skillName of toUninstall) {
347
341
  // Remove skill directories from all agents
348
342
  let removedCount = 0;
343
+ const dirs = new Set();
349
344
  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
- }
345
+ dirs.add(join(process.cwd(), agent.localSkillsDir, skillName));
346
+ // 0894: every global dir vskill may have installed into
347
+ // (Antigravity's legacy ~/.gemini/antigravity/skills included).
348
+ for (const globalDir of agentGlobalInstallDirs(agent, resolveTilde)) {
349
+ dirs.add(join(globalDir, skillName));
350
+ }
351
+ }
352
+ // 0894: shared readers are installed through the canonical
353
+ // .agents/skills folder alone, so the canonical copy for the scope this
354
+ // lockfile tracks goes too (symmetric with install).
355
+ dirs.add(join(lockDir, lockDir === join(os.homedir(), ".agents") ? "" : ".agents", "skills", skillName));
356
+ for (const dir of dirs) {
357
+ if (pathPresent(dir)) {
358
+ try {
359
+ rmSync(dir, { recursive: true, force: true });
360
+ removedCount++;
361
+ }
362
+ catch (err) {
363
+ console.warn(`[vskill] Could not remove ${dir}: ${err instanceof Error ? err.message : String(err)}`);
361
364
  }
362
365
  }
363
366
  }
@@ -518,8 +521,7 @@ async function installMarketplaceRepo(owner, repo, manifestContent, opts, preSel
518
521
  // not mutate already-valid frontmatter (no-touch principle).
519
522
  const isRenaming = sd.name !== plugin.name;
520
523
  const processedContent = ensureFrontmatter(content, namespacedName, isRenaming);
521
- for (const agent of agents) {
522
- const baseDir = resolveInstallBase(opts, agent);
524
+ for (const baseDir of uniqueInstallBases(opts, agents)) {
523
525
  const skillDir = join(baseDir, sd.name);
524
526
  mkdirSync(skillDir, { recursive: true });
525
527
  writeFileSync(join(skillDir, "SKILL.md"), processedContent, "utf-8");
@@ -535,8 +537,7 @@ async function installMarketplaceRepo(owner, repo, manifestContent, opts, preSel
535
537
  if (contentRes.ok) {
536
538
  const content = await contentRes.text();
537
539
  const processedContent = ensureFrontmatter(content, plugin.name);
538
- for (const agent of agents) {
539
- const baseDir = resolveInstallBase(opts, agent);
540
+ for (const baseDir of uniqueInstallBases(opts, agents)) {
540
541
  const skillDir = join(baseDir, plugin.name);
541
542
  mkdirSync(skillDir, { recursive: true });
542
543
  writeFileSync(join(skillDir, "SKILL.md"), processedContent, "utf-8");
@@ -613,9 +614,9 @@ async function installMarketplaceRepo(owner, repo, manifestContent, opts, preSel
613
614
  // unenabled. The user saw the skill on disk but Claude Code didn't load
614
615
  // it. Treat --global as opt-in to the enable hook (scope = user, since
615
616
  // --global is per-machine, not per-repo).
617
+ // (--dry-run, also an opt-in, returned earlier with its own preview.)
616
618
  const userOptedIn = opts.scope !== undefined ||
617
619
  opts.global === true ||
618
- opts.dryRun === true ||
619
620
  opts.enable === false;
620
621
  if (userOptedIn) {
621
622
  const enabledSoFar = [];
@@ -861,15 +862,46 @@ function resolveSkillsPath(base, localSkillsDir) {
861
862
  * Resolve the base directory for local skill installation.
862
863
  *
863
864
  * Priority:
864
- * 1. `--global` -> agent's globalSkillsDir
865
- * 2. default -> process.cwd() + agent's localSkillsDir
865
+ * 1. 0894: an agent that reads the shared `.agents/skills` in this scope ->
866
+ * that shared folder (same rule as the canonical installer)
867
+ * 2. `--global` -> agent's global skills dir (Antigravity: legacy-aware)
868
+ * 3. default -> process.cwd() + agent's localSkillsDir
866
869
  */
867
870
  function resolveInstallBase(opts, agent) {
868
- if (opts.global) {
869
- return resolveTilde(agent.globalSkillsDir);
871
+ const global = !!opts.global;
872
+ if (agent.readsSharedSkills?.[global ? "global" : "project"] === true) {
873
+ return global ? resolveTilde("~/.agents/skills") : join(safeProjectRoot(), ".agents", "skills");
874
+ }
875
+ if (global) {
876
+ return resolveAgentGlobalSkillsDir({ id: agent.id ?? "", globalSkillsDir: agent.globalSkillsDir }, resolveTilde);
870
877
  }
871
878
  return resolveSkillsPath(safeProjectRoot(), agent.localSkillsDir);
872
879
  }
880
+ /** 0894: each distinct install base once — shared readers (Cursor, Codex,
881
+ * ...) all resolve to `.agents/skills`, which must get one copy, not one
882
+ * per agent. Keeps the first agent seen for each base, in order. */
883
+ function uniqueInstallBases(opts, agents) {
884
+ return [...new Set(agents.map((a) => resolveInstallBase(opts, a)))];
885
+ }
886
+ /** 0894: true for anything at `p`, including a link whose target is gone. */
887
+ function pathPresent(p) {
888
+ if (existsSync(p))
889
+ return true;
890
+ try {
891
+ return lstatSync(p).isSymbolicLink();
892
+ }
893
+ catch {
894
+ return false;
895
+ }
896
+ }
897
+ /** 0894: the --dry-run report every install path prints instead of writing. */
898
+ function printDryRunPlan(names, agents, opts, uninstall = []) {
899
+ if (uninstall.length > 0) {
900
+ console.log(`\nDry run: would uninstall ${uninstall.map((n) => bold(n)).join(", ")}.`);
901
+ }
902
+ console.log(`\nDry run: would install ${names.map((n) => bold(n)).join(", ")} ` +
903
+ `(${opts.global ? "global" : "project"}) for ${agents.map((a) => a.id).join(", ")}. Nothing was written.`);
904
+ }
873
905
  /**
874
906
  * Prompt the user for agent selection and install method.
875
907
  *
@@ -963,9 +995,8 @@ async function promptInstallOptions(agents, opts) {
963
995
  console.log(dim(` Scope: ${bold(scopeLabel)} | Method: ${bold(methodLabel)}`));
964
996
  console.log(dim(` Agents (${selectedAgents.length}):`));
965
997
  for (const agent of selectedAgents) {
966
- const base = useGlobal
967
- ? resolveTilde(agent.globalSkillsDir)
968
- : resolveSkillsPath(safeProjectRoot(), agent.localSkillsDir);
998
+ // 0894: same resolver the installs use, so the preview cannot drift.
999
+ const base = resolveInstallBase({ ...opts, global: useGlobal }, agent);
969
1000
  console.log(dim(` ${agent.displayName}: ${base}/`));
970
1001
  }
971
1002
  console.log(dim(" ─────────────────────────────────────────────────"));
@@ -1087,17 +1118,22 @@ function listPluginDirPlugins(basePath) {
1087
1118
  * Collect all file contents from a plugin directory for security scanning.
1088
1119
  * Concatenates content from all readable files.
1089
1120
  */
1090
- function collectPluginContent(pluginDir) {
1121
+ export function collectPluginContent(pluginDir) {
1091
1122
  const files = readdirSync(pluginDir, { recursive: true });
1092
1123
  const parts = [];
1093
1124
  for (const file of files) {
1094
1125
  const fullPath = join(pluginDir, file);
1095
1126
  try {
1096
- const content = readFileSync(fullPath, "utf-8");
1097
- parts.push(`--- ${file} ---\n${content}`);
1127
+ const buffer = readFileSync(fullPath);
1128
+ // Images and other binaries decode to junk that looks like hidden
1129
+ // Unicode (a GIF header can spell tag characters); they are not text
1130
+ // an agent reads, so they are not scanned as text.
1131
+ if (isBinary(buffer))
1132
+ continue;
1133
+ parts.push(`--- ${file} ---\n${buffer.toString("utf-8")}`);
1098
1134
  }
1099
1135
  catch {
1100
- // Skip unreadable files (directories, binary, etc.)
1136
+ // Skip unreadable files (directories, etc.)
1101
1137
  }
1102
1138
  }
1103
1139
  return parts.join("\n");
@@ -1220,11 +1256,23 @@ async function installPluginDir(basePath, pluginName, opts, repoUrl) {
1220
1256
  const version = existsSync(mktPath)
1221
1257
  ? getPluginVersion(pluginName, readFileSync(mktPath, "utf-8")) || "0.0.0"
1222
1258
  : "0.0.0";
1259
+ if (opts.dryRun) {
1260
+ printDryRunPlan([pluginName], selectedAgents, opts);
1261
+ return;
1262
+ }
1223
1263
  // Install: recursively copy plugin directory to each agent
1224
1264
  const sha = computeSha(content);
1225
1265
  const locations = [];
1266
+ const doneBases = new Set();
1226
1267
  for (const agent of selectedAgents) {
1227
- const cacheDir = join(resolveInstallBase(opts, agent), pluginName);
1268
+ const base = resolveInstallBase(opts, agent);
1269
+ const cacheDir = join(base, pluginName);
1270
+ // 0894: shared readers share one copy in .agents/skills.
1271
+ if (doneBases.has(base)) {
1272
+ locations.push(`${agent.displayName}: ${cacheDir}`);
1273
+ continue;
1274
+ }
1275
+ doneBases.add(base);
1228
1276
  try {
1229
1277
  // Full clean before copy: removes stale files from older installs
1230
1278
  if (existsSync(cacheDir)) {
@@ -1294,7 +1342,17 @@ async function installOneGitHubSkill(owner, repo, skillName, rawUrl, opts, agent
1294
1342
  * through to the lockfile so the studio detail-header anchor resolves to
1295
1343
  * the actual file (not /blob/HEAD/SKILL.md at the repo root).
1296
1344
  */
1297
- sourceSkillPath) {
1345
+ sourceSkillPath,
1346
+ /**
1347
+ * 0894: the ONE ref (commit SHA when known) that SKILL.md, the bundle and
1348
+ * the lockfile all use, so they can never come from different commits.
1349
+ */
1350
+ ref,
1351
+ /** 0894: lockfile `files` of the previous install of this skill. */
1352
+ previousFiles) {
1353
+ if (ref && sourceSkillPath) {
1354
+ rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${sourceSkillPath}`;
1355
+ }
1298
1356
  // Blocklist + rejection check BEFORE fetching (prevents misleading 404)
1299
1357
  const repoUrl = `https://github.com/${owner}/${repo}`;
1300
1358
  const safety = await checkRepoInstallSafety(skillName, owner, repo);
@@ -1360,9 +1418,29 @@ sourceSkillPath) {
1360
1418
  }
1361
1419
  return { skillName, installed: false, verdict: scanResult.verdict, score: scanResult.score };
1362
1420
  }
1363
- // Fetch agent files (agents/*.md) if discovered
1421
+ // 0894: the complete skill folder (nested dirs, binaries, exec bits).
1422
+ let bundleFiles;
1423
+ let bundleExecutables;
1424
+ let installedFiles;
1425
+ if (sourceSkillPath) {
1426
+ const prepared = await prepareGitHubSkillBundle({
1427
+ owner,
1428
+ repo,
1429
+ ref: ref || (await getDefaultBranch(owner, repo)),
1430
+ skillMdPath: sourceSkillPath,
1431
+ force: !!opts.force,
1432
+ });
1433
+ if (!prepared.ok) {
1434
+ console.log(red(` ${prepared.message}`));
1435
+ return { skillName, installed: false, verdict: prepared.verdict, score: scanResult.score };
1436
+ }
1437
+ bundleFiles = Object.keys(prepared.bundle.files).length > 0 ? prepared.bundle.files : undefined;
1438
+ bundleExecutables = prepared.bundle.executables;
1439
+ installedFiles = prepared.bundle.fileList;
1440
+ }
1441
+ // Fetch agent files (agents/*.md) if discovered and the path is unknown
1364
1442
  let agentFiles;
1365
- if (agentRawUrls && Object.keys(agentRawUrls).length > 0) {
1443
+ if (!sourceSkillPath && agentRawUrls && Object.keys(agentRawUrls).length > 0) {
1366
1444
  agentFiles = {};
1367
1445
  const fetches = Object.entries(agentRawUrls).map(async ([relPath, url]) => {
1368
1446
  try {
@@ -1389,7 +1467,9 @@ sourceSkillPath) {
1389
1467
  scope: opts.global ? "user" : "project",
1390
1468
  projectRoot,
1391
1469
  copy: !!opts.copy,
1392
- bundleFiles: agentFiles,
1470
+ bundleFiles: bundleFiles ?? agentFiles,
1471
+ bundleExecutables,
1472
+ previousFiles,
1393
1473
  });
1394
1474
  }
1395
1475
  catch (err) {
@@ -1397,7 +1477,7 @@ sourceSkillPath) {
1397
1477
  return { skillName, installed: false, verdict: "WRITE_ERROR", score: scanResult.score };
1398
1478
  }
1399
1479
  const skillVersion = extractFrontmatterVersion(content) || "1.0.0";
1400
- return { skillName, installed: true, verdict: scanResult.verdict, score: scanResult.score, sha, version: skillVersion, sourceSkillPath };
1480
+ return { skillName, installed: true, verdict: scanResult.verdict, score: scanResult.score, sha, version: skillVersion, sourceSkillPath, files: installedFiles };
1401
1481
  }
1402
1482
  // ---------------------------------------------------------------------------
1403
1483
  // Bulk repo plugin installation: --repo with --all
@@ -1649,9 +1729,29 @@ async function installRepoPlugin(ownerRepo, pluginName, opts, overrideSource) {
1649
1729
  const sha = computeSha(combined);
1650
1730
  const skillFileNames = skills.map((s) => s.name === pluginName ? "SKILL.md" : `${s.name}/SKILL.md`).sort();
1651
1731
  const locations = [];
1732
+ if (opts.dryRun) {
1733
+ printDryRunPlan([pluginName], selectedAgents, opts);
1734
+ const enableResult = enableAfterInstall(pluginName, {}, opts);
1735
+ const report = buildPerAgentReport({
1736
+ skill: pluginName,
1737
+ scope: enableResult.scope,
1738
+ action: "not-applicable",
1739
+ agents: selectedAgents,
1740
+ });
1741
+ for (const line of report)
1742
+ console.log(` ${dim(">")} ${line.line}`);
1743
+ return;
1744
+ }
1745
+ const doneBases = new Set();
1652
1746
  for (const agent of selectedAgents) {
1653
1747
  const baseDir = resolveInstallBase(opts, agent);
1654
1748
  const plugDir = basename(baseDir) === pluginName ? baseDir : join(baseDir, pluginName);
1749
+ // 0894: shared readers share one copy in .agents/skills.
1750
+ if (doneBases.has(baseDir)) {
1751
+ locations.push(`${agent.displayName}: ${plugDir}`);
1752
+ continue;
1753
+ }
1754
+ doneBases.add(baseDir);
1655
1755
  try {
1656
1756
  // Skills: {agent-dir}/{plugin-name}/{skill-name}/SKILL.md
1657
1757
  for (const skill of skills) {
@@ -1702,9 +1802,9 @@ async function installRepoPlugin(ownerRepo, pluginName, opts, overrideSource) {
1702
1802
  // hence no rollback wiring here) — the gate only surfaces the same
1703
1803
  // acknowledgement + per-agent report as the marketplace path. The identical
1704
1804
  // opt-in condition keeps the default flow unchanged (NFR-005).
1805
+ // (--dry-run, also an opt-in, returned earlier with its own preview.)
1705
1806
  const userOptedIn = opts.scope !== undefined ||
1706
1807
  opts.global === true ||
1707
- opts.dryRun === true ||
1708
1808
  opts.enable === false;
1709
1809
  if (userOptedIn) {
1710
1810
  const enableResult = enableAfterInstall(pluginName, lock.skills[pluginName], opts);
@@ -1989,12 +2089,20 @@ async function addCommandInner(source, opts) {
1989
2089
  return process.exit(0);
1990
2090
  }
1991
2091
  }
2092
+ if (opts.dryRun) {
2093
+ printDryRunPlan(selectedSkills.map((s) => s.name), selectedAgents, opts);
2094
+ return;
2095
+ }
2096
+ // 0894: resolve the commit ONCE; SKILL.md, bundle and lockfile all use it.
2097
+ const sourceBranch = await getDefaultBranch(owner, repo);
2098
+ const sourceCommitSha = await getBranchHeadSha(owner, repo, sourceBranch);
2099
+ const priorSkills = readLockfile(lockfileRoot(opts))?.skills ?? {};
1992
2100
  // Install selected skills
1993
2101
  const results = [];
1994
2102
  for (const skill of selectedSkills) {
1995
2103
  console.log(dim(`\nInstalling skill: ${bold(skill.name)}...`));
1996
2104
  try {
1997
- const result = await installOneGitHubSkill(owner, repo, skill.name, skill.rawUrl, opts, selectedAgents, projectRoot, skill.agentRawUrls, skill.path);
2105
+ const result = await installOneGitHubSkill(owner, repo, skill.name, skill.rawUrl, opts, selectedAgents, projectRoot, skill.agentRawUrls, skill.path, sourceCommitSha || sourceBranch, priorSkills[skill.name]?.files);
1998
2106
  results.push(result);
1999
2107
  }
2000
2108
  catch (err) {
@@ -2007,8 +2115,6 @@ async function addCommandInner(source, opts) {
2007
2115
  // `sourceSkillPath` are persisted alongside the legacy `source` string.
2008
2116
  const lockDir = lockfileRoot(opts);
2009
2117
  const lock = ensureLockfile(lockDir);
2010
- const sourceBranch = await getDefaultBranch(owner, repo);
2011
- const sourceCommitSha = await getBranchHeadSha(owner, repo, sourceBranch);
2012
2118
  for (const r of results) {
2013
2119
  if (r.installed && r.sha) {
2014
2120
  lock.skills[r.skillName] = buildGitHubInstallLockEntry({
@@ -2021,6 +2127,7 @@ async function addCommandInner(source, opts) {
2021
2127
  commitSha: sourceCommitSha,
2022
2128
  pluginName: pluginNameFromSkillPath(r.sourceSkillPath),
2023
2129
  global: !!opts.global,
2130
+ files: r.files,
2024
2131
  });
2025
2132
  }
2026
2133
  }
@@ -2384,10 +2491,21 @@ async function installFromRegistry(skillName, opts) {
2384
2491
  // Install to each agent
2385
2492
  const sha = computeSha(content);
2386
2493
  const locations = [];
2494
+ if (opts.dryRun) {
2495
+ printDryRunPlan([skillName], selectedAgents, opts);
2496
+ return;
2497
+ }
2387
2498
  const processedContent = ensureFrontmatter(content, skillName);
2499
+ const doneBases = new Set();
2388
2500
  for (const agent of selectedAgents) {
2389
2501
  const baseDir = resolveInstallBase(opts, agent);
2390
2502
  const skillDir = join(baseDir, skillName);
2503
+ // 0894: shared readers share one copy in .agents/skills.
2504
+ if (doneBases.has(baseDir)) {
2505
+ locations.push(`${agent.displayName}: ${skillDir}`);
2506
+ continue;
2507
+ }
2508
+ doneBases.add(baseDir);
2391
2509
  try {
2392
2510
  mkdirSync(skillDir, { recursive: true });
2393
2511
  writeFileSync(join(skillDir, "SKILL.md"), processedContent, "utf-8");
@@ -2465,6 +2583,8 @@ async function installSingleSkillLegacy(owner, repo, skill, opts, skillSubpathOv
2465
2583
  }
2466
2584
  const branch = refOverride || (await getDefaultBranch(owner, repo));
2467
2585
  const sourceCommitSha = await getBranchHeadSha(owner, repo, branch);
2586
+ // 0894: SKILL.md, the bundle and the lockfile all read this one commit.
2587
+ const contentRef = sourceCommitSha || branch;
2468
2588
  // Candidate subpaths: explicit override wins; otherwise try the conventional
2469
2589
  // skills/{name}/ layout first, then root-level {name}/ (e.g. higgsfield-ai/skills).
2470
2590
  const subpathCandidates = skillSubpathOverride
@@ -2477,7 +2597,7 @@ async function installSingleSkillLegacy(owner, repo, skill, opts, skillSubpathOv
2477
2597
  let content;
2478
2598
  for (let i = 0; i < subpathCandidates.length; i++) {
2479
2599
  const candidate = subpathCandidates[i];
2480
- const candidateUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${candidate}`;
2600
+ const candidateUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${contentRef}/${candidate}`;
2481
2601
  const isLast = i === subpathCandidates.length - 1;
2482
2602
  if (isLast) {
2483
2603
  content = await fetchSkillContent(candidateUrl);
@@ -2493,53 +2613,6 @@ async function installSingleSkillLegacy(owner, repo, skill, opts, skillSubpathOv
2493
2613
  }
2494
2614
  if (content === undefined)
2495
2615
  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
2616
  // Platform security check (best-effort, non-blocking on network error).
2544
2617
  // Skipped for private repos: the registry looks skills up by name, so it
2545
2618
  // would leak the name and could match an unrelated public skill.
@@ -2624,6 +2697,26 @@ async function installSingleSkillLegacy(owner, repo, skill, opts, skillSubpathOv
2624
2697
  opts.global = true;
2625
2698
  if (!selections.symlink)
2626
2699
  opts.copy = true;
2700
+ if (opts.dryRun) {
2701
+ printDryRunPlan([skillName], selectedAgents, opts);
2702
+ return;
2703
+ }
2704
+ // 0894: the complete skill folder — every nested file, binaries as bytes,
2705
+ // exec bits kept — read from the same commit, scanned, never truncated; not
2706
+ // downloaded for --dry-run.
2707
+ const prepared = await prepareGitHubSkillBundle({
2708
+ owner,
2709
+ repo,
2710
+ ref: contentRef,
2711
+ skillMdPath: skillSubpath,
2712
+ force: !!opts.force,
2713
+ });
2714
+ if (!prepared.ok) {
2715
+ console.error(red(`\n${prepared.message}`));
2716
+ return process.exit(1);
2717
+ }
2718
+ const bundle = prepared.bundle;
2719
+ const previousFiles = readLockfile(lockfileRoot(opts))?.skills[skillName]?.files;
2627
2720
  // Install to each agent using canonical installer (flat directory, namespaced frontmatter name)
2628
2721
  const namespacedName = pluginNamespace && skillName !== pluginNamespace
2629
2722
  ? `${pluginNamespace}/${skillName}`
@@ -2648,7 +2741,9 @@ async function installSingleSkillLegacy(owner, repo, skill, opts, skillSubpathOv
2648
2741
  scope: opts.global ? "user" : "project",
2649
2742
  projectRoot,
2650
2743
  copy: !!opts.copy,
2651
- bundleFiles: legacyAgentFiles,
2744
+ bundleFiles: Object.keys(bundle.files).length > 0 ? bundle.files : undefined,
2745
+ bundleExecutables: bundle.executables,
2746
+ previousFiles,
2652
2747
  });
2653
2748
  // Update lockfile (global → ~/.agents/, project → project root).
2654
2749
  // 0743: persist `sourceSkillPath` from the locally-derived `skillSubpath`
@@ -2667,6 +2762,7 @@ async function installSingleSkillLegacy(owner, repo, skill, opts, skillSubpathOv
2667
2762
  commitSha: sourceCommitSha,
2668
2763
  pluginName: pluginNamespace ?? pluginNameFromSkillPath(skillSubpath),
2669
2764
  global: !!opts.global,
2765
+ files: bundle.fileList,
2670
2766
  });
2671
2767
  lock.agents = [...new Set([...(lock.agents || []), ...selectedAgents.map((a) => a.id)])];
2672
2768
  writeLockfile(lock, lockDir);