pruff-skills 1.11.0 → 1.12.1

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 +457 -172
  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";
@@ -30,6 +30,7 @@ const PROBE_TIMEOUT_MS = 2500;
30
30
  const probeCache = /* @__PURE__ */ new Map();
31
31
  let currentStep = "start";
32
32
  let failure = null;
33
+ let usageError = false;
33
34
  const tail = [];
34
35
  function setStep(step) {
35
36
  currentStep = step;
@@ -40,9 +41,13 @@ function recordFailure(message) {
40
41
  message
41
42
  };
42
43
  }
44
+ function markUsageError() {
45
+ usageError = true;
46
+ }
43
47
  function resetDiagnosticsState() {
44
48
  currentStep = "start";
45
49
  failure = null;
50
+ usageError = false;
46
51
  tail.length = 0;
47
52
  }
48
53
  function recordedFailure() {
@@ -333,7 +338,7 @@ async function withDiagnostics(command, args, fn) {
333
338
  };
334
339
  let printed = false;
335
340
  const emit = (exitCode, error) => {
336
- if (printed) return;
341
+ if (printed || usageError) return;
337
342
  printed = true;
338
343
  const message = error ?? recordedFailure()?.message ?? lastErrorLine(tail);
339
344
  restore();
@@ -1041,6 +1046,14 @@ async function getPluginGroupings(basePath) {
1041
1046
  } catch {}
1042
1047
  return groupings;
1043
1048
  }
1049
+ const AGENTS_DIR$1 = ".agents";
1050
+ const SKILLS_SUBDIR = "skills";
1051
+ const INSTALL_EXCLUDED_FILES = new Set(["metadata.json"]);
1052
+ const INSTALL_EXCLUDED_DIRS = new Set([
1053
+ ".git",
1054
+ "__pycache__",
1055
+ "__pypackages__"
1056
+ ]);
1044
1057
  const LOCAL_LOCK_FILE = "skills-lock.json";
1045
1058
  const CURRENT_VERSION$1 = 1;
1046
1059
  function getLocalLockPath(cwd) {
@@ -1077,8 +1090,14 @@ async function writeLocalLock(lock, cwd) {
1077
1090
  await writeFile(lockPath, JSON.stringify(sorted, null, 2) + "\n", "utf-8");
1078
1091
  }
1079
1092
  async function computeSkillFolderHash(skillDir) {
1093
+ return hashSkillFiles(skillDir, false);
1094
+ }
1095
+ async function computeSkillContentHash(skillDir) {
1096
+ return hashSkillFiles(skillDir, true);
1097
+ }
1098
+ async function hashSkillFiles(skillDir, installedOnly) {
1080
1099
  const files = [];
1081
- await collectFiles(skillDir, skillDir, files);
1100
+ await collectFiles(skillDir, skillDir, files, installedOnly);
1082
1101
  files.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
1083
1102
  const hash = createHash("sha256");
1084
1103
  for (const file of files) {
@@ -1087,14 +1106,16 @@ async function computeSkillFolderHash(skillDir) {
1087
1106
  }
1088
1107
  return hash.digest("hex");
1089
1108
  }
1090
- async function collectFiles(baseDir, currentDir, results) {
1109
+ async function collectFiles(baseDir, currentDir, results, installedOnly) {
1091
1110
  const entries = await readdir(currentDir, { withFileTypes: true });
1092
1111
  await Promise.all(entries.map(async (entry) => {
1093
1112
  const fullPath = join(currentDir, entry.name);
1094
1113
  if (entry.isDirectory()) {
1095
1114
  if (entry.name === ".git" || entry.name === "node_modules") return;
1096
- await collectFiles(baseDir, fullPath, results);
1115
+ if (installedOnly && INSTALL_EXCLUDED_DIRS.has(entry.name)) return;
1116
+ await collectFiles(baseDir, fullPath, results, installedOnly);
1097
1117
  } else if (entry.isFile()) {
1118
+ if (installedOnly && INSTALL_EXCLUDED_FILES.has(entry.name)) return;
1098
1119
  const content = await readFile(fullPath);
1099
1120
  const relativePath = relative(baseDir, fullPath).split("\\").join("/");
1100
1121
  results.push({
@@ -1109,6 +1130,13 @@ async function addSkillToLocalLock(skillName, entry, cwd) {
1109
1130
  lock.skills[skillName] = entry;
1110
1131
  await writeLocalLock(lock, cwd);
1111
1132
  }
1133
+ async function removeSkillFromLocalLock(skillName, cwd) {
1134
+ const lock = await readLocalLock(cwd);
1135
+ if (!(skillName in lock.skills)) return false;
1136
+ delete lock.skills[skillName];
1137
+ await writeLocalLock(lock, cwd);
1138
+ return true;
1139
+ }
1112
1140
  function createEmptyLocalLock() {
1113
1141
  return {
1114
1142
  version: CURRENT_VERSION$1,
@@ -1972,8 +2000,6 @@ function getNonUniversalAgents() {
1972
2000
  function isUniversalAgent(type) {
1973
2001
  return agents[type].skillsDir === ".agents/skills";
1974
2002
  }
1975
- const AGENTS_DIR$1 = ".agents";
1976
- const SKILLS_SUBDIR = "skills";
1977
2003
  function sanitizeName(name) {
1978
2004
  return name.toLowerCase().replace(/[^a-z0-9._]+/g, "-").replace(/^[.\-]+|[.\-]+$/g, "").substring(0, 255) || "unnamed-skill";
1979
2005
  }
@@ -2152,15 +2178,9 @@ async function installSkillForAgent(skill, agentType, options = {}) {
2152
2178
  };
2153
2179
  }
2154
2180
  }
2155
- const EXCLUDE_FILES$1 = new Set(["metadata.json"]);
2156
- const EXCLUDE_DIRS$1 = new Set([
2157
- ".git",
2158
- "__pycache__",
2159
- "__pypackages__"
2160
- ]);
2161
2181
  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;
2182
+ if (INSTALL_EXCLUDED_FILES.has(name)) return true;
2183
+ if (isDirectory && INSTALL_EXCLUDED_DIRS.has(name)) return true;
2164
2184
  return false;
2165
2185
  };
2166
2186
  async function copyDirectory(src, dest) {
@@ -3113,6 +3133,33 @@ async function dismissPrompt(promptKey) {
3113
3133
  lock.dismissed[promptKey] = true;
3114
3134
  await writeSkillLock(lock);
3115
3135
  }
3136
+ const DIVERGENT_HASH = "divergent";
3137
+ function findInstalledSkillDirs(skillName, cwd) {
3138
+ const candidates = [getCanonicalPath(skillName, { cwd })];
3139
+ for (const [type, config] of Object.entries(agents)) {
3140
+ if (isUniversalAgent(type)) continue;
3141
+ candidates.push(join(cwd, config.skillsDir, sanitizeName(skillName)));
3142
+ }
3143
+ const seen = /* @__PURE__ */ new Set();
3144
+ const dirs = [];
3145
+ for (const dir of candidates) {
3146
+ if (!existsSync(join(dir, "SKILL.md"))) continue;
3147
+ let real = dir;
3148
+ try {
3149
+ real = realpathSync(dir);
3150
+ } catch {}
3151
+ if (seen.has(real)) continue;
3152
+ seen.add(real);
3153
+ dirs.push(dir);
3154
+ }
3155
+ return dirs;
3156
+ }
3157
+ async function hashInstalledSkill(skillName, cwd) {
3158
+ const dirs = findInstalledSkillDirs(skillName, cwd);
3159
+ if (dirs.length === 0) return null;
3160
+ const hashes = new Set(await Promise.all(dirs.map((dir) => computeSkillContentHash(dir))));
3161
+ return hashes.size === 1 ? [...hashes][0] : DIVERGENT_HASH;
3162
+ }
3116
3163
  const DOWNLOAD_BASE_URL = process.env.PRUFF_SKILLS_DOWNLOAD_URL || "";
3117
3164
  const FETCH_TIMEOUT = 1e4;
3118
3165
  function toSkillSlug(name) {
@@ -3791,6 +3838,7 @@ async function runAdd(args, options = {}) {
3791
3838
  installTipShown = true;
3792
3839
  };
3793
3840
  if (!source) {
3841
+ markUsageError();
3794
3842
  console.log();
3795
3843
  console.log(import_picocolors.default.bgRed(import_picocolors.default.white(import_picocolors.default.bold(" ERROR "))) + " " + import_picocolors.default.red("Missing required argument: source"));
3796
3844
  console.log();
@@ -4255,12 +4303,14 @@ async function runAdd(args, options = {}) {
4255
4303
  if (successfulSkillNames.has(skillDisplayName)) try {
4256
4304
  const computedHash = blobResult && "snapshotHash" in skill ? skill.snapshotHash : await computeSkillFolderHash(skill.path);
4257
4305
  const skillPathValue = skillFiles[skill.name];
4306
+ const installedHash = await hashInstalledSkill(skill.name, cwd);
4258
4307
  await addSkillToLocalLock(skill.name, {
4259
4308
  source: lockSource || parsed.url,
4260
4309
  ref: parsed.ref,
4261
4310
  sourceType: parsed.type,
4262
4311
  ...skillPathValue && { skillPath: skillPathValue },
4263
- computedHash
4312
+ computedHash,
4313
+ ...installedHash && installedHash !== DIVERGENT_HASH && { installedHash }
4264
4314
  }, cwd);
4265
4315
  } catch {}
4266
4316
  }
@@ -4554,7 +4604,7 @@ async function updateAgents(options = {}) {
4554
4604
  result.unchanged.push(name);
4555
4605
  continue;
4556
4606
  }
4557
- await writeFile(dest, content);
4607
+ if (!options.dryRun) await writeFile(dest, content);
4558
4608
  result.updated.push(name);
4559
4609
  }
4560
4610
  return result;
@@ -4756,10 +4806,10 @@ async function runRules(args) {
4756
4806
  await addRules(names, options);
4757
4807
  }
4758
4808
  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";
4809
+ const RESET$4 = "\x1B[0m";
4810
+ const BOLD$4 = "\x1B[1m";
4811
+ const DIM$4 = "\x1B[38;5;102m";
4812
+ const TEXT$3 = "\x1B[38;5;145m";
4763
4813
  const CYAN$1 = "\x1B[36m";
4764
4814
  const GREEN = "\x1B[32m";
4765
4815
  const MAGENTA = "\x1B[35m";
@@ -4850,10 +4900,10 @@ const MOVE_UP = (n) => `\x1b[${n}A`;
4850
4900
  const MOVE_TO_COL = (n) => `\x1b[${n}G`;
4851
4901
  function rowBadge(skill) {
4852
4902
  const parts = [];
4853
- if (skill.category) parts.push(`${CYAN$1}${skill.category}${RESET$3}`);
4854
- if (skill.installed) parts.push(`${GREEN}installed${RESET$3}`);
4903
+ if (skill.category) parts.push(`${CYAN$1}${skill.category}${RESET$4}`);
4904
+ if (skill.installed) parts.push(`${GREEN}installed${RESET$4}`);
4855
4905
  if (parts.length === 0) return "";
4856
- return ` ${DIM$3}[${RESET$3}${parts.join(`${DIM$3} · ${RESET$3}`)}${DIM$3}]${RESET$3}`;
4906
+ return ` ${DIM$4}[${RESET$4}${parts.join(`${DIM$4} · ${RESET$4}`)}${DIM$4}]${RESET$4}`;
4857
4907
  }
4858
4908
  async function runSearchPrompt(kind, alreadyPicked = /* @__PURE__ */ new Set(), initialQuery = "") {
4859
4909
  let results = [];
@@ -4871,33 +4921,33 @@ async function runSearchPrompt(kind, alreadyPicked = /* @__PURE__ */ new Set(),
4871
4921
  if (lastRenderedLines > 0) process.stdout.write(MOVE_UP(lastRenderedLines) + MOVE_TO_COL(1));
4872
4922
  process.stdout.write(CLEAR_DOWN);
4873
4923
  const lines = [];
4874
- const cursor = `${BOLD$3}_${RESET$3}`;
4875
- lines.push(`${TEXT$2}Search ${noun}:${RESET$3} ${query}${cursor}`);
4924
+ const cursor = `${BOLD$4}_${RESET$4}`;
4925
+ lines.push(`${TEXT$3}Search ${noun}:${RESET$4} ${query}${cursor}`);
4876
4926
  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}`);
4927
+ if (loading && results.length === 0) lines.push(`${DIM$4}Loading catalog…${RESET$4}`);
4928
+ else if (results.length === 0) lines.push(`${DIM$4}${query ? `No ${noun} match "${query}"` : `No ${noun} in catalog`}${RESET$4}`);
4879
4929
  else {
4880
4930
  const maxVisible = 12;
4881
4931
  const start = Math.max(0, Math.min(selectedIndex - maxVisible + 1, results.length - maxVisible));
4882
4932
  const from = Math.max(0, start);
4883
4933
  const visible = results.slice(from, from + maxVisible);
4884
- if (from > 0) lines.push(` ${DIM$3}↑ ${from} more${RESET$3}`);
4934
+ if (from > 0) lines.push(` ${DIM$4}↑ ${from} more${RESET$4}`);
4885
4935
  for (let i = 0; i < visible.length; i++) {
4886
4936
  const idx = from + i;
4887
4937
  const skill = visible[i];
4888
4938
  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}` : "";
4939
+ const arrow = isSelected ? `${BOLD$4}>${RESET$4}` : " ";
4940
+ const box = picked.has(skill.name) ? `${GREEN}${CHECKED}${RESET$4}` : `${DIM$4}${UNCHECKED}${RESET$4}`;
4941
+ const name = isSelected ? `${BOLD$4}${skill.name}${RESET$4}` : `${TEXT$3}${skill.name}${RESET$4}`;
4942
+ const loadingIndicator = loading && idx === 0 ? ` ${DIM$4}...${RESET$4}` : "";
4893
4943
  lines.push(` ${arrow} ${box} ${name}${rowBadge(skill)}${loadingIndicator}`);
4894
4944
  }
4895
4945
  const remaining = results.length - (from + visible.length);
4896
- if (remaining > 0) lines.push(` ${DIM$3}↓ ${remaining} more${RESET$3}`);
4946
+ if (remaining > 0) lines.push(` ${DIM$4}↓ ${remaining} more${RESET$4}`);
4897
4947
  }
4898
4948
  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}`);
4949
+ const count = picked.size > 0 ? ` ${GREEN}(${picked.size} selected)${RESET$4}` : "";
4950
+ lines.push(`${DIM$4}up/down navigate | space toggle | enter done${count}${DIM$4} | esc back${RESET$4}`);
4901
4951
  for (const line of lines) process.stdout.write(line + "\n");
4902
4952
  lastRenderedLines = lines.length;
4903
4953
  }
@@ -4982,22 +5032,22 @@ async function runSearchPrompt(kind, alreadyPicked = /* @__PURE__ */ new Set(),
4982
5032
  async function runFind(args) {
4983
5033
  const query = args.join(" ");
4984
5034
  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}`;
5035
+ const agentTip = `${DIM$4}Tip: if running in a coding agent, follow these steps:${RESET$4}
5036
+ ${DIM$4} 1) npx pruff-skills find [query]${RESET$4}
5037
+ ${DIM$4} 2) npx pruff-skills add <skill>${RESET$4}`;
4988
5038
  if (query) {
4989
5039
  const results = await searchSkillsAPI(query);
4990
5040
  String(results.length);
4991
5041
  if (results.length === 0) {
4992
- console.log(`${DIM$3}Nothing in the catalog matches "${query}"${RESET$3}`);
5042
+ console.log(`${DIM$4}Nothing in the catalog matches "${query}"${RESET$4}`);
4993
5043
  return;
4994
5044
  }
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>`);
5045
+ 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
5046
  console.log();
4997
5047
  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}`);
5048
+ const kindBadge = skill.kind === "agent" ? ` ${MAGENTA}[subagent]${RESET$4}` : skill.kind === "rule" ? ` ${BLUE$1}[rule]${RESET$4}` : ` ${CYAN$1}[skill]${RESET$4}`;
5049
+ console.log(`${TEXT$3}${skill.name}${RESET$4}${kindBadge}${rowBadge(skill)}`);
5050
+ if (skill.description) console.log(`${DIM$4}└ ${skill.description}${RESET$4}`);
5001
5051
  console.log();
5002
5052
  }
5003
5053
  return;
@@ -5005,7 +5055,7 @@ ${DIM$3} 2) npx pruff-skills add <skill>${RESET$3}`;
5005
5055
  if (isNonInteractive || await isRunningInAgent()) {
5006
5056
  console.log(agentTip);
5007
5057
  console.log();
5008
- console.log(`${DIM$3}Usage: npx pruff-skills find <query>${RESET$3}`);
5058
+ console.log(`${DIM$4}Usage: npx pruff-skills find <query>${RESET$4}`);
5009
5059
  return;
5010
5060
  }
5011
5061
  const all = await searchSkillsAPI("");
@@ -5030,7 +5080,7 @@ ${DIM$3} 2) npx pruff-skills add <skill>${RESET$3}`;
5030
5080
  const pickedByKind = new Map(KINDS.map((k) => [k.value, /* @__PURE__ */ new Set()]));
5031
5081
  const totalPicked = () => [...pickedByKind.values()].reduce((n, set) => n + set.size, 0);
5032
5082
  const cancelled = () => {
5033
- console.log(`${DIM$3}Search cancelled${RESET$3}`);
5083
+ console.log(`${DIM$4}Search cancelled${RESET$4}`);
5034
5084
  console.log();
5035
5085
  };
5036
5086
  for (;;) {
@@ -5061,10 +5111,10 @@ ${DIM$3} 2) npx pruff-skills add <skill>${RESET$3}`;
5061
5111
  for (const kind of KINDS) {
5062
5112
  const names = [...pickedByKind.get(kind.value)];
5063
5113
  if (names.length === 0) continue;
5064
- console.log(`${TEXT$2}${kind.label}${RESET$3} ${DIM$3}(${names.length})${RESET$3}`);
5114
+ console.log(`${TEXT$3}${kind.label}${RESET$4} ${DIM$4}(${names.length})${RESET$4}`);
5065
5115
  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}`);
5116
+ const note = all.find((s) => s.kind === kind.value && s.name === name)?.installed ? ` ${DIM$4}— already installed, will reinstall${RESET$4}` : "";
5117
+ console.log(` ${DIM$4}·${RESET$4} ${name}${note}`);
5068
5118
  }
5069
5119
  }
5070
5120
  console.log();
@@ -5091,7 +5141,7 @@ ${DIM$3} 2) npx pruff-skills add <skill>${RESET$3}`;
5091
5141
  console.log();
5092
5142
  if (skills.length > 0) {
5093
5143
  const pkg = CATALOG_SOURCE;
5094
- console.log(`${TEXT$2}Installing ${BOLD$3}${skills.join(", ")}${RESET$3} from ${DIM$3}${pkg}${RESET$3}...`);
5144
+ console.log(`${TEXT$3}Installing ${BOLD$4}${skills.join(", ")}${RESET$4} from ${DIM$4}${pkg}${RESET$4}...`);
5095
5145
  console.log();
5096
5146
  const { source, options } = parseAddOptions([
5097
5147
  pkg,
@@ -5103,7 +5153,7 @@ ${DIM$3} 2) npx pruff-skills add <skill>${RESET$3}`;
5103
5153
  if (agents.length > 0) await addAgents(agents, {});
5104
5154
  if (rules.length > 0) await addRules(rules, {});
5105
5155
  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}`);
5156
+ console.log(`${DIM$4}Installed.${RESET$4} ${TEXT$3}npx pruff-skills list${RESET$4} ${DIM$4}shows what's installed.${RESET$4}`);
5107
5157
  console.log();
5108
5158
  }
5109
5159
  const isCancelled = (value) => typeof value === "symbol";
@@ -5452,9 +5502,9 @@ async function runInstallFromLock(args) {
5452
5502
  }
5453
5503
  }
5454
5504
  }
5455
- const RESET$2 = "\x1B[0m";
5456
- const BOLD$2 = "\x1B[1m";
5457
- const DIM$2 = "\x1B[38;5;102m";
5505
+ const RESET$3 = "\x1B[0m";
5506
+ const BOLD$3 = "\x1B[1m";
5507
+ const DIM$3 = "\x1B[38;5;102m";
5458
5508
  const CYAN = "\x1B[36m";
5459
5509
  const YELLOW = "\x1B[33m";
5460
5510
  const LIST_JSON_SCHEMA_VERSION = 1;
@@ -5491,8 +5541,8 @@ async function runList(args) {
5491
5541
  const validAgents = Object.keys(agents);
5492
5542
  const invalidAgents = options.agent.filter((a) => !validAgents.includes(a));
5493
5543
  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}`);
5544
+ console.log(`${YELLOW}Invalid agents: ${invalidAgents.join(", ")}${RESET$3}`);
5545
+ console.log(`${DIM$3}Valid agents: ${validAgents.join(", ")}${RESET$3}`);
5496
5546
  process.exit(1);
5497
5547
  }
5498
5548
  agentFilter = options.agent;
@@ -5518,21 +5568,21 @@ async function runList(args) {
5518
5568
  const cwd = process.cwd();
5519
5569
  const scopeLabel = scope ? "Global" : "Project";
5520
5570
  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}`);
