skillpass 0.1.0 → 0.2.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 +18 -5
  2. package/dist/cli.mjs +583 -44
  3. package/package.json +3 -3
package/README.md CHANGED
@@ -25,6 +25,8 @@ skillpass scan <path> [--json]
25
25
  skillpass report <slug>[@version] [--json]
26
26
  skillpass add <slug>[@version] [--target <tool> [--global] | --dir <path>] [--yes]
27
27
  skillpass remove <slug> [--target <tool> [--global] | --dir <path>]
28
+ skillpass update <slug>[@version] [--target <tool> [--global]] [--yes]
29
+ skillpass outdated
28
30
  skillpass list
29
31
  skillpass --version | --help
30
32
  ```
@@ -63,11 +65,22 @@ skills in one verified step.
63
65
 
64
66
  ### Manage
65
67
 
66
- `skillpass list` shows what is installed in the known skills areas.
67
- `skillpass remove <slug>` deletes an installed skill; it refuses anything that
68
- does not look like an installed skill, so a stray `--dir` can never wipe a
69
- real folder. `skillpass scan <path>` runs the same validator the directory
70
- uses on any local skill folder before you submit it.
68
+ Installs into the known skills areas are recorded in a per-area
69
+ `.skillpass.json` receipt, so the CLI knows what it installed and at which
70
+ version - your own hand-made skills in the same folders are left alone.
71
+
72
+ - `skillpass list` shows installed skills with their versions
73
+ - `skillpass outdated` compares them to the directory (exits 1 when updates
74
+ exist, handy in scripts)
75
+ - `skillpass update <slug>` re-runs the whole trust gate - including a
76
+ permission diff against the version you currently have - then swaps the
77
+ install atomically; updating a pack swaps, adds, and removes members as
78
+ the pack changed
79
+ - `skillpass remove <slug>` deletes an installed skill (a pack removes its
80
+ whole family); it refuses anything that does not look like an installed
81
+ skill, so a stray `--dir` can never wipe a real folder
82
+ - `skillpass scan <path>` runs the same validator the directory uses on any
83
+ local skill folder before you submit it
71
84
 
72
85
  ## Configuration
73
86
 
package/dist/cli.mjs CHANGED
@@ -5,7 +5,7 @@ var __export = (target, all) => {
5
5
  };
6
6
 
7
7
  // src/index.ts
8
- import { readFileSync as readFileSync2 } from "node:fs";
8
+ import { readFileSync as readFileSync3 } from "node:fs";
9
9
  import { createInterface } from "node:readline/promises";
10
10
 
11
11
  // ../../node_modules/.pnpm/fflate@0.8.3/node_modules/fflate/esm/index.mjs
@@ -494,15 +494,15 @@ function unzipSync(data, opts) {
494
494
  // src/add.ts
495
495
  import {
496
496
  existsSync,
497
- mkdirSync,
497
+ mkdirSync as mkdirSync2,
498
498
  readdirSync as readdirSync2,
499
499
  renameSync,
500
500
  rmdirSync,
501
501
  rmSync,
502
502
  statSync as statSync2,
503
- writeFileSync
503
+ writeFileSync as writeFileSync2
504
504
  } from "node:fs";
505
- import { dirname, isAbsolute, join as join3, resolve } from "node:path";
505
+ import { dirname, isAbsolute, join as join4, resolve as resolve2 } from "node:path";
506
506
 
507
507
  // ../validator/src/load.ts
508
508
  import { createHash } from "node:crypto";
@@ -16103,7 +16103,8 @@ async function validatePackage(dir, opts = {}) {
16103
16103
  // src/api.ts
16104
16104
  var DEFAULT_API_URL = "https://api.skillpass.dev";
16105
16105
  function resolveApiUrl(override) {
16106
- return (override ?? process.env.SKILLPASS_API ?? DEFAULT_API_URL).replace(/\/$/, "");
16106
+ const url2 = override ?? process.env.SKILLPASS_API;
16107
+ return (url2 || DEFAULT_API_URL).replace(/\/$/, "");
16107
16108
  }
16108
16109
  function parseSkillRef(ref) {
16109
16110
  const at = ref.indexOf("@");
@@ -16177,6 +16178,53 @@ function resolvePackMembers(members, target) {
16177
16178
  return { installs, skipped };
16178
16179
  }
16179
16180
 
16181
+ // src/receipts.ts
16182
+ import { mkdirSync, readFileSync as readFileSync2, writeFileSync } from "node:fs";
16183
+ import { join as join2 } from "node:path";
16184
+ var RECEIPT_FILE = ".skillpass.json";
16185
+ function looksLikeReceipt(value) {
16186
+ return typeof value === "object" && value !== null && typeof value.version === "string" && typeof value.sourceHash === "string";
16187
+ }
16188
+ function readReceipts(areaDir) {
16189
+ let parsed;
16190
+ try {
16191
+ parsed = JSON.parse(readFileSync2(join2(areaDir, RECEIPT_FILE), "utf8"));
16192
+ } catch {
16193
+ return {};
16194
+ }
16195
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
16196
+ return {};
16197
+ }
16198
+ const index = {};
16199
+ for (const [slug, receipt] of Object.entries(parsed)) {
16200
+ if (looksLikeReceipt(receipt)) {
16201
+ index[slug] = receipt;
16202
+ }
16203
+ }
16204
+ return index;
16205
+ }
16206
+ function writeReceipts(areaDir, index) {
16207
+ try {
16208
+ mkdirSync(areaDir, { recursive: true });
16209
+ writeFileSync(join2(areaDir, RECEIPT_FILE), `${JSON.stringify(index, null, " ")}
16210
+ `);
16211
+ } catch {
16212
+ }
16213
+ }
16214
+ function recordReceipt(areaDir, slug, receipt) {
16215
+ const index = readReceipts(areaDir);
16216
+ index[slug] = receipt;
16217
+ writeReceipts(areaDir, index);
16218
+ }
16219
+ function removeReceipt(areaDir, slug) {
16220
+ const index = readReceipts(areaDir);
16221
+ if (!(slug in index)) {
16222
+ return;
16223
+ }
16224
+ delete index[slug];
16225
+ writeReceipts(areaDir, index);
16226
+ }
16227
+
16180
16228
  // src/style.ts
16181
16229
  var wrap = (open, close) => (text) => `\x1B[${open}m${text}\x1B[${close}m`;
16182
16230
  var ANSI = {
@@ -16285,11 +16333,11 @@ function renderDiff(diff) {
16285
16333
 
16286
16334
  // src/targets.ts
16287
16335
  import { homedir } from "node:os";
16288
- import { join as join2 } from "node:path";
16336
+ import { join as join3, resolve } from "node:path";
16289
16337
  function installAreas(home = homedir()) {
16290
16338
  return {
16291
- "claude-code": { project: join2(".claude", "skills"), global: join2(home, ".claude", "skills") },
16292
- codex: { project: join2(".agents", "skills") }
16339
+ "claude-code": { project: join3(".claude", "skills"), global: join3(home, ".claude", "skills") },
16340
+ codex: { project: join3(".agents", "skills") }
16293
16341
  };
16294
16342
  }
16295
16343
  var MAPPED_TARGETS = Object.keys(installAreas());
@@ -16320,11 +16368,31 @@ function resolveTargetArea(target, global = false, home) {
16320
16368
  }
16321
16369
  function resolveTargetDir(target, slug, global = false, home) {
16322
16370
  const area = resolveTargetArea(target, global, home);
16323
- return area.ok ? { ok: true, dir: join2(area.dir, slug) } : area;
16371
+ return area.ok ? { ok: true, dir: join3(area.dir, slug) } : area;
16324
16372
  }
16325
16373
  function mappableDeclaredTargets(declared) {
16326
16374
  return declared.filter((t) => MAPPED_TARGETS.includes(t));
16327
16375
  }
16376
+ function knownAreas(cwd, home) {
16377
+ const areas = [];
16378
+ const byTarget = installAreas(home);
16379
+ for (const tool of MAPPED_TARGETS) {
16380
+ const area = byTarget[tool];
16381
+ if (!area) {
16382
+ continue;
16383
+ }
16384
+ areas.push({
16385
+ tool,
16386
+ global: false,
16387
+ label: `${tool} project (${area.project})`,
16388
+ dir: resolve(cwd, area.project)
16389
+ });
16390
+ if (area.global) {
16391
+ areas.push({ tool, global: true, label: `${tool} user (${area.global})`, dir: area.global });
16392
+ }
16393
+ }
16394
+ return areas;
16395
+ }
16328
16396
 
16329
16397
  // src/add.ts
16330
16398
  function unsafeEntryPath(path) {
@@ -16391,11 +16459,11 @@ function writeTree(files, target) {
16391
16459
  const tempDir = `${target}.tmp-${process.pid}`;
16392
16460
  try {
16393
16461
  for (const file2 of files) {
16394
- const filePath = join3(tempDir, file2.path);
16395
- mkdirSync(dirname(filePath), { recursive: true });
16396
- writeFileSync(filePath, file2.content);
16462
+ const filePath = join4(tempDir, file2.path);
16463
+ mkdirSync2(dirname(filePath), { recursive: true });
16464
+ writeFileSync2(filePath, file2.content);
16397
16465
  }
16398
- mkdirSync(dirname(target), { recursive: true });
16466
+ mkdirSync2(dirname(target), { recursive: true });
16399
16467
  if (existsSync(target)) {
16400
16468
  rmdirSync(target);
16401
16469
  }
@@ -16565,9 +16633,9 @@ async function runAdd(ref, opts = {}) {
16565
16633
  }
16566
16634
  }
16567
16635
  if (packTool !== void 0) {
16568
- const area = resolveTargetArea(packTool, packGlobal);
16569
- if (!area.ok) {
16570
- push("", `error: ${area.message}`);
16636
+ const area2 = resolveTargetArea(packTool, packGlobal);
16637
+ if (!area2.ok) {
16638
+ push("", `error: ${area2.message}`);
16571
16639
  return done(2);
16572
16640
  }
16573
16641
  const { installs, skipped } = resolvePackMembers(members, packTool);
@@ -16575,8 +16643,8 @@ async function runAdd(ref, opts = {}) {
16575
16643
  push("", `error: none of this pack's skills support ${packTool}`);
16576
16644
  return done(2);
16577
16645
  }
16578
- const areaAbs = resolve(opts.cwd ?? process.cwd(), area.dir);
16579
- const conflicts = installs.map((m) => join3(areaAbs, m.name)).filter(
16646
+ const areaAbs = resolve2(opts.cwd ?? process.cwd(), area2.dir);
16647
+ const conflicts = installs.map((m) => join4(areaAbs, m.name)).filter(
16580
16648
  (dest) => existsSync(dest) && (!statSync2(dest).isDirectory() || readdirSync2(dest).length > 0)
16581
16649
  );
16582
16650
  if (conflicts.length > 0) {
@@ -16610,7 +16678,7 @@ async function runAdd(ref, opts = {}) {
16610
16678
  const written = [];
16611
16679
  try {
16612
16680
  for (const plan of plans) {
16613
- writeTree(plan.files, join3(areaAbs, plan.name));
16681
+ writeTree(plan.files, join4(areaAbs, plan.name));
16614
16682
  written.push(plan.name);
16615
16683
  }
16616
16684
  } catch {
@@ -16620,6 +16688,14 @@ async function runAdd(ref, opts = {}) {
16620
16688
  );
16621
16689
  return done(2);
16622
16690
  }
16691
+ for (const name of written) {
16692
+ recordReceipt(areaAbs, name, {
16693
+ version: preflight.version,
16694
+ sourceHash: preflight.sourceHash,
16695
+ installedAt: (/* @__PURE__ */ new Date()).toISOString(),
16696
+ pack: { slug, version: preflight.version }
16697
+ });
16698
+ }
16623
16699
  push(
16624
16700
  "",
16625
16701
  `Installed ${written.length} skills to ${areaAbs}`,
@@ -16648,7 +16724,7 @@ async function runAdd(ref, opts = {}) {
16648
16724
  }
16649
16725
  }
16650
16726
  }
16651
- const target = resolve(opts.cwd ?? process.cwd(), targetDir);
16727
+ const target = resolve2(opts.cwd ?? process.cwd(), targetDir);
16652
16728
  if (existsSync(target)) {
16653
16729
  if (!statSync2(target).isDirectory()) {
16654
16730
  push("", `error: target ${target} already exists and is not a directory`);
@@ -16676,6 +16752,14 @@ async function runAdd(ref, opts = {}) {
16676
16752
  push("", "error: could not write the install; nothing was installed");
16677
16753
  return done(2);
16678
16754
  }
16755
+ const area = knownAreas(opts.cwd ?? process.cwd()).find((a) => a.dir === dirname(target));
16756
+ if (area) {
16757
+ recordReceipt(area.dir, slug, {
16758
+ version: preflight.version,
16759
+ sourceHash: preflight.sourceHash,
16760
+ installedAt: (/* @__PURE__ */ new Date()).toISOString()
16761
+ });
16762
+ }
16679
16763
  push(
16680
16764
  "",
16681
16765
  `Installed ${download.files.length} file(s) to ${target}`,
@@ -16686,27 +16770,12 @@ async function runAdd(ref, opts = {}) {
16686
16770
 
16687
16771
  // src/list.ts
16688
16772
  import { existsSync as existsSync2, readdirSync as readdirSync3, statSync as statSync3 } from "node:fs";
16689
- import { join as join4, resolve as resolve2 } from "node:path";
16690
- function knownAreas(cwd, home) {
16691
- const areas = [];
16692
- const byTarget = installAreas(home);
16693
- for (const tool of MAPPED_TARGETS) {
16694
- const area = byTarget[tool];
16695
- if (!area) {
16696
- continue;
16697
- }
16698
- areas.push({ label: `${tool} project (${area.project})`, dir: resolve2(cwd, area.project) });
16699
- if (area.global) {
16700
- areas.push({ label: `${tool} user (${area.global})`, dir: area.global });
16701
- }
16702
- }
16703
- return areas;
16704
- }
16773
+ import { join as join5 } from "node:path";
16705
16774
  function installedIn(dir) {
16706
16775
  if (!existsSync2(dir)) {
16707
16776
  return [];
16708
16777
  }
16709
- return readdirSync3(dir).sort().filter((name) => statSync3(join4(dir, name)).isDirectory());
16778
+ return readdirSync3(dir).sort().filter((name) => statSync3(join5(dir, name)).isDirectory());
16710
16779
  }
16711
16780
  function runList(opts = {}) {
16712
16781
  const cwd = opts.cwd ?? process.cwd();
@@ -16720,8 +16789,10 @@ function runList(opts = {}) {
16720
16789
  lines.push("");
16721
16790
  }
16722
16791
  lines.push(area.label);
16792
+ const receipts = readReceipts(area.dir);
16723
16793
  for (const name of names) {
16724
- lines.push(` ${name}`);
16794
+ const version2 = receipts[name]?.version;
16795
+ lines.push(version2 ? ` ${name} ${version2}` : ` ${name}`);
16725
16796
  }
16726
16797
  }
16727
16798
  if (lines.length === 0) {
@@ -16730,11 +16801,136 @@ function runList(opts = {}) {
16730
16801
  return { lines, exitCode: 0 };
16731
16802
  }
16732
16803
 
16804
+ // src/outdated.ts
16805
+ import { join as join6 } from "node:path";
16806
+ async function identifyByHash(fetchImpl, apiUrl, slug, dir) {
16807
+ let hash2;
16808
+ try {
16809
+ hash2 = loadPackage(dir).sourceHash;
16810
+ } catch {
16811
+ return void 0;
16812
+ }
16813
+ const detail = await getParsed(
16814
+ fetchImpl,
16815
+ `${apiUrl}/skills/${encodeURIComponent(slug)}`,
16816
+ publicSkillDetailSchema
16817
+ );
16818
+ if (!detail.ok) {
16819
+ return void 0;
16820
+ }
16821
+ for (const version2 of detail.data.versions) {
16822
+ const preflight = await getParsed(
16823
+ fetchImpl,
16824
+ `${apiUrl}/skills/${encodeURIComponent(slug)}/${encodeURIComponent(version2.version)}/preflight`,
16825
+ publicPreflightSchema
16826
+ );
16827
+ if (preflight.ok && preflight.data.sourceHash === hash2) {
16828
+ return version2.version;
16829
+ }
16830
+ }
16831
+ return void 0;
16832
+ }
16833
+ async function runOutdated(opts = {}) {
16834
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
16835
+ const apiUrl = resolveApiUrl(opts.apiUrl);
16836
+ const cwd = opts.cwd ?? process.cwd();
16837
+ const fetched = await getParsed(fetchImpl, `${apiUrl}/skills`, publicSkillListSchema);
16838
+ if (!fetched.ok) {
16839
+ return { lines: [`error: ${fetched.message}`], exitCode: 2 };
16840
+ }
16841
+ const latestBySlug = new Map(fetched.data.map((s) => [s.slug, s]));
16842
+ const lines = [];
16843
+ let outdatedCount = 0;
16844
+ let tracked = 0;
16845
+ for (const area of knownAreas(cwd, opts.home)) {
16846
+ const names = installedIn(area.dir);
16847
+ if (names.length === 0) {
16848
+ continue;
16849
+ }
16850
+ const receipts = readReceipts(area.dir);
16851
+ const rows = [];
16852
+ const seenPacks = /* @__PURE__ */ new Set();
16853
+ let unrelated = 0;
16854
+ for (const name of names) {
16855
+ const receipt = receipts[name];
16856
+ if (receipt?.pack) {
16857
+ if (seenPacks.has(receipt.pack.slug)) {
16858
+ continue;
16859
+ }
16860
+ seenPacks.add(receipt.pack.slug);
16861
+ tracked += 1;
16862
+ const members = Object.values(receipts).filter(
16863
+ (r) => r.pack?.slug === receipt.pack?.slug
16864
+ ).length;
16865
+ const latest = latestBySlug.get(receipt.pack.slug)?.version;
16866
+ if (latest === void 0) {
16867
+ rows.push(` ${receipt.pack.slug} ${receipt.pack.version} not in the directory`);
16868
+ } else if (latest === receipt.pack.version) {
16869
+ rows.push(` ${receipt.pack.slug} ${receipt.pack.version} current (pack, ${members} skills)`);
16870
+ } else {
16871
+ outdatedCount += 1;
16872
+ rows.push(
16873
+ ` ${receipt.pack.slug} ${receipt.pack.version} -> ${latest} (pack, ${members} skills)`
16874
+ );
16875
+ }
16876
+ continue;
16877
+ }
16878
+ if (receipt) {
16879
+ tracked += 1;
16880
+ const latest = latestBySlug.get(name)?.version;
16881
+ if (latest === void 0) {
16882
+ rows.push(` ${name} ${receipt.version} not in the directory`);
16883
+ } else if (latest === receipt.version) {
16884
+ rows.push(` ${name} ${receipt.version} current`);
16885
+ } else {
16886
+ outdatedCount += 1;
16887
+ rows.push(` ${name} ${receipt.version} -> ${latest}`);
16888
+ }
16889
+ continue;
16890
+ }
16891
+ if (!latestBySlug.has(name)) {
16892
+ unrelated += 1;
16893
+ continue;
16894
+ }
16895
+ const identified = await identifyByHash(fetchImpl, apiUrl, name, join6(area.dir, name));
16896
+ if (identified !== void 0) {
16897
+ tracked += 1;
16898
+ const latest = latestBySlug.get(name)?.version;
16899
+ if (latest === identified) {
16900
+ rows.push(` ${name} ${identified} current (identified by hash)`);
16901
+ } else {
16902
+ outdatedCount += 1;
16903
+ rows.push(` ${name} ${identified} -> ${latest} (identified by hash)`);
16904
+ }
16905
+ continue;
16906
+ }
16907
+ rows.push(` ${name} ? no receipt - reinstall to track updates`);
16908
+ }
16909
+ if (rows.length > 0) {
16910
+ if (lines.length > 0) {
16911
+ lines.push("");
16912
+ }
16913
+ lines.push(area.label, ...rows);
16914
+ if (unrelated > 0) {
16915
+ lines.push(` (${unrelated} folder(s) not from the directory)`);
16916
+ }
16917
+ }
16918
+ }
16919
+ if (tracked === 0 && lines.length === 0) {
16920
+ return { lines: ["No skills installed in the known install areas."], exitCode: 0 };
16921
+ }
16922
+ lines.push(
16923
+ "",
16924
+ outdatedCount === 0 ? "Everything is current." : `${outdatedCount} update(s) available - skillpass update <slug>`
16925
+ );
16926
+ return { lines, exitCode: outdatedCount === 0 ? 0 : 1 };
16927
+ }
16928
+
16733
16929
  // src/remove.ts
16734
16930
  import { existsSync as existsSync3, rmSync as rmSync2, statSync as statSync4 } from "node:fs";
16735
- import { join as join5, resolve as resolve3 } from "node:path";
16931
+ import { dirname as dirname2, join as join7, resolve as resolve3 } from "node:path";
16736
16932
  function looksLikeInstalledSkill(dir) {
16737
- return existsSync3(join5(dir, "SKILL.md")) || existsSync3(join5(dir, "skill.json"));
16933
+ return existsSync3(join7(dir, "SKILL.md")) || existsSync3(join7(dir, "skill.json"));
16738
16934
  }
16739
16935
  function candidateDirs(slug, cwd, home) {
16740
16936
  const dirs = [];
@@ -16744,9 +16940,9 @@ function candidateDirs(slug, cwd, home) {
16744
16940
  if (!area) {
16745
16941
  continue;
16746
16942
  }
16747
- dirs.push(resolve3(cwd, join5(area.project, slug)));
16943
+ dirs.push(resolve3(cwd, join7(area.project, slug)));
16748
16944
  if (area.global) {
16749
- dirs.push(join5(area.global, slug));
16945
+ dirs.push(join7(area.global, slug));
16750
16946
  }
16751
16947
  }
16752
16948
  dirs.push(resolve3(cwd, slug));
@@ -16760,6 +16956,39 @@ function runRemove(slug, opts = {}) {
16760
16956
  return { lines: ["error: --global needs --target (e.g. --target claude-code)"], exitCode: 2 };
16761
16957
  }
16762
16958
  const cwd = opts.cwd ?? process.cwd();
16959
+ if (!opts.dir) {
16960
+ let areas = knownAreas(cwd, opts.home);
16961
+ if (opts.target) {
16962
+ areas = areas.filter((a) => a.tool === opts.target && a.global === (opts.global ?? false));
16963
+ }
16964
+ const packHits = areas.map((area) => ({
16965
+ area,
16966
+ members: Object.entries(readReceipts(area.dir)).filter(([, r]) => r.pack?.slug === slug).map(([name]) => name)
16967
+ })).filter((hit) => hit.members.length > 0);
16968
+ if (packHits.length > 1) {
16969
+ return {
16970
+ lines: [
16971
+ `error: the ${slug} pack is installed in more than one place; pick one with --target:`,
16972
+ ...packHits.map((hit) => ` ${hit.area.dir}`)
16973
+ ],
16974
+ exitCode: 2
16975
+ };
16976
+ }
16977
+ if (packHits.length === 1) {
16978
+ const { area, members } = packHits[0];
16979
+ for (const name of members) {
16980
+ rmSync2(join7(area.dir, name), { recursive: true, force: true });
16981
+ removeReceipt(area.dir, name);
16982
+ }
16983
+ return {
16984
+ lines: [
16985
+ `Removed pack ${slug} (${members.length} skills) from ${area.dir}`,
16986
+ ` ${members.sort().join(", ")}`
16987
+ ],
16988
+ exitCode: 0
16989
+ };
16990
+ }
16991
+ }
16763
16992
  let dir;
16764
16993
  if (opts.target) {
16765
16994
  const resolved = resolveTargetDir(opts.target, slug, opts.global, opts.home);
@@ -16800,6 +17029,7 @@ function runRemove(slug, opts = {}) {
16800
17029
  };
16801
17030
  }
16802
17031
  rmSync2(dir, { recursive: true });
17032
+ removeReceipt(dirname2(dir), slug);
16803
17033
  return { lines: [`Removed ${slug} from ${dir}`], exitCode: 0 };
16804
17034
  }
16805
17035
 
@@ -16928,6 +17158,288 @@ async function runSearch(opts = {}) {
16928
17158
  };
16929
17159
  }
16930
17160
 
17161
+ // src/update.ts
17162
+ import { existsSync as existsSync4, readdirSync as readdirSync4, renameSync as renameSync2, rmSync as rmSync3, statSync as statSync5 } from "node:fs";
17163
+ import { join as join8 } from "node:path";
17164
+ async function locate(slug, opts, fetchImpl, apiUrl) {
17165
+ const cwd = opts.cwd ?? process.cwd();
17166
+ let areas = knownAreas(cwd, opts.home);
17167
+ if (opts.target) {
17168
+ areas = areas.filter((a) => a.tool === opts.target && a.global === (opts.global ?? false));
17169
+ }
17170
+ const hits = [];
17171
+ for (const area of areas) {
17172
+ const receipts = readReceipts(area.dir);
17173
+ const receipt = receipts[slug];
17174
+ const dir = join8(area.dir, slug);
17175
+ if (receipt) {
17176
+ hits.push({
17177
+ area,
17178
+ dir,
17179
+ installedVersion: receipt.version,
17180
+ packSlug: receipt.pack?.slug
17181
+ });
17182
+ continue;
17183
+ }
17184
+ const packMember = Object.values(receipts).find((r) => r.pack?.slug === slug);
17185
+ if (packMember?.pack) {
17186
+ hits.push({ area, dir: area.dir, installedVersion: packMember.pack.version, packSlug: slug });
17187
+ continue;
17188
+ }
17189
+ if (existsSync4(dir)) {
17190
+ const identified = await identifyByHash(fetchImpl, apiUrl, slug, dir);
17191
+ if (identified !== void 0) {
17192
+ hits.push({ area, dir, installedVersion: identified });
17193
+ }
17194
+ }
17195
+ }
17196
+ return hits;
17197
+ }
17198
+ function permissionChanges(installed, target) {
17199
+ const flat = (sets) => /* @__PURE__ */ new Set([...sets.declared, ...sets.detected]);
17200
+ const before = flat(installed);
17201
+ const after = flat(target);
17202
+ return {
17203
+ added: [...after].filter((key) => !before.has(key)).sort(),
17204
+ removed: [...before].filter((key) => !after.has(key)).sort()
17205
+ };
17206
+ }
17207
+ function swapTree(files, dir) {
17208
+ const fresh = `${dir}.new-${process.pid}`;
17209
+ const aside = `${dir}.old-${process.pid}`;
17210
+ writeTree(files, fresh);
17211
+ renameSync2(dir, aside);
17212
+ try {
17213
+ renameSync2(fresh, dir);
17214
+ } catch (err2) {
17215
+ renameSync2(aside, dir);
17216
+ rmSync3(fresh, { recursive: true, force: true });
17217
+ throw err2;
17218
+ }
17219
+ rmSync3(aside, { recursive: true, force: true });
17220
+ }
17221
+ async function runUpdate(ref, opts = {}) {
17222
+ const lines = [];
17223
+ const streamed = opts.emit !== void 0;
17224
+ const push = (...next) => {
17225
+ lines.push(...next);
17226
+ if (next.length > 0) {
17227
+ opts.emit?.(next.join("\n"));
17228
+ }
17229
+ };
17230
+ const done = (exitCode) => ({ lines, exitCode, streamed });
17231
+ const st = opts.style ?? PLAIN;
17232
+ if (opts.global && !opts.target) {
17233
+ push("error: --global needs --target (e.g. --target claude-code)");
17234
+ return done(2);
17235
+ }
17236
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
17237
+ const apiUrl = resolveApiUrl(opts.apiUrl);
17238
+ const { slug } = parseSkillRef(ref);
17239
+ if (!slug) {
17240
+ push(`error: invalid skill reference "${ref}"`);
17241
+ return done(2);
17242
+ }
17243
+ const hits = await locate(slug, opts, fetchImpl, apiUrl);
17244
+ const memberHit = hits.find((h) => h.packSlug !== void 0 && h.packSlug !== slug);
17245
+ if (memberHit?.packSlug !== void 0) {
17246
+ push(
17247
+ `error: ${slug} is part of the ${memberHit.packSlug} pack; run skillpass update ${memberHit.packSlug}`
17248
+ );
17249
+ return done(2);
17250
+ }
17251
+ if (hits.length === 0) {
17252
+ push(
17253
+ `error: ${slug} is not installed in a known skills area (only receipted or hash-identifiable installs can update)`
17254
+ );
17255
+ return done(2);
17256
+ }
17257
+ if (hits.length > 1) {
17258
+ push(
17259
+ `error: ${slug} is installed in more than one place; pick one with --target [--global]:`,
17260
+ ...hits.map((h) => ` ${h.dir}`)
17261
+ );
17262
+ return done(2);
17263
+ }
17264
+ const installed = hits[0];
17265
+ const fetched = await fetchPreflight(fetchImpl, apiUrl, ref);
17266
+ if (!fetched.ok) {
17267
+ push(...fetched.result.lines);
17268
+ return done(fetched.result.exitCode);
17269
+ }
17270
+ const { detail, preflight } = fetched;
17271
+ if ((detail.packMembers?.length ?? 0) > 0 || installed.packSlug === slug) {
17272
+ return runPackUpdate(slug, installed, detail, preflight, opts, fetchImpl, apiUrl, st, push, done);
17273
+ }
17274
+ if (installed.installedVersion === preflight.version) {
17275
+ push(`${slug} is already at ${preflight.version}.`);
17276
+ return done(0);
17277
+ }
17278
+ push(...renderPreflightReport(detail, preflight, opts.style));
17279
+ push("", ...await changesVsInstalled(fetchImpl, apiUrl, slug, installed, preflight, st));
17280
+ if (preflight.blocked) {
17281
+ return done(1);
17282
+ }
17283
+ if (preflight.riskLevel !== "low" && !opts.yes) {
17284
+ if (!opts.confirmImpl) {
17285
+ push("", `error: a ${preflight.riskLevel}-risk skill needs confirmation; rerun with --yes`);
17286
+ return done(2);
17287
+ }
17288
+ const confirmed = await opts.confirmImpl(
17289
+ `Update ${slug} to ${preflight.version} (${preflight.riskLevel} risk)? [y/N] `
17290
+ );
17291
+ if (!confirmed) {
17292
+ push("", "Update aborted; the installed version was left in place.");
17293
+ return done(2);
17294
+ }
17295
+ }
17296
+ const download = await downloadVerified(fetchImpl, apiUrl, slug, preflight.version, preflight.sourceHash);
17297
+ if (!download.ok) {
17298
+ push("", `error: ${download.message}`);
17299
+ return done(2);
17300
+ }
17301
+ try {
17302
+ swapTree(download.files, installed.dir);
17303
+ } catch {
17304
+ push("", "error: could not replace the install; the installed version was left in place");
17305
+ return done(2);
17306
+ }
17307
+ recordReceipt(installed.area.dir, slug, {
17308
+ version: preflight.version,
17309
+ sourceHash: preflight.sourceHash,
17310
+ installedAt: (/* @__PURE__ */ new Date()).toISOString()
17311
+ });
17312
+ push(
17313
+ "",
17314
+ `Updated ${slug} ${installed.installedVersion ?? "?"} -> ${preflight.version} in ${installed.dir}`,
17315
+ "Source hash verified against the Skill Passport."
17316
+ );
17317
+ return done(0);
17318
+ }
17319
+ async function changesVsInstalled(fetchImpl, apiUrl, slug, installed, target, st) {
17320
+ if (installed.installedVersion === void 0) {
17321
+ return ["Changes vs installed: unknown (no version identified)"];
17322
+ }
17323
+ const before = await getParsed(
17324
+ fetchImpl,
17325
+ `${apiUrl}/skills/${encodeURIComponent(slug)}/${encodeURIComponent(installed.installedVersion)}/preflight`,
17326
+ publicPreflightSchema
17327
+ );
17328
+ if (!before.ok) {
17329
+ return [
17330
+ `Changes vs installed v${installed.installedVersion}: unavailable (that version is no longer published)`
17331
+ ];
17332
+ }
17333
+ const { added, removed } = permissionChanges(before.data.permissions, target.permissions);
17334
+ if (added.length === 0 && removed.length === 0) {
17335
+ return [`Changes vs installed v${installed.installedVersion}: no permission changes`];
17336
+ }
17337
+ return [
17338
+ `Changes vs installed v${installed.installedVersion}`,
17339
+ ...added.map((key) => st.red(` + ${key} (new)`)),
17340
+ ...removed.map((key) => ` - ${key} (no longer requested)`)
17341
+ ];
17342
+ }
17343
+ async function runPackUpdate(slug, installed, detail, preflight, opts, fetchImpl, apiUrl, st, push, done) {
17344
+ if (installed.packSlug !== slug) {
17345
+ push(`error: ${slug} is a pack but this install is not tracked as one; reinstall with skillpass add`);
17346
+ return done(2);
17347
+ }
17348
+ if (installed.installedVersion === preflight.version) {
17349
+ push(`${slug} is already at ${preflight.version}.`);
17350
+ return done(0);
17351
+ }
17352
+ push(...renderPreflightReport(detail, preflight, opts.style));
17353
+ push("", ...await changesVsInstalled(fetchImpl, apiUrl, slug, installed, preflight, st));
17354
+ if (preflight.blocked) {
17355
+ return done(1);
17356
+ }
17357
+ if (preflight.riskLevel !== "low" && !opts.yes) {
17358
+ if (!opts.confirmImpl) {
17359
+ push("", `error: a ${preflight.riskLevel}-risk skill needs confirmation; rerun with --yes`);
17360
+ return done(2);
17361
+ }
17362
+ const confirmed = await opts.confirmImpl(
17363
+ `Update ${slug} to ${preflight.version} (${preflight.riskLevel} risk)? [y/N] `
17364
+ );
17365
+ if (!confirmed) {
17366
+ push("", "Update aborted; the installed version was left in place.");
17367
+ return done(2);
17368
+ }
17369
+ }
17370
+ const areaDir = installed.area.dir;
17371
+ const { installs, skipped } = resolvePackMembers(detail.packMembers ?? [], installed.area.tool);
17372
+ if (installs.length === 0) {
17373
+ push("", `error: none of the new version's skills support ${installed.area.tool}`);
17374
+ return done(2);
17375
+ }
17376
+ const receipts = readReceipts(areaDir);
17377
+ const currentMembers = Object.entries(receipts).filter(([, r]) => r.pack?.slug === slug).map(([name]) => name);
17378
+ const newNames = new Set(installs.map((m) => m.name));
17379
+ const toRemove = currentMembers.filter((name) => !newNames.has(name));
17380
+ const additions = installs.filter((m) => !currentMembers.includes(m.name));
17381
+ const conflicts = additions.map((m) => join8(areaDir, m.name)).filter(
17382
+ (dest) => existsSync4(dest) && (!statSync5(dest).isDirectory() || readdirSync4(dest).length > 0)
17383
+ );
17384
+ if (conflicts.length > 0) {
17385
+ push(
17386
+ "",
17387
+ "error: these destinations already exist and are not empty; nothing was changed:",
17388
+ ...conflicts.map((c) => ` ${c}`)
17389
+ );
17390
+ return done(2);
17391
+ }
17392
+ const download = await downloadVerified(fetchImpl, apiUrl, slug, preflight.version, preflight.sourceHash);
17393
+ if (!download.ok) {
17394
+ push("", `error: ${download.message}`);
17395
+ return done(2);
17396
+ }
17397
+ const plans = installs.map((m) => ({ ...m, files: memberFiles(download.files, m.sourceDir) }));
17398
+ const missing = plans.find((p) => p.files.length === 0);
17399
+ if (missing) {
17400
+ push("", `error: the snapshot has no files for "${missing.name}"; nothing was changed`);
17401
+ return done(2);
17402
+ }
17403
+ for (const name of skipped) {
17404
+ push("", `note: ${name} does not support ${installed.area.tool}; skipped`);
17405
+ }
17406
+ const done1 = [];
17407
+ try {
17408
+ for (const plan of plans) {
17409
+ const dest = join8(areaDir, plan.name);
17410
+ if (existsSync4(dest)) {
17411
+ swapTree(plan.files, dest);
17412
+ } else {
17413
+ writeTree(plan.files, dest);
17414
+ }
17415
+ done1.push(plan.name);
17416
+ recordReceipt(areaDir, plan.name, {
17417
+ version: preflight.version,
17418
+ sourceHash: preflight.sourceHash,
17419
+ installedAt: (/* @__PURE__ */ new Date()).toISOString(),
17420
+ pack: { slug, version: preflight.version }
17421
+ });
17422
+ }
17423
+ } catch {
17424
+ push(
17425
+ "",
17426
+ `error: could not write ${plans[done1.length].name}; updated before the failure: ${done1.join(", ") || "none"}`
17427
+ );
17428
+ return done(2);
17429
+ }
17430
+ for (const name of toRemove) {
17431
+ rmSync3(join8(areaDir, name), { recursive: true, force: true });
17432
+ removeReceipt(areaDir, name);
17433
+ }
17434
+ push(
17435
+ "",
17436
+ `Updated ${slug} ${installed.installedVersion ?? "?"} -> ${preflight.version} in ${areaDir}`,
17437
+ ` ${done1.length} skill(s)${additions.length > 0 ? `, added: ${additions.map((m) => m.name).join(", ")}` : ""}${toRemove.length > 0 ? `, removed: ${toRemove.join(", ")}` : ""}`,
17438
+ "Source hash verified against the Skill Passport."
17439
+ );
17440
+ return done(0);
17441
+ }
17442
+
16931
17443
  // src/scan.ts
16932
17444
  async function runScan(path, opts = {}) {
16933
17445
  let report;
@@ -16970,7 +17482,7 @@ async function runScan(path, opts = {}) {
16970
17482
  }
16971
17483
 
16972
17484
  // src/index.ts
16973
- var VERSION = JSON.parse(readFileSync2(new URL("../package.json", import.meta.url), "utf8")).version;
17485
+ var VERSION = JSON.parse(readFileSync3(new URL("../package.json", import.meta.url), "utf8")).version;
16974
17486
  var USAGE = [
16975
17487
  "skillpass - validate AI agent skills locally and inspect hosted passports",
16976
17488
  "",
@@ -16984,6 +17496,9 @@ var USAGE = [
16984
17496
  " skillpass remove <slug> [--target <tool> [--global] | --dir <path>]",
16985
17497
  " remove an installed skill",
16986
17498
  " skillpass list show installed skills in the known areas",
17499
+ " skillpass outdated compare installed skills to the directory",
17500
+ " skillpass update <slug>[@version] [--target <tool> [--global]] [--yes]",
17501
+ " update an installed skill through the gate",
16987
17502
  "",
16988
17503
  "Flags:",
16989
17504
  " --json print machine-readable JSON instead of the readable report",
@@ -16998,7 +17513,8 @@ var USAGE = [
16998
17513
  " --help show this message",
16999
17514
  "",
17000
17515
  "The search, report, and add commands read the API base URL from SKILLPASS_API.",
17001
- "Exit codes: 0 ok/warning, 1 failed or blocked, 2 usage/load/network errors."
17516
+ "Exit codes: 0 ok/warning, 1 failed or blocked, 2 usage/load/network errors",
17517
+ "(outdated exits 1 when updates are available)."
17002
17518
  ].join("\n");
17003
17519
  var COMMAND_FLAGS = {
17004
17520
  scan: ["--json"],
@@ -17006,6 +17522,8 @@ var COMMAND_FLAGS = {
17006
17522
  add: ["--yes", "--target", "--dir", "--global"],
17007
17523
  remove: ["--target", "--dir", "--global"],
17008
17524
  list: [],
17525
+ outdated: [],
17526
+ update: ["--yes", "--target", "--global"],
17009
17527
  search: ["--target", "--category", "--packs", "--json"]
17010
17528
  };
17011
17529
  function parseCliArgs(argv) {
@@ -17124,6 +17642,27 @@ async function run(argv) {
17124
17642
  }
17125
17643
  return runList();
17126
17644
  }
17645
+ if (args.command === "update") {
17646
+ const [ref] = args.positional;
17647
+ if (!ref) {
17648
+ return { lines: ["error: update needs a skill slug", "", USAGE], exitCode: 2 };
17649
+ }
17650
+ const tty = Boolean(process.stdin.isTTY);
17651
+ return runUpdate(ref, {
17652
+ yes: args.yes,
17653
+ target: args.target,
17654
+ global: args.global,
17655
+ style: styler(Boolean(process.stdout.isTTY)),
17656
+ confirmImpl: tty ? confirmViaTty : void 0,
17657
+ emit: (text) => console.log(text)
17658
+ });
17659
+ }
17660
+ if (args.command === "outdated") {
17661
+ if (args.positional.length > 0) {
17662
+ return { lines: ["error: outdated takes no arguments", "", USAGE], exitCode: 2 };
17663
+ }
17664
+ return runOutdated();
17665
+ }
17127
17666
  if (args.command === "report") {
17128
17667
  const [ref] = args.positional;
17129
17668
  if (!ref) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillpass",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Validation-first installer for AI agent skills: search the SkillPass directory, inspect a skill's passport, and install hash-verified skills and packs for Claude Code, Codex, and more.",
5
5
  "keywords": [
6
6
  "ai",
@@ -36,8 +36,8 @@
36
36
  "esbuild": "^0.28.1",
37
37
  "fflate": "^0.8.3",
38
38
  "typescript": "^6.0.3",
39
- "skill-schema": "0.1.0",
40
- "validator": "0.3.0"
39
+ "validator": "0.3.0",
40
+ "skill-schema": "0.1.0"
41
41
  },
42
42
  "scripts": {
43
43
  "build": "esbuild src/cli.ts --bundle --platform=node --format=esm --outfile=dist/cli.mjs --log-level=warning",