pruff-skills 1.11.0 → 1.12.0

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 (3) hide show
  1. package/README.md +2 -1
  2. package/dist/cli.mjs +450 -171
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -142,7 +142,7 @@ npx pruff-skills add <skill> -g -a claude-code -y
142
142
  | `npx pruff-skills list` | List installed skills (alias: `ls`) |
143
143
  | `npx pruff-skills find [query]` | Search the Pruff catalog interactively or by keyword |
144
144
  | `npx pruff-skills remove [skills]` | Remove installed skills from agents |
145
- | `npx pruff-skills update [skills]` | Update installed skills to latest versions |
145
+ | `npx pruff-skills update [skills]` | Show what changed upstream (modified / new / deleted), then update only that |
146
146
  | `npx pruff-skills init [name]` | Create a new `SKILL.md` template |
147
147
  | `npx pruff-skills restore` | Reinstall everything recorded in `skills-lock.json` |
148
148
  | `npx pruff-skills sync` | Install skills found in `node_modules` into agent dirs |
@@ -155,6 +155,7 @@ Troubleshooting: if a command fails, run `npx pruff-skills doctor` and paste the
155
155
  npx pruff-skills list # list installed skills
156
156
  npx pruff-skills find logs # search the catalog by keyword
157
157
  npx pruff-skills update -g # update global skills
158
+ npx pruff-skills update --dry-run # show the plan, change nothing
158
159
  npx pruff-skills remove <skill> # remove a skill
