@pharaoh-so/mcp 0.3.16 → 0.3.17

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.
@@ -31,6 +31,15 @@ const BUNDLED_COMMANDS_DIR = join(PKG_ROOT, "commands");
31
31
  * get collision-safe distinctive names on this install path.
32
32
  */
33
33
  const SKILL_PREFIX = "pharaoh-";
34
+ /**
35
+ * Filename of the install manifest written alongside the installed skills.
36
+ * Records which skills we installed in the previous run so the next run can
37
+ * diff against current source and remove skills that were cut or renamed
38
+ * between versions. See `cleanupRemovedSkills` for the diff logic.
39
+ */
40
+ const MANIFEST_FILENAME = ".pharaoh-manifest.json";
41
+ /** Current manifest format version. Bump on breaking shape changes. */
42
+ const MANIFEST_VERSION = 1;
34
43
  // ── Detection ───────────────────────────────────────────────────────
35
44
  /**
36
45
  * Detect whether Claude Code is installed by checking for ~/.claude/.
@@ -83,12 +92,25 @@ function installClaudeCodePlugin(home = homedir()) {
83
92
  }
84
93
  // Install skills under the pharaoh- prefix for collision-safe names.
85
94
  const skillsDst = join(pluginDir, "skills");
95
+ // Read the previous install's manifest BEFORE copying fresh skills so
96
+ // the diff logic below sees the old state. Missing/corrupt manifest
97
+ // returns null → cleanupRemovedSkills becomes a no-op (first-run fallback).
98
+ const oldManifest = readPharaohManifest(skillsDst);
86
99
  const skillCount = installPrefixedSkills(BUNDLED_SKILLS_DIR, skillsDst, SKILL_PREFIX);
87
- // Migrate: delete any bare-name skill dirs left from prior installs
88
- // that used the dual bare+prefixed layout. Without this, users who
89
- // upgrade keep both copies and Claude Code's prompt-name dedupe picks
90
- // the wrong one.
100
+ // Legacy dual-layout cleanup: delete bare-name dirs matching CURRENT
101
+ // source skills. Still runs on every install because the manifest path
102
+ // doesn't know about bare-name stragglers for skills that are still in
103
+ // source only about cuts.
91
104
  cleanupBareNameSkills(BUNDLED_SKILLS_DIR, skillsDst);
105
+ // Manifest-based cut cleanup: for any skill that was in the previous
106
+ // install's manifest but is no longer in current source, remove both
107
+ // its bare-name and pharaoh-prefixed variants. Heals the token-burn
108
+ // from cut/renamed skills between versions (see #788).
109
+ cleanupRemovedSkills(BUNDLED_SKILLS_DIR, skillsDst, oldManifest);
110
+ // Stamp a fresh manifest reflecting exactly what's in current source.
111
+ // Written AFTER install + cleanup so it always matches on-disk state,
112
+ // even if a corrupt old manifest caused the diff step to skip.
113
+ writePharaohManifest(skillsDst, getSourceSkillNames(BUNDLED_SKILLS_DIR));
92
114
  // Copy .mcp.json
93
115
  const mcpSrc = join(PKG_ROOT, ".mcp.json");
94
116
  if (existsSync(mcpSrc)) {
@@ -150,6 +172,10 @@ function installPrefixedSkills(srcDir, dstDir, prefix) {
150
172
  * skills from other sources that happen to share the `pharaoh/skills/`
151
173
  * install path).
152
174
  *
175
+ * This is the LEGACY cleanup — it only heals the bare+prefixed dual
176
+ * layout for skills that are STILL in source. Skills cut between
177
+ * versions are handled by `cleanupRemovedSkills` via the manifest.
178
+ *
153
179
  * @param srcDir - Bundled source skills directory.
154
180
  * @param dstDir - Installed skills directory.
155
181
  * @returns Number of stale directories removed.
@@ -171,6 +197,123 @@ function cleanupBareNameSkills(srcDir, dstDir) {
171
197
  }
172
198
  return removed;
173
199
  }
200
+ /**
201
+ * Enumerate the bare source skill names by listing the source skills dir.
202
+ * Returns the names that `installPrefixedSkills` would copy (directory
203
+ * entries that contain a `SKILL.md`). Used by both the manifest writer
204
+ * and the cleanup diff logic so the two stay in lockstep.
205
+ *
206
+ * @param srcDir - Bundled source skills directory.
207
+ * @returns Sorted array of bare skill dir names.
208
+ */
209
+ function getSourceSkillNames(srcDir) {
210
+ if (!existsSync(srcDir))
211
+ return [];
212
+ const names = [];
213
+ for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
214
+ if (!entry.isDirectory())
215
+ continue;
216
+ if (!existsSync(join(srcDir, entry.name, "SKILL.md")))
217
+ continue;
218
+ names.push(entry.name);
219
+ }
220
+ return names.sort();
221
+ }
222
+ /**
223
+ * Read `.pharaoh-manifest.json` from the installed skills directory.
224
+ *
225
+ * Returns `null` when:
226
+ * - The file does not exist (first run after this fix shipped)
227
+ * - The file is corrupted or not valid JSON (treated as missing, caller
228
+ * will write a fresh one on the next install cycle)
229
+ *
230
+ * Corrupted-manifest handling is silent-but-logged: we don't want a bad
231
+ * manifest to crash the install, but we do want a warning on stderr so
232
+ * operators can spot persistent corruption.
233
+ *
234
+ * @param skillsDir - Installed skills directory.
235
+ * @returns The parsed manifest, or `null` if missing/invalid.
236
+ */
237
+ function readPharaohManifest(skillsDir) {
238
+ const manifestPath = join(skillsDir, MANIFEST_FILENAME);
239
+ if (!existsSync(manifestPath))
240
+ return null;
241
+ try {
242
+ const raw = readFileSync(manifestPath, "utf-8");
243
+ const parsed = JSON.parse(raw);
244
+ // Defensive shape check — a valid-JSON manifest with the wrong
245
+ // shape is as useful as no manifest, so we reject it and let the
246
+ // caller write a fresh one.
247
+ if (typeof parsed.version !== "number" ||
248
+ typeof parsed.installedAt !== "string" ||
249
+ !Array.isArray(parsed.skills) ||
250
+ !parsed.skills.every((s) => typeof s === "string")) {
251
+ process.stderr.write(`Pharaoh: ${MANIFEST_FILENAME} has unexpected shape — treating as missing and writing a fresh one.\n`);
252
+ return null;
253
+ }
254
+ return parsed;
255
+ }
256
+ catch {
257
+ process.stderr.write(`Pharaoh: ${MANIFEST_FILENAME} is not valid JSON — treating as missing and writing a fresh one.\n`);
258
+ return null;
259
+ }
260
+ }
261
+ /**
262
+ * Write a fresh manifest file listing the current install's bare skill
263
+ * names. Overwrites any existing manifest. The caller is responsible for
264
+ * ensuring the parent directory exists — `installPrefixedSkills` already
265
+ * does that as part of its own run, so writing the manifest after the
266
+ * install copy completes is safe.
267
+ *
268
+ * @param skillsDir - Installed skills directory.
269
+ * @param skills - Bare skill names present in the current source.
270
+ */
271
+ function writePharaohManifest(skillsDir, skills) {
272
+ const manifest = {
273
+ version: MANIFEST_VERSION,
274
+ installedAt: new Date().toISOString(),
275
+ skills: [...skills],
276
+ };
277
+ writeFileSync(join(skillsDir, MANIFEST_FILENAME), JSON.stringify(manifest, null, "\t"));
278
+ }
279
+ /**
280
+ * Diff-based cleanup of skills removed between versions.
281
+ *
282
+ * Reads the old manifest's skill list and removes any entry that's no
283
+ * longer in the current source. For each removed name, we delete BOTH
284
+ * the bare-name and `pharaoh-` prefixed variants — the dual-layout from
285
+ * the pre-prefix era means both can exist on the same machine for the
286
+ * same skill, and leaving either behind burns context on every session.
287
+ *
288
+ * The flagship `pharaoh` skill is exempt from deletion even if a corrupt
289
+ * manifest lists it as removed. Losing the flagship would break every
290
+ * skill slash command, and the exempt-name guard is the only thing
291
+ * preventing an accidental self-destruct.
292
+ *
293
+ * @param srcDir - Bundled source skills directory.
294
+ * @param dstDir - Installed skills directory.
295
+ * @param oldManifest - Manifest from the previous install, or `null` for
296
+ * first-run-after-fix (in which case this is a no-op).
297
+ * @returns Number of directories removed.
298
+ */
299
+ function cleanupRemovedSkills(srcDir, dstDir, oldManifest) {
300
+ if (!oldManifest || !existsSync(dstDir))
301
+ return 0;
302
+ const exemptName = SKILL_PREFIX.replace(/-$/, "");
303
+ const currentSkills = new Set(getSourceSkillNames(srcDir));
304
+ const removed = oldManifest.skills.filter((name) => !currentSkills.has(name) && name !== exemptName);
305
+ let deleted = 0;
306
+ for (const name of removed) {
307
+ for (const variant of [name, `${SKILL_PREFIX}${name}`]) {
308
+ const dir = join(dstDir, variant);
309
+ if (existsSync(dir)) {
310
+ rmSync(dir, { recursive: true, force: true });
311
+ deleted++;
312
+ }
313
+ }
314
+ }
315
+ return deleted;
316
+ }
174
317
  /**
175
318
  * Copy bundled slash command templates (`.md` files in the package's
176
319
  * `commands/` directory) to `~/.claude/commands/`, unconditionally
@@ -275,10 +418,18 @@ export function installSkills(home = homedir()) {
275
418
  if (!existsSync(targetDir)) {
276
419
  mkdirSync(targetDir, { recursive: true });
277
420
  }
421
+ // Read previous manifest before copying fresh skills (same rationale as
422
+ // the Claude Code path — diff the old state against current source so
423
+ // cut/renamed skills get cleaned up instead of lingering forever).
424
+ const oldManifest = readPharaohManifest(targetDir);
278
425
  // Prefix skills for collision safety (same rationale as Claude Code path).
279
426
  const count = installPrefixedSkills(BUNDLED_SKILLS_DIR, targetDir, SKILL_PREFIX);
280
- // Also clean up any bare-name leftovers from prior installs.
427
+ // Legacy dual-layout cleanup for bare-name stragglers of current skills.
281
428
  cleanupBareNameSkills(BUNDLED_SKILLS_DIR, targetDir);
429
+ // Manifest-based cut cleanup for skills removed between versions.
430
+ cleanupRemovedSkills(BUNDLED_SKILLS_DIR, targetDir, oldManifest);
431
+ // Stamp a fresh manifest reflecting current source.
432
+ writePharaohManifest(targetDir, getSourceSkillNames(BUNDLED_SKILLS_DIR));
282
433
  return count;
283
434
  }
284
435
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pharaoh-so/mcp",
3
3
  "mcpName": "so.pharaoh/pharaoh",
4
- "version": "0.3.16",
4
+ "version": "0.3.17",
5
5
  "description": "MCP proxy for Pharaoh — maps codebases into queryable knowledge graphs for AI agents. Enables Claude Code in headless environments (VPS, SSH, CI) via device flow auth.",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",