5571
+ console.log(`${DIM$3}No ${scopeLabel.toLowerCase()} skills found.${RESET$3}`);
5572
+ if (scope) console.log(`${DIM$3}Try listing project skills without -g${RESET$3}`);
5573
+ else console.log(`${DIM$3}Try listing global skills with -g${RESET$3}`);
5524
5574
  return;
5525
5575
  }
5526
5576
  function printSkill(skill, indent = false, maxNameLength = 0, maxPathLength = 0) {
5527
5577
  const prefix = indent ? " " : "";
5528
5578
  const shortPath = shortenPath(skill.canonicalPath, cwd);
5529
5579
  const agentNames = skill.agents.map((a) => agents[a].displayName);
5530
- const agentInfo = skill.agents.length > 0 ? formatList(agentNames) : `${YELLOW}not linked${RESET$2}`;
5580
+ const agentInfo = skill.agents.length > 0 ? formatList(agentNames) : `${YELLOW}not linked${RESET$3}`;
5531
5581
  const paddedName = sanitizeMetadata(skill.name).padEnd(maxNameLength);
5532
5582
  const paddedPath = shortPath.padEnd(maxPathLength);
5533
- console.log(`${prefix}${CYAN}${paddedName}${RESET$2} ${DIM$2}${paddedPath}${RESET$2} ${DIM$2}Agents:${RESET$2} ${agentInfo}`);
5583
+ console.log(`${prefix}${CYAN}${paddedName}${RESET$3} ${DIM$3}${paddedPath}${RESET$3} ${DIM$3}Agents:${RESET$3} ${agentInfo}`);
5534
5584
  }