159
160
  ```
160
161
 
package/dist/cli.mjs CHANGED
@@ -7,7 +7,7 @@ import "./_chunks/libs/@kwsites/promise-deferred.mjs";
7
7
  import { t as esm_default } from "./_chunks/libs/simple-git.mjs";
8
8
  import { t as xdgConfig } from "./_chunks/libs/xdg-basedir.mjs";
9
9
  import { t as require_dist } from "./_chunks/libs/@vercel/detect-agent.mjs";
10
- import { accessSync, chmodSync, constants, copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "fs";
10
+ import { accessSync, chmodSync, constants, copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "fs";
11
11
  import { basename, delimiter, dirname, isAbsolute, join, normalize, relative, resolve, sep } from "path";
12
12
  import { fileURLToPath } from "url";
13
13
  import { stripVTControlCharacters } from "node:util";
@@ -1041,6 +1041,14 @@ async function getPluginGroupings(basePath) {
1041
1041
  } catch {}
1042
1042
  return groupings;
1043
1043
  }
1044
+ const AGENTS_DIR$1 = ".agents";
1045
+ const SKILLS_SUBDIR = "skills";
1046
+ const INSTALL_EXCLUDED_FILES = new Set(["metadata.json"]);
1047
+ const INSTALL_EXCLUDED_DIRS = new Set([
1048
+ ".git",
1049
+ "__pycache__",
1050
+ "__pypackages__"
1051
+ ]);
1044
1052
  const LOCAL_LOCK_FILE = "skills-lock.json";
1045
1053
  const CURRENT_VERSION$1 = 1;
1046
1054
  function getLocalLockPath(cwd) {
@@ -1077,8 +1085,14 @@ async function writeLocalLock(lock, cwd) {
1077
1085
  await writeFile(lockPath, JSON.stringify(sorted, null, 2) + "\n", "utf-8");
1078
1086
  }
1079
1087
  async function computeSkillFolderHash(skillDir) {
1088
+ return hashSkillFiles(skillDir, false);
1089
+ }
1090
+ async function computeSkillContentHash(skillDir) {
1091
+ return hashSkillFiles(skillDir, true);
1092
+ }
1093
+ async function hashSkillFiles(skillDir, installedOnly) {
1080
1094
  const files = [];
1081
- await collectFiles(skillDir, skillDir, files);
1095
+ await collectFiles(skillDir, skillDir, files, installedOnly);
1082
1096
  files.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
1083
1097
  const hash = createHash("sha256");
1084
1098
  for (const file of files) {
@@ -1087,14 +1101,16 @@ async function computeSkillFolderHash(skillDir) {
1087
1101
  }
1088
1102
  return hash.digest("hex");
1089
1103
  }
1090
- async function collectFiles(baseDir, currentDir, results) {
1104
+ async function collectFiles(baseDir, currentDir, results, installedOnly) {
1091
1105
  const entries = await readdir(currentDir, { withFileTypes: true });
1092
1106
  await Promise.all(entries.map(async (entry) => {
1093
1107
  const fullPath = join(currentDir, entry.name);
1094
1108
  if (entry.isDirectory()) {
1095
1109
  if (entry.name === ".git" || entry.name === "node_modules") return;
1096
- await collectFiles(baseDir, fullPath, results);
1110
+ if (installedOnly && INSTALL_EXCLUDED_DIRS.has(entry.name)) return;
1111
+ await collectFiles(baseDir, fullPath, results, installedOnly);
1097
1112
  } else if (entry.isFile()) {
1113
+ if (installedOnly && INSTALL_EXCLUDED_FILES.has(entry.name)) return;
1098
1114
  const content = await readFile(fullPath);
1099
1115
  const relativePath = relative(baseDir, fullPath).split("\\").join("/");
1100
1116
  results.push({
@@ -1109,6 +1125,13 @@ async function addSkillToLocalLock(skillName, entry, cwd) {
1109
1125
  lock.skills[skillName] = entry;
1110
1126
  await writeLocalLock(lock, cwd);
1111
1127
  }
1128
+ async function removeSkillFromLocalLock(skillName, cwd) {
1129
+ const lock = await readLocalLock(cwd);
1130
+ if (!(skillName in lock.skills)) return false;
1131
+ delete lock.skills[skillName];
1132
+ await writeLocalLock(lock, cwd);
1133
+ return true;
1134
+ }
1112
1135
  function createEmptyLocalLock() {
1113
1136
  return {
1114
1137
  version: CURRENT_VERSION$1,
@@ -1972,8 +1995,6 @@ function getNonUniversalAgents() {
1972
1995
  function isUniversalAgent(type) {
1973
1996
  return agents[type].skillsDir === ".agents/skills";
1974
1997
  }
1975
- const AGENTS_DIR$1 = ".agents";
1976
- const SKILLS_SUBDIR = "skills";
1977
1998
  function sanitizeName(name) {
1978
1999
  return name.toLowerCase().replace(/[^a-z0-9._]+/g, "-").replace(/^[.\-]+|[.\-]+$/g, "").substring(0, 255) || "unnamed-skill";
1979
2000
  }
@@ -2152,15 +2173,9 @@ async function installSkillForAgent(skill, agentType, options = {}) {
2152
2173
  };
2153
2174
  }
2154
2175
  }
2155
- const EXCLUDE_FILES$1 = new Set(["metadata.json"]);
2156
- const EXCLUDE_DIRS$1 = new Set([
2157
- ".git",
2158
- "__pycache__",
2159
- "__pypackages__"
2160
- ]);
2161
2176
  const isExcluded$1 = (name, isDirectory = false) => {
2162
- if (EXCLUDE_FILES$1.has(name)) return true;
2163
- if (isDirectory && EXCLUDE_DIRS$1.has(name)) return true;
2177
+ if (INSTALL_EXCLUDED_FILES.has(name)) return true;
2178
+ if (isDirectory && INSTALL_EXCLUDED_DIRS.has(name)) return true;
2164
2179
  return false;
2165
2180
  };
2166
2181
  async function copyDirectory(src, dest) {
@@ -3113,6 +3128,33 @@ async function dismissPrompt(promptKey) {
3113
3128
  lock.dismissed[promptKey] = true;
3114
3129
  await writeSkillLock(lock);
3115
3130
  }
3131
+ const DIVERGENT_HASH = "divergent";
3132
+ function findInstalledSkillDirs(skillName, cwd) {
3133
+ const candidates = [getCanonicalPath(skillName, { cwd })];
3134
+ for (const [type, config] of Object.entries(agents)) {
3135
+ if (isUniversalAgent(type)) continue;
3136
+ candidates.push(join(cwd, config.skillsDir, sanitizeName(skillName)));
3137
+ }
3138
+ const seen = /* @__PURE__ */ new Set();
3139
+ const dirs = [];
3140
+ for (const dir of candidates) {
3141
+ if (!existsSync(join(dir, "SKILL.md"))) continue;
3142
+ let real = dir;
3143
+ try {
3144
+ real = realpathSync(dir);
3145
+ } catch {}
3146
+ if (seen.has(real)) continue;
3147
+ seen.add(real);
3148
+ dirs.push(dir);
3149
+ }
3150
+ return dirs;
3151
+ }
3152
+ async function hashInstalledSkill(skillName, cwd) {
3153
+ const dirs = findInstalledSkillDirs(skillName, cwd);
3154
+ if (dirs.length === 0) return null;
3155
+ const hashes = new Set(await Promise.all(dirs.map((dir) => computeSkillContentHash(dir))));
3156
+ return hashes.size === 1 ? [...hashes][0] : DIVERGENT_HASH;
3157
+ }
3116
3158
  const DOWNLOAD_BASE_URL = process.env.PRUFF_SKILLS_DOWNLOAD_URL || "";
3117
3159
  const FETCH_TIMEOUT = 1e4;
3118
3160
  function toSkillSlug(name) {
@@ -4255,12 +4297,14 @@ async function runAdd(args, options = {}) {
4255
4297
  if (successfulSkillNames.has(skillDisplayName)) try {
4256
4298
  const computedHash = blobResult && "snapshotHash" in skill ? skill.snapshotHash : await computeSkillFolderHash(skill.path);
4257
4299
  const skillPathValue = skillFiles[skill.name];
4300
+ const installedHash = await hashInstalledSkill(skill.name, cwd);
4258
4301
  await addSkillToLocalLock(skill.name, {
4259
4302
  source: lockSource || parsed.url,
4260
4303
  ref: parsed.ref,
4261
4304
  sourceType: parsed.type,
4262
4305
  ...skillPathValue && { skillPath: skillPathValue },
4263
- computedHash
4306
+ computedHash,
4307
+ ...installedHash && installedHash !== DIVERGENT_HASH && { installedHash }
4264
4308
  }, cwd);
4265
4309
  } catch {}
4266
4310
  }
@@ -4554,7 +4598,7 @@ async function updateAgents(options = {}) {
4554
4598
  result.unchanged.push(name);
4555
4599
  continue;
4556
4600
  }
4557
- await writeFile(dest, content);
4601
+ if (!options.dryRun) await writeFile(dest, content);
4558
4602
  result.updated.push(name);
4559
4603
  }
4560
4604
  return result;
@@ -4756,10 +4800,10 @@ async function runRules(args) {
4756
4800
  await addRules(names, options);
4757
4801
  }
4758
4802
  const execFileAsync$2 = promisify(execFile);
4759
- const RESET$3 = "\x1B[0m";
4760
- const BOLD$3 = "\x1B[1m";
4761
- const DIM$3 = "\x1B[38;5;102m";
4762
- const TEXT$2 = "\x1B[38;5;145m";
4803
+ const RESET$4 = "\x1B[0m";
4804
+ const BOLD$4 = "\x1B[1m";
4805
+ const DIM$4 = "\x1B[38;5;102m";
4806
+ const TEXT$3 = "\x1B[38;5;145m";
4763
4807
  const CYAN$1 = "\x1B[36m";
4764
4808
  const GREEN = "\x1B[32m";
4765
4809
  const MAGENTA = "\x1B[35m";
@@ -4850,10 +4894,10 @@ const MOVE_UP = (n) => `\x1b[${n}A`;
4850
4894
  const MOVE_TO_COL = (n) => `\x1b[${n}G`;
4851
4895
  function rowBadge(skill) {
4852
4896
  const parts = [];
4853
- if (skill.category) parts.push(`${CYAN$1}${skill.category}${RESET$3}`);
4854
- if (skill.installed) parts.push(`${GREEN}installed${RESET$3}`);
4897
+ if (skill.category) parts.push(`${CYAN$1}${skill.category}${RESET$4}`);
4898
+ if (skill.installed) parts.push(`${GREEN}installed${RESET$4}`);
4855
4899
  if (parts.length === 0) return "";
4856
- return ` ${DIM$3}[${RESET$3}${parts.join(`${DIM$3} · ${RESET$3}`)}${DIM$3}]${RESET$3}`;
4900
+ return ` ${DIM$4}[${RESET$4}${parts.join(`${DIM$4} · ${RESET$4}`)}${DIM$4}]${RESET$4}`;
4857
4901
  }
4858
4902
  async function runSearchPrompt(kind, alreadyPicked = /* @__PURE__ */ new Set(), initialQuery = "") {
4859
4903
  let results = [];
@@ -4871,33 +4915,33 @@ async function runSearchPrompt(kind, alreadyPicked = /* @__PURE__ */ new Set(),
4871
4915
  if (lastRenderedLines > 0) process.stdout.write(MOVE_UP(lastRenderedLines) + MOVE_TO_COL(1));
4872
4916
  process.stdout.write(CLEAR_DOWN);
4873
4917
  const lines = [];
4874
- const cursor = `${BOLD$3}_${RESET$3}`;
4875
- lines.push(`${TEXT$2}Search ${noun}:${RESET$3} ${query}${cursor}`);
4918
+ const cursor = `${BOLD$4}_${RESET$4}`;
4919
+ lines.push(`${TEXT$3}Search ${noun}:${RESET$4} ${query}${cursor}`);
4876
4920
  lines.push("");
4877
- if (loading && results.length === 0) lines.push(`${DIM$3}Loading catalog…${RESET$3}`);
4878
- else if (results.length === 0) lines.push(`${DIM$3}${query ? `No ${noun} match "${query}"` : `No ${noun} in catalog`}${RESET$3}`);
4921
+ if (loading && results.length === 0) lines.push(`${DIM$4}Loading catalog…${RESET$4}`);
4922
+ else if (results.length === 0) lines.push(`${DIM$4}${query ? `No ${noun} match "${query}"` : `No ${noun} in catalog`}${RESET$4}`);
4879
4923
  else {
4880
4924
  const maxVisible = 12;
4881
4925
  const start = Math.max(0, Math.min(selectedIndex - maxVisible + 1, results.length - maxVisible));
4882
4926
  const from = Math.max(0, start);
4883
4927
  const visible = results.slice(from, from + maxVisible);
4884
- if (from > 0) lines.push(` ${DIM$3}↑ ${from} more${RESET$3}`);
4928
+ if (from > 0) lines.push(` ${DIM$4}↑ ${from} more${RESET$4}`);
4885
4929
  for (let i = 0; i < visible.length; i++) {
4886
4930
  const idx = from + i;
4887
4931
  const skill = visible[i];
4888
4932
  const isSelected = idx === selectedIndex;
4889
- const arrow = isSelected ? `${BOLD$3}>${RESET$3}` : " ";
4890
- const box = picked.has(skill.name) ? `${GREEN}${CHECKED}${RESET$3}` : `${DIM$3}${UNCHECKED}${RESET$3}`;
4891
- const name = isSelected ? `${BOLD$3}${skill.name}${RESET$3}` : `${TEXT$2}${skill.name}${RESET$3}`;
4892
- const loadingIndicator = loading && idx === 0 ? ` ${DIM$3}...${RESET$3}` : "";
4933
+ const arrow = isSelected ? `${BOLD$4}>${RESET$4}` : " ";
4934
+ const box = picked.has(skill.name) ? `${GREEN}${CHECKED}${RESET$4}` : `${DIM$4}${UNCHECKED}${RESET$4}`;
4935
+ const name = isSelected ? `${BOLD$4}${skill.name}${RESET$4}` : `${TEXT$3}${skill.name}${RESET$4}`;
4936
+ const loadingIndicator = loading && idx === 0 ? ` ${DIM$4}...${RESET$4}` : "";
4893
4937
  lines.push(` ${arrow} ${box} ${name}${rowBadge(skill)}${loadingIndicator}`);
4894
4938
  }
4895
4939
  const remaining = results.length - (from + visible.length);
4896
- if (remaining > 0) lines.push(` ${DIM$3}↓ ${remaining} more${RESET$3}`);
4940
+ if (remaining > 0) lines.push(` ${DIM$4}↓ ${remaining} more${RESET$4}`);
4897
4941
  }
4898
4942
  lines.push("");
4899
- const count = picked.size > 0 ? ` ${GREEN}(${picked.size} selected)${RESET$3}` : "";
4900
- lines.push(`${DIM$3}up/down navigate | space toggle | enter done${count}${DIM$3} | esc back${RESET$3}`);
4943
+ const count = picked.size > 0 ? ` ${GREEN}(${picked.size} selected)${RESET$4}` : "";
4944
+ lines.push(`${DIM$4}up/down navigate | space toggle | enter done${count}${DIM$4} | esc back${RESET$4}`);
4901
4945
  for (const line of lines) process.stdout.write(line + "\n");
4902
4946
  lastRenderedLines = lines.length;
4903
4947
  }
@@ -4982,22 +5026,22 @@ async function runSearchPrompt(kind, alreadyPicked = /* @__PURE__ */ new Set(),
4982
5026
  async function runFind(args) {
4983
5027
  const query = args.join(" ");
4984
5028
  const isNonInteractive = !process.stdin.isTTY;
4985
- const agentTip = `${DIM$3}Tip: if running in a coding agent, follow these steps:${RESET$3}
4986
- ${DIM$3} 1) npx pruff-skills find [query]${RESET$3}
4987
- ${DIM$3} 2) npx pruff-skills add <skill>${RESET$3}`;
5029
+ const agentTip = `${DIM$4}Tip: if running in a coding agent, follow these steps:${RESET$4}
5030
+ ${DIM$4} 1) npx pruff-skills find [query]${RESET$4}
5031
+ ${DIM$4} 2) npx pruff-skills add <skill>${RESET$4}`;
4988
5032
  if (query) {
4989
5033
  const results = await searchSkillsAPI(query);
4990
5034
  String(results.length);
4991
5035
  if (results.length === 0) {
4992
- console.log(`${DIM$3}Nothing in the catalog matches "${query}"${RESET$3}`);
5036
+ console.log(`${DIM$4}Nothing in the catalog matches "${query}"${RESET$4}`);
4993
5037
  return;
4994
5038
  }
4995
- console.log(`${DIM$3}Install a skill with${RESET$3} npx pruff-skills add <name>${DIM$3}, a subagent with${RESET$3} npx pruff-skills agents add <name>${DIM$3}, a rule with${RESET$3} npx pruff-skills rules add <name>`);
5039
+ console.log(`${DIM$4}Install a skill with${RESET$4} npx pruff-skills add <name>${DIM$4}, a subagent with${RESET$4} npx pruff-skills agents add <name>${DIM$4}, a rule with${RESET$4} npx pruff-skills rules add <name>`);
4996
5040
  console.log();
4997
5041
  for (const skill of results.slice(0, 10)) {
4998
- const kindBadge = skill.kind === "agent" ? ` ${MAGENTA}[subagent]${RESET$3}` : skill.kind === "rule" ? ` ${BLUE$1}[rule]${RESET$3}` : ` ${CYAN$1}[skill]${RESET$3}`;
4999
- console.log(`${TEXT$2}${skill.name}${RESET$3}${kindBadge}${rowBadge(skill)}`);
5000
- if (skill.description) console.log(`${DIM$3}└ ${skill.description}${RESET$3}`);
5042
+ const kindBadge = skill.kind === "agent" ? ` ${MAGENTA}[subagent]${RESET$4}` : skill.kind === "rule" ? ` ${BLUE$1}[rule]${RESET$4}` : ` ${CYAN$1}[skill]${RESET$4}`;
5043
+ console.log(`${TEXT$3}${skill.name}${RESET$4}${kindBadge}${rowBadge(skill)}`);
5044
+ if (skill.description) console.log(`${DIM$4}└ ${skill.description}${RESET$4}`);
5001
5045
  console.log();
5002
5046
  }
5003
5047
  return;
@@ -5005,7 +5049,7 @@ ${DIM$3} 2) npx pruff-skills add <skill>${RESET$3}`;
5005
5049
  if (isNonInteractive || await isRunningInAgent()) {
5006
5050
  console.log(agentTip);
5007
5051
  console.log();
5008
- console.log(`${DIM$3}Usage: npx pruff-skills find <query>${RESET$3}`);
5052
+ console.log(`${DIM$4}Usage: npx pruff-skills find <query>${RESET$4}`);
5009
5053
  return;
5010
5054
  }
5011
5055
  const all = await searchSkillsAPI("");
@@ -5030,7 +5074,7 @@ ${DIM$3} 2) npx pruff-skills add <skill>${RESET$3}`;
5030
5074
  const pickedByKind = new Map(KINDS.map((k) => [k.value, /* @__PURE__ */ new Set()]));
5031
5075
  const totalPicked = () => [...pickedByKind.values()].reduce((n, set) => n + set.size, 0);
5032
5076
  const cancelled = () => {
5033
- console.log(`${DIM$3}Search cancelled${RESET$3}`);
5077
+ console.log(`${DIM$4}Search cancelled${RESET$4}`);
5034
5078
  console.log();
5035
5079
  };
5036
5080
  for (;;) {
@@ -5061,10 +5105,10 @@ ${DIM$3} 2) npx pruff-skills add <skill>${RESET$3}`;
5061
5105
  for (const kind of KINDS) {
5062
5106
  const names = [...pickedByKind.get(kind.value)];
5063
5107
  if (names.length === 0) continue;
5064
- console.log(`${TEXT$2}${kind.label}${RESET$3} ${DIM$3}(${names.length})${RESET$3}`);
5108
+ console.log(`${TEXT$3}${kind.label}${RESET$4} ${DIM$4}(${names.length})${RESET$4}`);
5065
5109
  for (const name of names) {
5066
- const note = all.find((s) => s.kind === kind.value && s.name === name)?.installed ? ` ${DIM$3}— already installed, will reinstall${RESET$3}` : "";
5067
- console.log(` ${DIM$3}·${RESET$3} ${name}${note}`);
5110
+ const note = all.find((s) => s.kind === kind.value && s.name === name)?.installed ? ` ${DIM$4}— already installed, will reinstall${RESET$4}` : "";
5111
+ console.log(` ${DIM$4}·${RESET$4} ${name}${note}`);
5068
5112
  }
5069
5113
  }
5070
5114
  console.log();