5535
- console.log(`${BOLD$2}${scopeLabel} Skills${RESET$2}`);
5585
+ console.log(`${BOLD$3}${scopeLabel} Skills${RESET$3}`);
5536
5586
  console.log();
5537
5587
  const groupedSkills = {};
5538
5588
  const ungroupedSkills = [];
@@ -5548,7 +5598,7 @@ async function runList(args) {
5548
5598
  const sortedGroups = Object.keys(groupedSkills).sort();
5549
5599
  for (const group of sortedGroups) {
5550
5600
  const title = group.split("-").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
5551
- console.log(`${BOLD$2}${title}${RESET$2}`);
5601
+ console.log(`${BOLD$3}${title}${RESET$3}`);
5552
5602
  const skills = groupedSkills[group];
5553
5603
  if (skills) {
5554
5604
  let maxNameLength = 0;
@@ -5564,7 +5614,7 @@ async function runList(args) {
5564
5614
  console.log();
5565
5615
  }
5566
5616
  if (ungroupedSkills.length > 0) {
5567
- console.log(`${BOLD$2}General${RESET$2}`);
5617
+ console.log(`${BOLD$3}General${RESET$3}`);
5568
5618
  let maxNameLength = 0;
5569
5619
  let maxPathLength = 0;
5570
5620
  for (const skill of ungroupedSkills) {
@@ -5787,6 +5837,81 @@ function parseRemoveOptions(args) {
5787
5837
  options
5788
5838
  };
5789
5839
  }
5840
+ const RESET$2 = "\x1B[0m";
5841
+ const BOLD$2 = "\x1B[1m";
5842
+ const DIM$2 = "\x1B[38;5;102m";
5843
+ const TEXT$2 = "\x1B[38;5;145m";
5844
+ function classifyTrackedSkill(h) {
5845
+ if (h.diskHash === null) return {
5846
+ status: "missing",
5847
+ locallyEdited: false
5848
+ };
5849
+ const divergent = h.diskHash === DIVERGENT_HASH;
5850
+ if (h.catalogHash && h.catalogHash === h.lockHash) {
5851
+ const installed = h.installedHash ?? h.catalogContentHash;
5852
+ return {
5853
+ status: "unchanged",
5854
+ locallyEdited: divergent || h.diskHash !== installed
5855
+ };
5856
+ }
5857
+ if (h.catalogContentHash && h.diskHash === h.catalogContentHash) return {
5858
+ status: "rebaseline",
5859
+ locallyEdited: false
5860
+ };
5861
+ const installed = h.installedHash ?? h.lockHash;
5862
+ return {
5863
+ status: "modified",
5864
+ locallyEdited: divergent || h.diskHash !== installed
5865
+ };
5866
+ }
5867
+ function emptyPlan() {
5868
+ return {
5869
+ modified: [],
5870
+ missing: [],
5871
+ rebaseline: [],
5872
+ unchanged: [],
5873
+ conflicts: [],
5874
+ localEdits: [],
5875
+ deleted: [],
5876
+ available: [],
5877
+ untracked: [],
5878
+ failedSources: []
5879
+ };
5880
+ }
5881
+ function addClassified(plan, entry, c) {
5882
+ if (c.status === "unchanged") {
5883
+ plan.unchanged.push(entry);
5884
+ if (c.locallyEdited) plan.localEdits.push(entry);
5885
+ return;
5886
+ }
5887
+ if (c.status === "modified" && c.locallyEdited) {
5888
+ plan.conflicts.push(entry);
5889
+ return;
5890
+ }
5891
+ plan[c.status].push(entry);
5892
+ }
5893
+ function names(entries) {
5894
+ return entries.map((e) => sanitizeMetadata(e.name)).join(", ");
5895
+ }
5896
+ function line(label, entries, note) {
5897
+ if (entries.length === 0) return;
5898
+ const suffix = note ? ` ${DIM$2}— ${note}${RESET$2}` : "";
5899
+ console.log(` ${TEXT$2}${label} (${entries.length}):${RESET$2} ${names(entries)}${suffix}`);
5900
+ }
5901
+ function printPlan(plan, opts = {}) {
5902
+ console.log(`${BOLD$2}Plan${RESET$2}`);
5903
+ line("Modified upstream", plan.modified, "will update");
5904
+ line("Missing on disk", plan.missing, "will reinstall");
5905
+ line("Modified upstream + edited locally", plan.conflicts, opts.force ? "will overwrite (--force)" : "local edits kept unless you confirm or pass --force");
5906
+ line("Deleted upstream", plan.deleted);
5907
+ line("Already current", plan.rebaseline, "lock hash refreshed, nothing reinstalled");
5908
+ line("New in catalog", plan.available, "not installed");
5909
+ line("Installed but not tracked", plan.untracked, "not in skills-lock.json");
5910
+ line("Edited locally", plan.localEdits, "unchanged upstream, left as is");
5911
+ if (plan.unchanged.length > 0) console.log(` ${DIM$2}Up to date: ${plan.unchanged.length}${RESET$2}`);
5912
+ for (const source of plan.failedSources) console.log(` ${DIM$2}✗ Could not read ${sanitizeMetadata(source)} — its skills were not checked${RESET$2}`);
5913
+ console.log();
5914
+ }
5790
5915
  function formatSourceInput(sourceUrl, ref) {
5791
5916
  if (!ref) return sourceUrl;
5792
5917
  return `${sourceUrl}#${ref}`;
@@ -5830,6 +5955,8 @@ function parseUpdateOptions(args) {
5830
5955
  for (const arg of args) if (arg === "-g" || arg === "--global") options.global = true;
5831
5956
  else if (arg === "-p" || arg === "--project") options.project = true;
5832
5957
  else if (arg === "-y" || arg === "--yes") options.yes = true;
5958
+ else if (arg === "--dry-run") options.dryRun = true;
5959
+ else if (arg === "--force") options.force = true;
5833
5960
  else if (!arg.startsWith("-")) positional.push(arg);
5834
5961
  if (positional.length > 0) options.skills = positional;
5835
5962
  return options;
@@ -5950,7 +6077,9 @@ async function checkAndPromptForDeletions(source, allLockedForSource, lockSkills
5950
6077
  console.log();
5951
6078
  console.log(`${DIM$1}Warning:${RESET$1} The following skills from ${DIM$1}${source}${RESET$1} appear to have been deleted upstream:`);
5952
6079
  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}`);
6080
+ const isNonInteractive = options.yes || !process.stdin.isTTY;
6081
+ if (options.dryRun) console.log(`${DIM$1}Dry run — nothing removed.${RESET$1}`);
6082
+ else if (isNonInteractive) console.log(`${DIM$1}Skipping deletion in non-interactive mode.${RESET$1}`);
5954
6083
  else {
5955
6084
  const confirmed = await ye({ message: `Would you like to remove the local copies of these deleted skills?` });
5956
6085
  if (confirmed && !pD(confirmed)) for (const s of deletedSkills) {
@@ -6086,6 +6215,16 @@ async function updateGlobalSkills(options = {}) {
6086
6215
  }
6087
6216
  console.log(`${TEXT$1}Found ${updates.length} global update(s)${RESET$1}`);
6088
6217
  console.log();
6218
+ if (options.dryRun) {
6219
+ for (const update of updates) console.log(` ${TEXT$1}•${RESET$1} ${sanitizeMetadata(update.name)} ${DIM$1}would update${RESET$1}`);
6220
+ printSkippedSkills(skipped);
6221
+ console.log(`${DIM$1}Dry run — nothing was changed.${RESET$1}`);
6222
+ return {
6223
+ successCount,
6224
+ failCount,
6225
+ checkedCount
6226
+ };
6227
+ }
6089
6228
  for (const update of updates) {
6090
6229
  const safeName = sanitizeMetadata(update.name);
6091
6230
  console.log(`${TEXT$1}Updating ${safeName}...${RESET$1}`);
@@ -6125,36 +6264,7 @@ async function updateGlobalSkills(options = {}) {
6125
6264
  checkedCount
6126
6265
  };
6127
6266
  }
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();
6267
+ function printUpdateTargets(cwd) {
6158
6268
  const targetAgentNames = [];
6159
6269
  let hasUniversal = false;
6160
6270
  for (const [type, config] of Object.entries(agents)) if (isUniversalAgent(type)) {
@@ -6167,95 +6277,253 @@ async function updateProjectSkills(options = {}) {
6167
6277
  if (hasUniversal) targetParts.push("Universal");
6168
6278
  targetParts.push(...targetAgentNames);
6169
6279
  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();
6280
+ }
6281
+ async function safeHash(hash, dir) {
6282
+ try {
6283
+ return await hash(dir) || "";
6284
+ } catch {
6285
+ return "";
6286
+ }
6287
+ }
6288
+ async function buildProjectPlan(updatable, localLock, cwd, skillFilter) {
6289
+ const plan = emptyPlan();
6290
+ const hashes = /* @__PURE__ */ new Map();
6172
6291
  const bySource = /* @__PURE__ */ new Map();
6173
6292
  for (const skill of updatable) {
6174
- const source = skill.entry.source;
6175
- const existing = bySource.get(source) || [];
6293
+ const existing = bySource.get(skill.entry.source) || [];
6176
6294
  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
- };
6295
+ bySource.set(skill.entry.source, existing);
6189
6296
  }
6190
6297
  for (const [source, skillsForSource] of bySource) {
6191
6298
  const firstEntry = skillsForSource[0].entry;
6192
6299
  const parsedSource = parseSource(firstEntry.source);
6193
- const sourceUrl = parsedSource.url;
6194
6300
  const ref = firstEntry.ref ?? parsedSource.ref;
6195
- const allLockedForSource = Object.entries(localLock.skills).filter(([_, entry]) => entry.source === source).map(([name, _]) => name);
6196
6301
  let tempDir = null;
6197
- let deletedSkills = [];
6198
- const unchangedSkills = /* @__PURE__ */ new Set();
6199
6302
  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);
6303
+ setStep(`update:check:${source}`);
6304
+ tempDir = await cloneRepo$1(parsedSource.url, ref);
6305
+ const discovered = await discoverSkills(tempDir);
6306
+ const byPath = new Map(discovered.map((s) => [join(relative(tempDir, s.path), "SKILL.md").split(sep).join("/"), s]));
6205
6307
  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);
6308
+ const entry = {
6309
+ name: skill.name,
6310
+ source
6311
+ };
6312
+ const found = byPath.get(skill.entry.skillPath);
6313
+ if (!found) {
6314
+ plan.deleted.push(entry);
6315
+ continue;
6316
+ }
6317
+ const [catalogHash, catalogContentHash, diskHash] = await Promise.all([
6318
+ safeHash(computeSkillFolderHash, found.path),
6319
+ safeHash(computeSkillContentHash, found.path),
6320
+ hashInstalledSkill(skill.name, cwd)
6321
+ ]);
6322
+ hashes.set(skill.name, {
6323
+ catalogHash,
6324
+ catalogContentHash,
6325
+ diskHash
6326
+ });
6327
+ addClassified(plan, entry, classifyTrackedSkill({
6328
+ lockHash: skill.entry.computedHash,
6329
+ installedHash: skill.entry.installedHash,
6330
+ catalogHash,
6331
+ catalogContentHash,
6332
+ diskHash
6333
+ }));
6334
+ }
6335
+ for (const skill of discovered) {
6336
+ if (skill.name in localLock.skills) continue;
6337
+ if (!matchesSkillFilter(skill.name, skillFilter)) continue;
6338
+ const entry = {
6339
+ name: skill.name,
6340
+ source
6341
+ };
6342
+ if (findInstalledSkillDirs(skill.name, cwd).length > 0) plan.untracked.push(entry);
6343
+ else plan.available.push(entry);
6210
6344
  }
6211
6345
  } catch (error) {
6212
6346
  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}`);
6347
+ console.log(`${DIM$1}✗ Failed to read ${source}: ${sanitizeMetadata(reason)}${RESET$1}`);
6348
+ plan.failedSources.push(source);
6214
6349
  } finally {
6215
6350
  if (tempDir) await cleanupTempDir(tempDir);
6216
6351
  }
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
6352
  }
6251
- printLegacyProjectSkills(legacy);
6252
6353
  return {
6253
- successCount,
6254
- failCount,
6255
- foundCount: projectSkills.length,
6256
- upToDateCount
6354
+ plan,
6355
+ hashes
6257
6356
  };
6258
6357
  }
6358
+ async function selectEntries(message, entries, hint) {
6359
+ if (entries.length === 0) return [];
6360
+ const picked = await searchMultiselect({
6361
+ message,
6362
+ items: entries.map((e) => ({
6363
+ value: e,
6364
+ label: e.name,
6365
+ hint: hint(e)
6366
+ }))
6367
+ });
6368
+ if (typeof picked === "symbol") return [];
6369
+ return picked;
6370
+ }
6371
+ async function applyDeletions(deleted, interactive, cwd) {
6372
+ if (deleted.length === 0) return;
6373
+ if (!interactive) {
6374
+ console.log(`${DIM$1}Deleted-upstream skills kept (non-interactive).${RESET$1}`);
6375
+ return;
6376
+ }
6377
+ const confirmed = await ye({ message: `Remove the local copies of ${deleted.length} skill(s) deleted upstream?` });
6378
+ if (!confirmed || pD(confirmed)) return;
6379
+ for (const { name } of deleted) {
6380
+ if (findInstalledSkillDirs(name, cwd).length > 0) {
6381
+ console.log(`${DIM$1}Removing${RESET$1} ${sanitizeMetadata(name)}...`);
6382
+ await removeCommand([name], {
6383
+ yes: true,
6384
+ global: false
6385
+ });
6386
+ }
6387
+ await removeSkillFromLocalLock(name, cwd);
6388
+ }
6389
+ }
6390
+ async function refreshLockHashes(plan, hashes, cwd) {
6391
+ const lock = await readLocalLock(cwd);
6392
+ const edited = new Set(plan.localEdits.map((e) => e.name));
6393
+ let changed = false;
6394
+ for (const { name } of plan.rebaseline) {
6395
+ const h = hashes.get(name);
6396
+ const entry = lock.skills[name];
6397
+ if (!h || !entry || !h.diskHash) continue;
6398
+ lock.skills[name] = {
6399
+ ...entry,
6400
+ computedHash: h.catalogHash,
6401
+ installedHash: h.diskHash
6402
+ };
6403
+ changed = true;
6404
+ }
6405
+ for (const { name } of plan.unchanged) {
6406
+ const h = hashes.get(name);
6407
+ const entry = lock.skills[name];
6408
+ if (!h || !entry || entry.installedHash || edited.has(name) || !h.diskHash) continue;
6409
+ lock.skills[name] = {
6410
+ ...entry,
6411
+ installedHash: h.diskHash
6412
+ };
6413
+ changed = true;
6414
+ }
6415
+ if (changed) await writeLocalLock(lock, cwd);
6416
+ }
6417
+ function installFromSource(cliEntry, installUrl, skillNames) {
6418
+ return spawnSync(process.execPath, [
6419
+ cliEntry,
6420
+ "add",
6421
+ installUrl,
6422
+ "--skill",
6423
+ ...skillNames,
6424
+ "-y"
6425
+ ], {
6426
+ stdio: [
6427
+ "inherit",
6428
+ "pipe",
6429
+ "pipe"
6430
+ ],
6431
+ encoding: "utf-8",
6432
+ shell: process.platform === "win32"
6433
+ }).status === 0;
6434
+ }
6435
+ async function updateProjectSkills(options = {}) {
6436
+ const result = {
6437
+ successCount: 0,
6438
+ failCount: 0,
6439
+ foundCount: 0,
6440
+ upToDateCount: 0,
6441
+ installedCount: 0,
6442
+ rebaselinedCount: 0
6443
+ };
6444
+ const projectSkills = await getProjectSkillsForUpdate(options.skills);
6445
+ result.foundCount = projectSkills.length;
6446
+ if (projectSkills.length === 0) {
6447
+ if (!options.skills) {
6448
+ console.log(`${DIM$1}No project skills to update.${RESET$1}`);
6449
+ console.log(`${DIM$1}Install project skills with${RESET$1} ${TEXT$1}npx pruff-skills add <skill>${RESET$1}`);
6450
+ }
6451
+ return result;
6452
+ }
6453
+ const updatable = projectSkills.filter((s) => s.entry.skillPath);
6454
+ const legacy = projectSkills.filter((s) => !s.entry.skillPath);
6455
+ if (updatable.length === 0) {
6456
+ console.log(`${DIM$1}No project skills can be updated in place.${RESET$1}`);
6457
+ printLegacyProjectSkills(legacy);
6458
+ return result;
6459
+ }
6460
+ const cwd = process.cwd();
6461
+ printUpdateTargets(cwd);
6462
+ console.log(`${TEXT$1}Comparing ${updatable.length} skill(s) with their sources...${RESET$1}`);
6463
+ console.log();
6464
+ const { plan, hashes } = await buildProjectPlan(updatable, await readLocalLock(cwd), cwd, options.skills);
6465
+ printPlan(plan, { force: options.force });
6466
+ printLegacyProjectSkills(legacy);
6467
+ result.upToDateCount = plan.unchanged.length;
6468
+ if (options.dryRun) {
6469
+ console.log(`${DIM$1}Dry run — nothing was changed.${RESET$1}`);
6470
+ return result;
6471
+ }
6472
+ const interactive = !options.yes && process.stdin.isTTY === true;
6473
+ await applyDeletions(plan.deleted, interactive, cwd);
6474
+ await refreshLockHashes(plan, hashes, cwd);
6475
+ result.rebaselinedCount = plan.rebaseline.length;
6476
+ result.upToDateCount += plan.rebaseline.length;
6477
+ let overwrite = [];
6478
+ if (options.force) overwrite = plan.conflicts;
6479
+ 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");
6480
+ let toInstall = [];
6481
+ 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);
6482
+ 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}`);
6483
+ const toUpdate = [
6484
+ ...plan.modified,
6485
+ ...plan.missing,
6486
+ ...overwrite
6487
+ ];
6488
+ if (toUpdate.length === 0 && toInstall.length === 0) return result;
6489
+ const cliEntry = join(__dirname$2, "..", "bin", "cli.mjs");
6490
+ if (!existsSync(cliEntry)) {
6491
+ console.log(`${DIM$1}✗ CLI entrypoint not found at ${cliEntry}${RESET$1}`);
6492
+ result.failCount += toUpdate.length + toInstall.length;
6493
+ return result;
6494
+ }
6495
+ const refBySource = new Map(updatable.map((s) => [s.entry.source, s.entry.ref]));
6496
+ const batches = /* @__PURE__ */ new Map();
6497
+ for (const e of [...toUpdate, ...toInstall]) {
6498
+ const batch = batches.get(e.source) || [];
6499
+ batch.push(e);
6500
+ batches.set(e.source, batch);
6501
+ }
6502
+ for (const [source, entries] of batches) {
6503
+ const label = entries.map((e) => sanitizeMetadata(e.name)).join(", ");
6504
+ console.log(`${TEXT$1}Installing from ${sanitizeMetadata(source)}: ${label}${RESET$1}`);
6505
+ setStep(`update:install:${source}`);
6506
+ installFromSource(cliEntry, formatSourceInput(source, refBySource.get(source)), entries.map((e) => e.name));
6507
+ }
6508
+ const after = await readLocalLock(cwd);
6509
+ const isNew = new Set(toInstall.map((e) => e.name));
6510
+ for (const e of [...toUpdate, ...toInstall]) {
6511
+ const safeName = sanitizeMetadata(e.name);
6512
+ const entry = after.skills[e.name];
6513
+ const expected = hashes.get(e.name)?.catalogHash;
6514
+ if (!(isNew.has(e.name) ? Boolean(entry) : Boolean(entry && (!expected || entry.computedHash === expected)))) {
6515
+ result.failCount++;
6516
+ console.log(` ${DIM$1}✗ Failed to ${isNew.has(e.name) ? "install" : "update"} ${safeName}${RESET$1}`);
6517
+ } else if (isNew.has(e.name)) {
6518
+ result.installedCount++;
6519
+ console.log(` ${TEXT$1}✓${RESET$1} Installed ${safeName}`);
6520
+ } else {
6521
+ result.successCount++;
6522
+ console.log(` ${TEXT$1}✓${RESET$1} Updated ${safeName}`);
6523
+ }
6524
+ }
6525
+ return result;
6526
+ }
6259
6527
  function printLegacyProjectSkills(legacy) {
6260
6528
  if (legacy.length === 0) return;
6261
6529
  console.log();
@@ -6269,14 +6537,18 @@ function printLegacyProjectSkills(legacy) {
6269
6537
  async function syncAgentsForScope(isGlobal, options) {
6270
6538
  const result = await updateAgents({
6271
6539
  global: isGlobal,
6272
- names: options.skills
6540
+ names: options.skills,
6541
+ dryRun: options.dryRun
6273
6542
  });
6274
6543
  const label = isGlobal ? "global" : "project";
6275
6544
  if (result.catalogUnavailable) {
6276
6545
  console.log(` ${DIM$1}✗ Could not read the subagent catalog — skipped ${label} subagents${RESET$1}`);
6277
6546
  return result;
6278
6547
  }
6279
- for (const name of result.updated) console.log(` ${TEXT$1}✓${RESET$1} Synced ${sanitizeMetadata(name)}`);
6548
+ for (const name of result.updated) {
6549
+ const verb = options.dryRun ? "Would sync" : "Synced";
6550
+ console.log(` ${TEXT$1}✓${RESET$1} ${verb} ${sanitizeMetadata(name)}`);
6551
+ }
6280
6552
  for (const name of result.unchanged) console.log(` ${TEXT$1}✓${RESET$1} ${sanitizeMetadata(name)} ${DIM$1}is up to date${RESET$1}`);
6281
6553
  for (const name of result.failed) console.log(` ${DIM$1}✗ Failed to sync ${sanitizeMetadata(name)}${RESET$1}`);
6282
6554
  if (result.localOnly.length > 0) {
@@ -6301,6 +6573,8 @@ async function runUpdate(args = []) {
6301
6573
  let totalFail = 0;
6302
6574
  let totalFound = 0;
6303
6575
  let totalUpToDate = 0;
6576
+ let totalInstalled = 0;
6577
+ let totalRebaselined = 0;
6304
6578
  if (scope === "global" || scope === "both") {
6305
6579
  if (scope === "both" && !options.skills) console.log(`${BOLD$1}Global Skills${RESET$1}`);
6306
6580
  setStep("update:global-skills");
@@ -6313,15 +6587,17 @@ async function runUpdate(args = []) {
6313
6587
  if (scope === "project" || scope === "both") {
6314
6588
  if (scope === "both" && !options.skills) console.log(`${BOLD$1}Project Skills${RESET$1}`);
6315
6589
  setStep("update:project-skills");
6316
- const { successCount, failCount, foundCount, upToDateCount } = await updateProjectSkills(options);
6590
+ const { successCount, failCount, foundCount, upToDateCount, installedCount, rebaselinedCount } = await updateProjectSkills(options);
6317
6591
  totalUpToDate += upToDateCount;
6592
+ totalInstalled += installedCount;
6593
+ totalRebaselined += rebaselinedCount;
6318
6594
  totalSuccess += successCount;
6319
6595
  totalFail += failCount;
6320
6596
  totalFound += foundCount;
6321
6597
  }
6322
6598
  console.log();
6323
6599
  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}`);