@@ -5091,7 +5135,7 @@ ${DIM$3} 2) npx pruff-skills add <skill>${RESET$3}`;
5091
5135
  console.log();
5092
5136
  if (skills.length > 0) {
5093
5137
  const pkg = CATALOG_SOURCE;
5094
- console.log(`${TEXT$2}Installing ${BOLD$3}${skills.join(", ")}${RESET$3} from ${DIM$3}${pkg}${RESET$3}...`);
5138
+ console.log(`${TEXT$3}Installing ${BOLD$4}${skills.join(", ")}${RESET$4} from ${DIM$4}${pkg}${RESET$4}...`);
5095
5139
  console.log();
5096
5140
  const { source, options } = parseAddOptions([
5097
5141
  pkg,
@@ -5103,7 +5147,7 @@ ${DIM$3} 2) npx pruff-skills add <skill>${RESET$3}`;
5103
5147
  if (agents.length > 0) await addAgents(agents, {});
5104
5148
  if (rules.length > 0) await addRules(rules, {});
5105
5149
  console.log();
5106
- console.log(`${DIM$3}Installed.${RESET$3} ${TEXT$2}npx pruff-skills list${RESET$3} ${DIM$3}shows what's installed.${RESET$3}`);
5150
+ console.log(`${DIM$4}Installed.${RESET$4} ${TEXT$3}npx pruff-skills list${RESET$4} ${DIM$4}shows what's installed.${RESET$4}`);
5107
5151
  console.log();
5108
5152
  }
5109
5153
  const isCancelled = (value) => typeof value === "symbol";
@@ -5452,9 +5496,9 @@ async function runInstallFromLock(args) {
5452
5496
  }
5453
5497
  }
5454
5498
  }
5455
- const RESET$2 = "\x1B[0m";
5456
- const BOLD$2 = "\x1B[1m";
5457
- const DIM$2 = "\x1B[38;5;102m";
5499
+ const RESET$3 = "\x1B[0m";
5500
+ const BOLD$3 = "\x1B[1m";
5501
+ const DIM$3 = "\x1B[38;5;102m";
5458
5502
  const CYAN = "\x1B[36m";
5459
5503
  const YELLOW = "\x1B[33m";
5460
5504
  const LIST_JSON_SCHEMA_VERSION = 1;
@@ -5491,8 +5535,8 @@ async function runList(args) {
5491
5535
  const validAgents = Object.keys(agents);
5492
5536
  const invalidAgents = options.agent.filter((a) => !validAgents.includes(a));
5493
5537
  if (invalidAgents.length > 0) {
5494
- console.log(`${YELLOW}Invalid agents: ${invalidAgents.join(", ")}${RESET$2}`);
5495
- console.log(`${DIM$2}Valid agents: ${validAgents.join(", ")}${RESET$2}`);
5538
+ console.log(`${YELLOW}Invalid agents: ${invalidAgents.join(", ")}${RESET$3}`);
5539
+ console.log(`${DIM$3}Valid agents: ${validAgents.join(", ")}${RESET$3}`);
5496
5540
  process.exit(1);
5497
5541
  }
5498
5542
  agentFilter = options.agent;
@@ -5518,21 +5562,21 @@ async function runList(args) {
5518
5562
  const cwd = process.cwd();
5519
5563
  const scopeLabel = scope ? "Global" : "Project";
5520
5564
  if (installedSkills.length === 0) {
5521
- console.log(`${DIM$2}No ${scopeLabel.toLowerCase()} skills found.${RESET$2}`);
5522
- if (scope) console.log(`${DIM$2}Try listing project skills without -g${RESET$2}`);
5523
- else console.log(`${DIM$2}Try listing global skills with -g${RESET$2}`);
5565
+ console.log(`${DIM$3}No ${scopeLabel.toLowerCase()} skills found.${RESET$3}`);
5566
+ if (scope) console.log(`${DIM$3}Try listing project skills without -g${RESET$3}`);
5567
+ else console.log(`${DIM$3}Try listing global skills with -g${RESET$3}`);
5524
5568
  return;
5525
5569
  }
5526
5570
  function printSkill(skill, indent = false, maxNameLength = 0, maxPathLength = 0) {
5527
5571
  const prefix = indent ? " " : "";
5528
5572
  const shortPath = shortenPath(skill.canonicalPath, cwd);
5529
5573
  const agentNames = skill.agents.map((a) => agents[a].displayName);
5530
- const agentInfo = skill.agents.length > 0 ? formatList(agentNames) : `${YELLOW}not linked${RESET$2}`;
5574
+ const agentInfo = skill.agents.length > 0 ? formatList(agentNames) : `${YELLOW}not linked${RESET$3}`;
5531
5575
  const paddedName = sanitizeMetadata(skill.name).padEnd(maxNameLength);
5532
5576
  const paddedPath = shortPath.padEnd(maxPathLength);
5533
- console.log(`${prefix}${CYAN}${paddedName}${RESET$2} ${DIM$2}${paddedPath}${RESET$2} ${DIM$2}Agents:${RESET$2} ${agentInfo}`);
5577
+ console.log(`${prefix}${CYAN}${paddedName}${RESET$3} ${DIM$3}${paddedPath}${RESET$3} ${DIM$3}Agents:${RESET$3} ${agentInfo}`);
5534
5578
  }
5535
- console.log(`${BOLD$2}${scopeLabel} Skills${RESET$2}`);
5579
+ console.log(`${BOLD$3}${scopeLabel} Skills${RESET$3}`);
5536
5580
  console.log();
5537
5581
  const groupedSkills = {};
5538
5582
  const ungroupedSkills = [];
@@ -5548,7 +5592,7 @@ async function runList(args) {
5548
5592
  const sortedGroups = Object.keys(groupedSkills).sort();
5549
5593
  for (const group of sortedGroups) {
5550
5594
  const title = group.split("-").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
5551
- console.log(`${BOLD$2}${title}${RESET$2}`);
5595
+ console.log(`${BOLD$3}${title}${RESET$3}`);
5552
5596
  const skills = groupedSkills[group];
5553
5597
  if (skills) {
5554
5598
  let maxNameLength = 0;
@@ -5564,7 +5608,7 @@ async function runList(args) {
5564
5608
  console.log();
5565
5609
  }
5566
5610
  if (ungroupedSkills.length > 0) {
5567
- console.log(`${BOLD$2}General${RESET$2}`);
5611
+ console.log(`${BOLD$3}General${RESET$3}`);
5568
5612
  let maxNameLength = 0;
5569
5613
  let maxPathLength = 0;
5570
5614
  for (const skill of ungroupedSkills) {
@@ -5787,6 +5831,81 @@ function parseRemoveOptions(args) {
5787
5831
  options
5788
5832
  };
5789
5833
  }
5834
+ const RESET$2 = "\x1B[0m";
5835
+ const BOLD$2 = "\x1B[1m";
5836
+ const DIM$2 = "\x1B[38;5;102m";
5837
+ const TEXT$2 = "\x1B[38;5;145m";
5838
+ function classifyTrackedSkill(h) {
5839
+ if (h.diskHash === null) return {
5840
+ status: "missing",
5841
+ locallyEdited: false
5842
+ };
5843
+ const divergent = h.diskHash === DIVERGENT_HASH;
5844
+ if (h.catalogHash && h.catalogHash === h.lockHash) {
5845
+ const installed = h.installedHash ?? h.catalogContentHash;
5846
+ return {
5847
+ status: "unchanged",
5848
+ locallyEdited: divergent || h.diskHash !== installed
5849
+ };
5850
+ }
5851
+ if (h.catalogContentHash && h.diskHash === h.catalogContentHash) return {
5852
+ status: "rebaseline",
5853
+ locallyEdited: false
5854
+ };
5855
+ const installed = h.installedHash ?? h.lockHash;
5856
+ return {
5857
+ status: "modified",
5858
+ locallyEdited: divergent || h.diskHash !== installed
5859
+ };
5860
+ }
5861
+ function emptyPlan() {
5862
+ return {
5863
+ modified: [],
5864
+ missing: [],
5865
+ rebaseline: [],
5866
+ unchanged: [],
5867
+ conflicts: [],
5868
+ localEdits: [],
5869
+ deleted: [],
5870
+ available: [],
5871
+ untracked: [],
5872
+ failedSources: []
5873
+ };
5874
+ }
5875
+ function addClassified(plan, entry, c) {
5876
+ if (c.status === "unchanged") {
5877
+ plan.unchanged.push(entry);
5878
+ if (c.locallyEdited) plan.localEdits.push(entry);
5879
+ return;
5880
+ }
5881
+ if (c.status === "modified" && c.locallyEdited) {
5882
+ plan.conflicts.push(entry);
5883
+ return;
5884
+ }
5885
+ plan[c.status].push(entry);
5886
+ }
5887
+ function names(entries) {
5888
+ return entries.map((e) => sanitizeMetadata(e.name)).join(", ");
5889
+ }
5890
+ function line(label, entries, note) {
5891
+ if (entries.length === 0) return;
5892
+ const suffix = note ? ` ${DIM$2}— ${note}${RESET$2}` : "";
5893
+ console.log(` ${TEXT$2}${label} (${entries.length}):${RESET$2} ${names(entries)}${suffix}`);
5894
+ }
5895
+ function printPlan(plan, opts = {}) {
5896
+ console.log(`${BOLD$2}Plan${RESET$2}`);
5897
+ line("Modified upstream", plan.modified, "will update");
5898
+ line("Missing on disk", plan.missing, "will reinstall");
5899
+ line("Modified upstream + edited locally", plan.conflicts, opts.force ? "will overwrite (--force)" : "local edits kept unless you confirm or pass --force");
5900
+ line("Deleted upstream", plan.deleted);
5901
+ line("Already current", plan.rebaseline, "lock hash refreshed, nothing reinstalled");
5902
+ line("New in catalog", plan.available, "not installed");
5903
+ line("Installed but not tracked", plan.untracked, "not in skills-lock.json");
5904
+ line("Edited locally", plan.localEdits, "unchanged upstream, left as is");
5905
+ if (plan.unchanged.length > 0) console.log(` ${DIM$2}Up to date: ${plan.unchanged.length}${RESET$2}`);
5906
+ for (const source of plan.failedSources) console.log(` ${DIM$2}✗ Could not read ${sanitizeMetadata(source)} — its skills were not checked${RESET$2}`);
5907
+ console.log();
5908
+ }
5790
5909
  function formatSourceInput(sourceUrl, ref) {
5791
5910
  if (!ref) return sourceUrl;
5792
5911
  return `${sourceUrl}#${ref}`;
@@ -5830,6 +5949,8 @@ function parseUpdateOptions(args) {
5830
5949
  for (const arg of args) if (arg === "-g" || arg === "--global") options.global = true;
5831
5950
  else if (arg === "-p" || arg === "--project") options.project = true;
5832
5951
  else if (arg === "-y" || arg === "--yes") options.yes = true;
5952
+ else if (arg === "--dry-run") options.dryRun = true;
5953
+ else if (arg === "--force") options.force = true;
5833
5954
  else if (!arg.startsWith("-")) positional.push(arg);
5834
5955
  if (positional.length > 0) options.skills = positional;
5835
5956
  return options;
@@ -5950,7 +6071,9 @@ async function checkAndPromptForDeletions(source, allLockedForSource, lockSkills
5950
6071
  console.log();
5951
6072
  console.log(`${DIM$1}Warning:${RESET$1} The following skills from ${DIM$1}${source}${RESET$1} appear to have been deleted upstream:`);
5952
6073
  for (const s of deletedSkills) console.log(` ${DIM$1}•${RESET$1} ${s}`);
5953
- if (options.yes || !process.stdin.isTTY) console.log(`${DIM$1}Skipping deletion in non-interactive mode.${RESET$1}`);
6074
+ const isNonInteractive = options.yes || !process.stdin.isTTY;
6075
+ if (options.dryRun) console.log(`${DIM$1}Dry run — nothing removed.${RESET$1}`);
6076
+ else if (isNonInteractive) console.log(`${DIM$1}Skipping deletion in non-interactive mode.${RESET$1}`);
5954
6077
  else {
5955
6078
  const confirmed = await ye({ message: `Would you like to remove the local copies of these deleted skills?` });
5956
6079
  if (confirmed && !pD(confirmed)) for (const s of deletedSkills) {
@@ -6086,6 +6209,16 @@ async function updateGlobalSkills(options = {}) {
6086
6209
  }
6087
6210
  console.log(`${TEXT$1}Found ${updates.length} global update(s)${RESET$1}`);
6088
6211
  console.log();
6212
+ if (options.dryRun) {
6213
+ for (const update of updates) console.log(` ${TEXT$1}•${RESET$1} ${sanitizeMetadata(update.name)} ${DIM$1}would update${RESET$1}`);
6214
+ printSkippedSkills(skipped);
6215
+ console.log(`${DIM$1}Dry run — nothing was changed.${RESET$1}`);
6216
+ return {
6217
+ successCount,
6218
+ failCount,
6219
+ checkedCount
6220
+ };
6221
+ }
6089
6222
  for (const update of updates) {
6090
6223
  const safeName = sanitizeMetadata(update.name);
6091
6224
  console.log(`${TEXT$1}Updating ${safeName}...${RESET$1}`);
@@ -6125,36 +6258,7 @@ async function updateGlobalSkills(options = {}) {
6125
6258
  checkedCount
6126
6259
  };
6127
6260
  }
6128
- async function updateProjectSkills(options = {}) {
6129
- const projectSkills = await getProjectSkillsForUpdate(options.skills);
6130
- let successCount = 0;
6131
- let failCount = 0;
6132
- let upToDateCount = 0;
6133
- if (projectSkills.length === 0) {
6134
- if (!options.skills) {
6135
- console.log(`${DIM$1}No project skills to update.${RESET$1}`);
6136
- console.log(`${DIM$1}Install project skills with${RESET$1} ${TEXT$1}npx pruff-skills add <skill>${RESET$1}`);
6137
- }
6138
- return {
6139
- successCount,
6140
- failCount,
6141
- foundCount: 0,
6142
- upToDateCount
6143
- };
6144
- }
6145
- const updatable = projectSkills.filter((s) => s.entry.skillPath);
6146
- const legacy = projectSkills.filter((s) => !s.entry.skillPath);
6147
- if (updatable.length === 0) {
6148
- console.log(`${DIM$1}No project skills can be updated in place.${RESET$1}`);
6149
- printLegacyProjectSkills(legacy);
6150
- return {
6151
- successCount,
6152
- failCount,
6153
- foundCount: projectSkills.length,
6154
- upToDateCount
6155
- };
6156
- }
6157
- const cwd = process.cwd();
6261
+ function printUpdateTargets(cwd) {
6158
6262
  const targetAgentNames = [];
6159
6263
  let hasUniversal = false;
6160
6264
  for (const [type, config] of Object.entries(agents)) if (isUniversalAgent(type)) {
@@ -6167,95 +6271,253 @@ async function updateProjectSkills(options = {}) {
6167
6271
  if (hasUniversal) targetParts.push("Universal");
6168
6272
  targetParts.push(...targetAgentNames);
6169
6273
  if (targetParts.length > 0) console.log(`${TEXT$1}Updating for: ${targetParts.join(", ")}${RESET$1}`);
6170
- console.log(`${TEXT$1}Refreshing ${updatable.length} skill(s)...${RESET$1}`);
6171
- console.log();
6274
+ }
6275
+ async function safeHash(hash, dir) {
6276
+ try {
6277
+ return await hash(dir) || "";
6278
+ } catch {
6279
+ return "";
6280
+ }
6281
+ }
6282
+ async function buildProjectPlan(updatable, localLock, cwd, skillFilter) {
6283
+ const plan = emptyPlan();
6284
+ const hashes = /* @__PURE__ */ new Map();
6172
6285
  const bySource = /* @__PURE__ */ new Map();
6173
6286
  for (const skill of updatable) {
6174
- const source = skill.entry.source;
6175
- const existing = bySource.get(source) || [];
6287
+ const existing = bySource.get(skill.entry.source) || [];
6176
6288
  existing.push(skill);
6177
- bySource.set(source, existing);
6178
- }
6179
- const localLock = await readLocalLock();
6180
- const cliEntry = join(__dirname$2, "..", "bin", "cli.mjs");
6181
- if (!existsSync(cliEntry)) {
6182
- console.log(`${DIM$1}✗ CLI entrypoint not found at ${cliEntry}${RESET$1}`);
6183
- return {
6184
- successCount,
6185
- failCount: updatable.length,
6186
- foundCount: projectSkills.length,
6187
- upToDateCount
6188
- };
6289
+ bySource.set(skill.entry.source, existing);
6189
6290
  }
6190
6291
  for (const [source, skillsForSource] of bySource) {
6191
6292
  const firstEntry = skillsForSource[0].entry;
6192
6293
  const parsedSource = parseSource(firstEntry.source);
6193
- const sourceUrl = parsedSource.url;
6194
6294
  const ref = firstEntry.ref ?? parsedSource.ref;
6195
- const allLockedForSource = Object.entries(localLock.skills).filter(([_, entry]) => entry.source === source).map(([name, _]) => name);
6196
6295
  let tempDir = null;
6197
- let deletedSkills = [];
6198
- const unchangedSkills = /* @__PURE__ */ new Set();
6199
6296
  try {
6200
- tempDir = await cloneRepo$1(sourceUrl, ref);
6201
- const discoveredPaths = (await discoverSkills(tempDir)).map((s) => {
6202
- return join(relative(tempDir, s.path), "SKILL.md").split(sep).join("/");
6203
- });
6204
- deletedSkills = await checkAndPromptForDeletions(source, allLockedForSource, localLock.skills, false, options, discoveredPaths);
6297
+ setStep(`update:check:${source}`);
6298
+ tempDir = await cloneRepo$1(parsedSource.url, ref);
6299
+ const discovered = await discoverSkills(tempDir);
6300
+ const byPath = new Map(discovered.map((s) => [join(relative(tempDir, s.path), "SKILL.md").split(sep).join("/"), s]));
6205
6301
  for (const skill of skillsForSource) {
6206
- const { skillPath, computedHash } = skill.entry;
6207
- if (!skillPath || !computedHash || deletedSkills.includes(skill.name)) continue;
6208
- const currentHash = await computeSkillFolderHash(join(tempDir, dirname(skillPath))).catch(() => "");
6209
- if (currentHash && currentHash === computedHash) unchangedSkills.add(skill.name);
6302
+ const entry = {
6303
+ name: skill.name,
6304
+ source
6305
+ };
6306
+ const found = byPath.get(skill.entry.skillPath);
6307
+ if (!found) {
6308
+ plan.deleted.push(entry);
6309
+ continue;
6310
+ }
6311
+ const [catalogHash, catalogContentHash, diskHash] = await Promise.all([
6312
+ safeHash(computeSkillFolderHash, found.path),
6313
+ safeHash(computeSkillContentHash, found.path),
6314
+ hashInstalledSkill(skill.name, cwd)
6315
+ ]);
6316
+ hashes.set(skill.name, {
6317
+ catalogHash,
6318
+ catalogContentHash,
6319
+ diskHash
6320
+ });
6321
+ addClassified(plan, entry, classifyTrackedSkill({
6322
+ lockHash: skill.entry.computedHash,
6323
+ installedHash: skill.entry.installedHash,
6324
+ catalogHash,
6325
+ catalogContentHash,
6326
+ diskHash
6327
+ }));
6328
+ }
6329
+ for (const skill of discovered) {
6330
+ if (skill.name in localLock.skills) continue;
6331
+ if (!matchesSkillFilter(skill.name, skillFilter)) continue;
6332
+ const entry = {
6333
+ name: skill.name,
6334
+ source
6335
+ };
6336
+ if (findInstalledSkillDirs(skill.name, cwd).length > 0) plan.untracked.push(entry);
6337
+ else plan.available.push(entry);
6210
6338
  }
6211
6339
  } catch (error) {
6212
6340
  const reason = error instanceof Error ? error.message : String(error);
6213
- console.log(`${DIM$1}✗ Failed to check for deleted skills from ${source}: ${sanitizeMetadata(reason)}${RESET$1}`);
6341
+ console.log(`${DIM$1}✗ Failed to read ${source}: ${sanitizeMetadata(reason)}${RESET$1}`);
6342
+ plan.failedSources.push(source);
6214
6343
  } finally {
6215
6344
  if (tempDir) await cleanupTempDir(tempDir);
6216
6345
  }
6217
- const remainingSkills = skillsForSource.filter((s) => !deletedSkills.includes(s.name));
6218
- for (const skill of remainingSkills) {
6219
- const safeName = sanitizeMetadata(skill.name);
6220
- if (unchangedSkills.has(skill.name)) {
6221
- upToDateCount++;
6222
- console.log(`${TEXT$1}✓${RESET$1} ${safeName} ${DIM$1}is up to date${RESET$1}`);
6223
- continue;
6224
- }
6225
- console.log(`${TEXT$1}Updating ${safeName}...${RESET$1}`);
6226
- const installUrl = formatSourceInput(skill.entry.source, skill.entry.ref);
6227
- if (spawnSync(process.execPath, [
6228
- cliEntry,
6229
- "add",
6230
- installUrl,
6231
- "--skill",
6232
- skill.name,
6233
- "-y"
6234
- ], {
6235
- stdio: [
6236
- "inherit",
6237
- "pipe",
6238
- "pipe"
6239
- ],
6240
- encoding: "utf-8",
6241
- shell: process.platform === "win32"
6242
- }).status === 0) {
6243
- successCount++;
6244
- console.log(` ${TEXT$1}✓${RESET$1} Updated ${safeName}`);
6245
- } else {
6246
- failCount++;
6247
- console.log(` ${DIM$1}✗ Failed to update ${safeName}${RESET$1}`);
6248
- }
6249
- }
6250
6346
  }
6251
- printLegacyProjectSkills(legacy);
6252
6347
  return {
6253
- successCount,
6254
- failCount,
6255
- foundCount: projectSkills.length,
6256
- upToDateCount
6348
+ plan,
6349
+ hashes
6257
6350
  };
6258
6351
  }
6352
+ async function selectEntries(message, entries, hint) {
6353
+ if (entries.length === 0) return [];
6354
+ const picked = await searchMultiselect({
6355
+ message,
6356
+ items: entries.map((e) => ({
6357
+ value: e,
6358
+ label: e.name,
6359
+ hint: hint(e)
6360
+ }))
6361
+ });
6362
+ if (typeof picked === "symbol") return [];
6363
+ return picked;
6364
+ }
6365
+ async function applyDeletions(deleted, interactive, cwd) {
6366
+ if (deleted.length === 0) return;
6367
+ if (!interactive) {
6368
+ console.log(`${DIM$1}Deleted-upstream skills kept (non-interactive).${RESET$1}`);
6369
+ return;
6370
+ }
6371
+ const confirmed = await ye({ message: `Remove the local copies of ${deleted.length} skill(s) deleted upstream?` });
6372
+ if (!confirmed || pD(confirmed)) return;
6373
+ for (const { name } of deleted) {
6374
+ if (findInstalledSkillDirs(name, cwd).length > 0) {
6375
+ console.log(`${DIM$1}Removing${RESET$1} ${sanitizeMetadata(name)}...`);
6376
+ await removeCommand([name], {
6377
+ yes: true,
6378
+ global: false
6379
+ });
6380
+ }
6381
+ await removeSkillFromLocalLock(name, cwd);
6382
+ }
6383
+ }
6384
+ async function refreshLockHashes(plan, hashes, cwd) {
6385
+ const lock = await readLocalLock(cwd);
6386
+ const edited = new Set(plan.localEdits.map((e) => e.name));
6387
+ let changed = false;
6388
+ for (const { name } of plan.rebaseline) {
6389
+ const h = hashes.get(name);
6390
+ const entry = lock.skills[name];
6391
+ if (!h || !entry || !h.diskHash) continue;
6392
+ lock.skills[name] = {
6393
+ ...entry,
6394
+ computedHash: h.catalogHash,
6395
+ installedHash: h.diskHash
6396
+ };
6397
+ changed = true;
6398
+ }
6399
+ for (const { name } of plan.unchanged) {
6400
+ const h = hashes.get(name);
6401
+ const entry = lock.skills[name];
6402
+ if (!h || !entry || entry.installedHash || edited.has(name) || !h.diskHash) continue;
6403
+ lock.skills[name] = {
6404
+ ...entry,
6405
+ installedHash: h.diskHash
6406
+ };
6407
+ changed = true;
6408
+ }
6409
+ if (changed) await writeLocalLock(lock, cwd);
6410
+ }
6411
+ function installFromSource(cliEntry, installUrl, skillNames) {
6412
+ return spawnSync(process.execPath, [
6413
+ cliEntry,
6414
+ "add",
6415
+ installUrl,
6416
+ "--skill",
6417
+ ...skillNames,
6418
+ "-y"
6419
+ ], {
6420
+ stdio: [
6421
+ "inherit",
6422
+ "pipe",
6423
+ "pipe"
6424
+ ],
6425
+ encoding: "utf-8",
6426
+ shell: process.platform === "win32"
6427
+ }).status === 0;
6428
+ }
6429
+ async function updateProjectSkills(options = {}) {
6430
+ const result = {
6431
+ successCount: 0,
6432
+ failCount: 0,
6433
+ foundCount: 0,
6434
+ upToDateCount: 0,
6435
+ installedCount: 0,
6436
+ rebaselinedCount: 0
6437
+ };
6438
+ const projectSkills = await getProjectSkillsForUpdate(options.skills);
6439
+ result.foundCount = projectSkills.length;
6440
+ if (projectSkills.length === 0) {
6441
+ if (!options.skills) {
6442
+ console.log(`${DIM$1}No project skills to update.${RESET$1}`);
6443
+ console.log(`${DIM$1}Install project skills with${RESET$1} ${TEXT$1}npx pruff-skills add <skill>${RESET$1}`);
6444
+ }
6445
+ return result;
6446
+ }
6447
+ const updatable = projectSkills.filter((s) => s.entry.skillPath);
6448
+ const legacy = projectSkills.filter((s) => !s.entry.skillPath);
6449
+ if (updatable.length === 0) {
6450
+ console.log(`${DIM$1}No project skills can be updated in place.${RESET$1}`);
6451
+ printLegacyProjectSkills(legacy);
6452
+ return result;
6453
+ }
6454
+ const cwd = process.cwd();
6455
+ printUpdateTargets(cwd);
6456
+ console.log(`${TEXT$1}Comparing ${updatable.length} skill(s) with their sources...${RESET$1}`);
6457
+ console.log();
6458
+ const { plan, hashes } = await buildProjectPlan(updatable, await readLocalLock(cwd), cwd, options.skills);
6459
+ printPlan(plan, { force: options.force });
6460
+ printLegacyProjectSkills(legacy);
6461
+ result.upToDateCount = plan.unchanged.length;
6462
+ if (options.dryRun) {
6463
+ console.log(`${DIM$1}Dry run — nothing was changed.${RESET$1}`);
6464
+ return result;
6465
+ }
6466
+ const interactive = !options.yes && process.stdin.isTTY === true;
6467
+ await applyDeletions(plan.deleted, interactive, cwd);
6468
+ await refreshLockHashes(plan, hashes, cwd);
6469
+ result.rebaselinedCount = plan.rebaseline.length;
6470
+ result.upToDateCount += plan.rebaseline.length;
6471
+ let overwrite = [];
6472
+ if (options.force) overwrite = plan.conflicts;
6473
+ else if (interactive) overwrite = await selectEntries("Changed upstream and edited locally. Overwrite which? (none selected keeps your edits)", plan.conflicts, () => "local edits will be lost");
6474
+ let toInstall = [];
6475
+ if (interactive) toInstall = await selectEntries("Install new skills from your sources?", [...plan.available, ...plan.untracked], (e) => plan.untracked.includes(e) ? "already on disk, untracked — overwrites it" : e.source);
6476
+ else if (plan.available.length > 0) console.log(`${DIM$1}To install new skills: ${TEXT$1}npx pruff-skills add <source> --skill <name>${RESET$1}`);
6477
+ const toUpdate = [
6478
+ ...plan.modified,
6479
+ ...plan.missing,
6480
+ ...overwrite
6481
+ ];
6482
+ if (toUpdate.length === 0 && toInstall.length === 0) return result;
6483
+ const cliEntry = join(__dirname$2, "..", "bin", "cli.mjs");
6484
+ if (!existsSync(cliEntry)) {
6485
+ console.log(`${DIM$1}✗ CLI entrypoint not found at ${cliEntry}${RESET$1}`);
6486
+ result.failCount += toUpdate.length + toInstall.length;
6487
+ return result;
6488
+ }
6489
+ const refBySource = new Map(updatable.map((s) => [s.entry.source, s.entry.ref]));
6490
+ const batches = /* @__PURE__ */ new Map();
6491
+ for (const e of [...toUpdate, ...toInstall]) {
6492
+ const batch = batches.get(e.source) || [];
6493
+ batch.push(e);
6494
+ batches.set(e.source, batch);
6495
+ }
6496
+ for (const [source, entries] of batches) {
6497
+ const label = entries.map((e) => sanitizeMetadata(e.name)).join(", ");
6498
+ console.log(`${TEXT$1}Installing from ${sanitizeMetadata(source)}: ${label}${RESET$1}`);
6499
+ setStep(`update:install:${source}`);
6500
+ installFromSource(cliEntry, formatSourceInput(source, refBySource.get(source)), entries.map((e) => e.name));
6501
+ }
6502
+ const after = await readLocalLock(cwd);
6503
+ const isNew = new Set(toInstall.map((e) => e.name));
6504
+ for (const e of [...toUpdate, ...toInstall]) {
6505
+ const safeName = sanitizeMetadata(e.name);
6506
+ const entry = after.skills[e.name];
6507
+ const expected = hashes.get(e.name)?.catalogHash;
6508
+ if (!(isNew.has(e.name) ? Boolean(entry) : Boolean(entry && (!expected || entry.computedHash === expected)))) {
6509
+ result.failCount++;
6510
+ console.log(` ${DIM$1}✗ Failed to ${isNew.has(e.name) ? "install" : "update"} ${safeName}${RESET$1}`);
6511
+ } else if (isNew.has(e.name)) {
6512
+ result.installedCount++;
6513
+ console.log(` ${TEXT$1}✓${RESET$1} Installed ${safeName}`);
6514
+ } else {
6515
+ result.successCount++;
6516
+ console.log(` ${TEXT$1}✓${RESET$1} Updated ${safeName}`);
6517
+ }
6518
+ }
6519
+ return result;
6520
+ }
6259
6521
  function printLegacyProjectSkills(legacy) {
6260
6522
  if (legacy.length === 0) return;
6261
6523
  console.log();
@@ -6269,14 +6531,18 @@ function printLegacyProjectSkills(legacy) {
6269
6531
  async function syncAgentsForScope(isGlobal, options) {
6270
6532
  const result = await updateAgents({
6271
6533
  global: isGlobal,
6272
- names: options.skills
6534
+ names: options.skills,
6535
+ dryRun: options.dryRun
6273
6536
  });
6274
6537
  const label = isGlobal ? "global" : "project";
6275
6538
  if (result.catalogUnavailable) {
6276
6539
  console.log(` ${DIM$1}✗ Could not read the subagent catalog — skipped ${label} subagents${RESET$1}`);
6277
6540
  return result;
6278
6541
  }
6279
- for (const name of result.updated) console.log(` ${TEXT$1}✓${RESET$1} Synced ${sanitizeMetadata(name)}`);
6542
+ for (const name of result.updated) {
6543
+ const verb = options.dryRun ? "Would sync" : "Synced";
6544
+ console.log(` ${TEXT$1}✓${RESET$1} ${verb} ${sanitizeMetadata(name)}`);
6545
+ }
6280
6546
  for (const name of result.unchanged) console.log(` ${TEXT$1}✓${RESET$1} ${sanitizeMetadata(name)} ${DIM$1}is up to date${RESET$1}`);
6281
6547
  for (const name of result.failed) console.log(` ${DIM$1}✗ Failed to sync ${sanitizeMetadata(name)}${RESET$1}`);
6282
6548
  if (result.localOnly.length > 0) {
@@ -6301,6 +6567,8 @@ async function runUpdate(args = []) {
6301
6567
  let totalFail = 0;
6302
6568
  let totalFound = 0;
6303
6569
  let totalUpToDate = 0;
6570
+ let totalInstalled = 0;
6571
+ let totalRebaselined = 0;
6304
6572
  if (scope === "global" || scope === "both") {
6305
6573
  if (scope === "both" && !options.skills) console.log(`${BOLD$1}Global Skills${RESET$1}`);
6306
6574
  setStep("update:global-skills");
@@ -6313,15 +6581,17 @@ async function runUpdate(args = []) {
6313
6581
  if (scope === "project" || scope === "both") {
6314
6582
  if (scope === "both" && !options.skills) console.log(`${BOLD$1}Project Skills${RESET$1}`);
6315
6583
  setStep("update:project-skills");
6316
- const { successCount, failCount, foundCount, upToDateCount } = await updateProjectSkills(options);
6584
+ const { successCount, failCount, foundCount, upToDateCount, installedCount, rebaselinedCount } = await updateProjectSkills(options);
6317
6585
  totalUpToDate += upToDateCount;
6586
+ totalInstalled += installedCount;
6587
+ totalRebaselined += rebaselinedCount;
6318
6588
  totalSuccess += successCount;
6319
6589
  totalFail += failCount;
6320
6590
  totalFound += foundCount;
6321
6591
  }
6322
6592
  console.log();
6323
6593
  console.log(`${BOLD$1}Subagents${RESET$1}`);
6324
- console.log(`${DIM$1}ALL installed subagents will be updated and synced with the repo — local edits are overwritten.${RESET$1}`);
6594
+ console.log(options.dryRun ? `${DIM$1}Dry run — subagents are compared, not written.${RESET$1}` : `${DIM$1}ALL installed subagents will be updated and synced with the repo — local edits are overwritten.${RESET$1}`);
6325
6595
  setStep("update:sync-agents");
6326
6596
  const agentScopes = scope === "both" ? [true, false] : scope === "global" ? [true] : [false];
6327
6597
  let agentsSynced = 0;
@@ -6340,8 +6610,13 @@ async function runUpdate(args = []) {
6340
6610
  totalUpToDate += agentsUnchanged;
6341
6611
  if (options.skills && totalFound === 0) console.log(`${DIM$1}No installed skills found matching: ${options.skills.join(", ")}${RESET$1}`);
6342
6612
  console.log();
6343
- if (totalSuccess > 0) console.log(`${TEXT$1}✓ Updated ${totalSuccess} item(s)${RESET$1}`);
6344
- else if (totalUpToDate > 0 && totalFail === 0) console.log(`${TEXT$1}✓ Everything is up to date${RESET$1}`);
6613
+ const parts = [];
6614
+ if (totalSuccess > 0) parts.push(`${totalSuccess} ${options.dryRun ? "to update" : "updated"}`);
6615
+ if (totalInstalled > 0) parts.push(`${totalInstalled} installed`);
6616
+ if (totalRebaselined > 0) parts.push(`${totalRebaselined} already current (lock refreshed)`);
6617
+ if (totalUpToDate - totalRebaselined > 0) parts.push(`${totalUpToDate - totalRebaselined} up to date`);
6618
+ if (totalSuccess + totalInstalled === 0 && totalFail === 0 && totalUpToDate > 0) console.log(`${TEXT$1}✓ Everything is up to date${RESET$1} ${DIM$1}(${parts.join(" · ")})${RESET$1}`);
6619
+ else if (parts.length > 0) console.log(`${TEXT$1}✓ ${parts.join(" · ")}${RESET$1}`);
6345
6620
  if (totalFail > 0) {
6346
6621
  recordFailure(`Failed to update ${totalFail} item(s)`);
6347
6622
  console.log(`${DIM$1}Failed to update ${totalFail} item(s)${RESET$1}`);
@@ -10236,14 +10511,17 @@ ${BOLD}Troubleshooting:${RESET}
10236
10511
  add, update, rules add, agents add or onboarding fails.
10237
10512
 
10238
10513
  ${BOLD}Updates:${RESET}
10239
- update [skills...] Update skills to latest versions (alias: upgrade)
10240
- Also syncs ALL installed subagents with the catalog,
10241
- overwriting local edits.
10514
+ update [skills...] Compare installed skills with their source, print a plan
10515
+ (modified / new / deleted / up to date) and apply only what
10516
+ changed (alias: upgrade). Also syncs ALL installed subagents
10517
+ with the catalog, overwriting local edits.
10242
10518
 
10243
10519
  ${BOLD}Update Options:${RESET}
10244
10520
  -g, --global Update global skills only
10245
10521
  -p, --project Update project skills only
10246
10522
  -y, --yes Skip scope prompt (auto-detect: project if in a project, else global)
10523
+ --dry-run Print the plan and change nothing
10524
+ --force Overwrite skills edited locally that also changed upstream
10247
10525
 
10248
10526
  ${BOLD}Project:${RESET}
10249
10527
  restore Restore skills from skills-lock.json
@@ -10307,6 +10585,7 @@ ${BOLD}Examples:${RESET}
10307
10585
  ${DIM}$${RESET} pruff-skills update
10308
10586
  ${DIM}$${RESET} pruff-skills update <skill> ${DIM}# update a single skill${RESET}
10309
10587
  ${DIM}$${RESET} pruff-skills update -g ${DIM}# update global skills only${RESET}
10588
+ ${DIM}$${RESET} pruff-skills update --dry-run ${DIM}# show what would change${RESET}
10310
10589
  ${DIM}$${RESET} pruff-skills restore ${DIM}# restore from skills-lock.json${RESET}
10311
10590
  ${DIM}$${RESET} pruff-skills init my-skill
10312
10591
  ${DIM}$${RESET} pruff-skills sync ${DIM}# sync from node_modules${RESET}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pruff-skills",
3
- "version": "1.11.0",
3
+ "version": "1.12.0",
4
4
  "description": "The official Pruff agent skills CLI",
5
5
  "type": "module",
6
6
  "bin": {