6600
+ 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
6601
  setStep("update:sync-agents");
6326
6602
  const agentScopes = scope === "both" ? [true, false] : scope === "global" ? [true] : [false];
6327
6603
  let agentsSynced = 0;
@@ -6340,8 +6616,13 @@ async function runUpdate(args = []) {
6340
6616
  totalUpToDate += agentsUnchanged;
6341
6617
  if (options.skills && totalFound === 0) console.log(`${DIM$1}No installed skills found matching: ${options.skills.join(", ")}${RESET$1}`);
6342
6618
  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}`);
6619
+ const parts = [];
6620
+ if (totalSuccess > 0) parts.push(`${totalSuccess} ${options.dryRun ? "to update" : "updated"}`);
6621
+ if (totalInstalled > 0) parts.push(`${totalInstalled} installed`);
6622
+ if (totalRebaselined > 0) parts.push(`${totalRebaselined} already current (lock refreshed)`);
6623
+ if (totalUpToDate - totalRebaselined > 0) parts.push(`${totalUpToDate - totalRebaselined} up to date`);
6624
+ 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}`);
6625
+ else if (parts.length > 0) console.log(`${TEXT$1}✓ ${parts.join(" · ")}${RESET$1}`);
6345
6626
  if (totalFail > 0) {
6346
6627
  recordFailure(`Failed to update ${totalFail} item(s)`);
6347
6628
  console.log(`${DIM$1}Failed to update ${totalFail} item(s)${RESET$1}`);
@@ -10236,14 +10517,17 @@ ${BOLD}Troubleshooting:${RESET}
10236
10517
  add, update, rules add, agents add or onboarding fails.
10237
10518
 
10238
10519
  ${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.
10520
+ update [skills...] Compare installed skills with their source, print a plan
10521
+ (modified / new / deleted / up to date) and apply only what
10522
+ changed (alias: upgrade). Also syncs ALL installed subagents
10523
+ with the catalog, overwriting local edits.
10242
10524
 
10243
10525
  ${BOLD}Update Options:${RESET}
10244
10526
  -g, --global Update global skills only
10245
10527
  -p, --project Update project skills only
10246
10528
  -y, --yes Skip scope prompt (auto-detect: project if in a project, else global)
10529
+ --dry-run Print the plan and change nothing
10530
+ --force Overwrite skills edited locally that also changed upstream
10247
10531
 
10248
10532
  ${BOLD}Project:${RESET}
10249
10533
  restore Restore skills from skills-lock.json
@@ -10307,6 +10591,7 @@ ${BOLD}Examples:${RESET}
10307
10591
  ${DIM}$${RESET} pruff-skills update
10308
10592
  ${DIM}$${RESET} pruff-skills update <skill> ${DIM}# update a single skill${RESET}
10309
10593
  ${DIM}$${RESET} pruff-skills update -g ${DIM}# update global skills only${RESET}
10594
+ ${DIM}$${RESET} pruff-skills update --dry-run ${DIM}# show what would change${RESET}
10310
10595
  ${DIM}$${RESET} pruff-skills restore ${DIM}# restore from skills-lock.json${RESET}
10311
10596
  ${DIM}$${RESET} pruff-skills init my-skill
10312
10597
  ${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.1",
4
4
  "description": "The official Pruff agent skills CLI",
5
5
  "type": "module",
6
6
  "bin": {