@spardutti/claude-skills 2.59.1 → 2.60.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.
package/bin/cli.mjs CHANGED
@@ -8,13 +8,13 @@ import { dirname, join, resolve } from "node:path";
8
8
  import { homedir } from "node:os";
9
9
  import { fetchSkills, fetchCommands, fetchAgents } from "../lib/github.mjs";
10
10
  import { promptSkillSelection, promptCommandSelection, promptRemoval } from "../lib/prompt.mjs";
11
- import { installSkills, installCommands, installRequiredAgents } from "../lib/install.mjs";
11
+ import { installSkills, installCommands, installRequiredAgents, withRequiredCommands } from "../lib/install.mjs";
12
12
  import { makeLocalSource } from "../lib/local.mjs";
13
13
  import { runPostInstall } from "../lib/post-install.mjs";
14
14
  import { runSync, runSyncAll } from "../lib/sync.mjs";
15
15
  import {
16
16
  readManifest, writeManifest, computeOrphans, computeRemovals, scanInstalled, removeArtifacts,
17
- MANIFEST_FILE,
17
+ catalogIds, MANIFEST_FILE,
18
18
  } from "../lib/manifest.mjs";
19
19
 
20
20
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -104,7 +104,7 @@ async function main() {
104
104
  let installedAgents = [];
105
105
  if (commands.length > 0) {
106
106
  console.log();
107
- selectedCommands = await promptCommandSelection(commands, manifest?.commands ?? []);
107
+ selectedCommands = withRequiredCommands(await promptCommandSelection(commands, manifest?.commands ?? []), commands);
108
108
  if (selectedCommands.length > 0) {
109
109
  console.log();
110
110
  await installCommands(selectedCommands);
@@ -141,6 +141,7 @@ async function main() {
141
141
  skills: selectedSkills.map((s) => s.dirName),
142
142
  commands: selectedCommands.map((c) => c.fileName),
143
143
  agents: installedAgents.map((a) => a.fileName),
144
+ known: catalogIds(catalog),
144
145
  });
145
146
 
146
147
  const parts = [];
@@ -71,7 +71,11 @@ function stale() {
71
71
  if (!syms.length) say("extend_by names no symbol — write names like `AimStrategy`");
72
72
  const code = files.filter((f) => insideLocation(e, f) && !f.endsWith(REGISTRY_SUFFIX))
73
73
  .map((f) => readFileSync(join(root, f), "utf8")).join("\n");
74
- for (const s of syms) if (!uses(code, s)) say(`${s} from extend_by is not found in ${e.location}`);
74
+ for (const s of syms) {
75
+ // A plain word like `first` matches `var first = enemies[0]`; `Aim.first` only matches real use.
76
+ if (/^[a-z][a-z0-9]*$/.test(s)) say(`\`${s}\` is too common a word to prove use — name its owner, like \`Owner.${s}\``);
77
+ else if (!s.split(".").every((part) => uses(code, part))) say(`${s} from extend_by is not found in ${e.location}`);
78
+ }
75
79
  }
76
80
  if (problems.length) fail(problems);
77
81
  }
@@ -57,20 +57,21 @@ function finalize(dir, path, record, tag, status, reason, at) {
57
57
  }
58
58
 
59
59
  function prOf(branch) {
60
- const r = spawnSync("gh", ["pr", "list", "--head", branch, "--state", "all", "--json", "number,createdAt", "--limit", "1"], { encoding: "utf8" });
60
+ const r = spawnSync("gh", ["pr", "list", "--head", branch, "--state", "all", "--json", "number,createdAt,state", "--limit", "1"], { encoding: "utf8" });
61
61
  try { return r.status === 0 ? JSON.parse(r.stdout)[0] : undefined; } catch { return undefined; }
62
62
  }
63
63
 
64
64
  // Agents skip the `pr` call, so a log whose branch has a PR by now is closed at that PR.
65
65
  // A PR older than the log was only updated by it, so that log ends at its own last event.
66
+ // A long-lived branch like dev keeps its merged PRs, so an old one that is not open is not this log's.
66
67
  function closeOpened(dir, app) {
67
68
  const open = join(dir, "open");
68
69
  for (const f of existsSync(open) ? readdirSync(open).filter((n) => n.startsWith(`${app}__`)) : []) {
69
70
  const path = join(open, f), record = JSON.parse(readFileSync(path, "utf8"));
70
71
  if (!record.gate.length && !record.findings.length) continue;
71
72
  const pr = prOf(record.branch);
72
- if (!pr) continue;
73
- const opened = pr.createdAt >= record.started;
73
+ const opened = pr && pr.createdAt >= record.started;
74
+ if (!pr || (!opened && pr.state !== "OPEN")) continue;
74
75
  finalize(dir, path, Object.assign(record, { pr: pr.number }), `pr${pr.number}`,
75
76
  opened ? "pr-opened" : "pr-updated", null, opened ? pr.createdAt : lastEvent(record));
76
77
  }
@@ -16,7 +16,7 @@
16
16
  export function parseFrontmatter(content, fallbackName) {
17
17
  const match = content.match(/^---\n([\s\S]*?)\n---/);
18
18
  if (!match) {
19
- return { name: fallbackName, description: "", category: "General", requiresAgents: [] };
19
+ return { name: fallbackName, description: "", category: "General", requiresAgents: [], requiresCommands: [] };
20
20
  }
21
21
  const block = match[1];
22
22
 
@@ -33,6 +33,7 @@ export function parseFrontmatter(content, fallbackName) {
33
33
  description: pick("description"),
34
34
  category: pick("category") || "General",
35
35
  requiresAgents: parseAgentList(pick("requires-agents")),
36
+ requiresCommands: parseAgentList(pick("requires-commands")),
36
37
  };
37
38
  }
38
39
 
package/lib/github.mjs CHANGED
@@ -154,8 +154,8 @@ export function fetchCommands() {
154
154
  entryFilter: (e) => e.type === "file" && e.name.endsWith(".md"),
155
155
  buildRawUrl: (file) => `${RAW_COMMANDS_BASE}/${file.name}`,
156
156
  mapEntry: (file, content) => {
157
- const { name, description, category, requiresAgents } = parseFrontmatter(content, file.name.replace(/\.md$/, ""));
158
- return { fileName: file.name, name, description, category, requiresAgents, content };
157
+ const { name, description, category, requiresAgents, requiresCommands } = parseFrontmatter(content, file.name.replace(/\.md$/, ""));
158
+ return { fileName: file.name, name, description, category, requiresAgents, requiresCommands, content };
159
159
  },
160
160
  });
161
161
  }
package/lib/install.mjs CHANGED
@@ -43,6 +43,18 @@ export function installAgents(agents, targetDir = process.cwd()) {
43
43
  return installFlat(agents, "agents", targetDir);
44
44
  }
45
45
 
46
+ // A command that names another in `requires-commands` breaks without it, so it comes along.
47
+ export function withRequiredCommands(selected, catalogCommands) {
48
+ const out = [...selected];
49
+ for (let i = 0; i < out.length; i++) {
50
+ for (const name of out[i].requiresCommands ?? []) {
51
+ const dep = catalogCommands.find((c) => c.name === name);
52
+ if (dep && !out.some((c) => c.fileName === dep.fileName)) out.push(dep);
53
+ }
54
+ }
55
+ return out;
56
+ }
57
+
46
58
  export async function installRequiredAgents(selectedCommands, availableAgents, targetDir = process.cwd()) {
47
59
  const requiredNames = new Set(
48
60
  selectedCommands.flatMap((c) => c.requiresAgents ?? [])
package/lib/local.mjs CHANGED
@@ -39,8 +39,8 @@ export function makeLocalSource(root) {
39
39
  for (const f of await listDir(join(root, "commands"))) {
40
40
  if (!f.isFile() || !f.name.endsWith(".md")) continue;
41
41
  const content = await readFile(join(root, "commands", f.name), "utf-8");
42
- const { name, description, category, requiresAgents } = parseFrontmatter(content, f.name.replace(/\.md$/, ""));
43
- out.push({ fileName: f.name, name, description, category, requiresAgents, content });
42
+ const { name, description, category, requiresAgents, requiresCommands } = parseFrontmatter(content, f.name.replace(/\.md$/, ""));
43
+ out.push({ fileName: f.name, name, description, category, requiresAgents, requiresCommands, content });
44
44
  }
45
45
  return out;
46
46
  },
package/lib/manifest.mjs CHANGED
@@ -17,6 +17,7 @@ export async function readManifest(targetDir = process.cwd()) {
17
17
  commands: data.commands ?? [],
18
18
  agents: data.agents ?? [],
19
19
  custom: data.custom ?? [],
20
+ known: data.known ?? null,
20
21
  };
21
22
  } catch {
22
23
  return null; // no manifest — a pre-manifest project or a fresh install
@@ -25,9 +26,10 @@ export async function readManifest(targetDir = process.cwd()) {
25
26
 
26
27
  // Record exactly what the CLI has installed. This is the CLI's source of truth —
27
28
  // it never deletes anything not listed here. `custom` is the user's own gated skills, carried over untouched.
28
- export async function writeManifest(targetDir, { catalogVersion, skills, commands, agents }) {
29
+ export async function writeManifest(targetDir, { catalogVersion, skills, commands, agents, known }) {
29
30
  await mkdir(join(targetDir, ".claude"), { recursive: true });
30
- const custom = (await readManifest(targetDir))?.custom ?? [];
31
+ const previous = await readManifest(targetDir);
32
+ const custom = previous?.custom ?? [];
31
33
  const data = {
32
34
  catalogVersion,
33
35
  installedAt: new Date().toISOString(),
@@ -35,10 +37,16 @@ export async function writeManifest(targetDir, { catalogVersion, skills, command
35
37
  commands: [...new Set(commands)].sort(),
36
38
  agents: [...new Set(agents)].sort(),
37
39
  custom,
40
+ known: [...new Set(known ?? previous?.known ?? [])].sort(),
38
41
  };
39
42
  await writeFile(manifestPath(targetDir), JSON.stringify(data, null, 2) + "\n");
40
43
  }
41
44
 
45
+ // Every skill and command in the catalog, so the next sync can name what is new since.
46
+ export function catalogIds(catalog) {
47
+ return [...catalog.skills.map((s) => s.dirName), ...catalog.commands.map((c) => c.fileName)];
48
+ }
49
+
42
50
  // Manifest entries that no longer exist in the current catalog (renamed or removed upstream).
43
51
  export function computeOrphans(manifest, catalog) {
44
52
  if (!manifest) return { skills: [], commands: [], agents: [] };
package/lib/sync.mjs CHANGED
@@ -2,13 +2,27 @@ import chalk from "chalk";
2
2
  import { existsSync } from "node:fs";
3
3
  import { readdir } from "node:fs/promises";
4
4
  import { join, basename } from "node:path";
5
- import { installSkills, installCommands, installRequiredAgents } from "./install.mjs";
5
+ import { installSkills, installCommands, installRequiredAgents, withRequiredCommands } from "./install.mjs";
6
6
  import { setupHook } from "./setup-hook.mjs";
7
7
  import { setupClaudeMd } from "./setup-claude-md.mjs";
8
- import { readManifest, writeManifest, computeOrphans, removeArtifacts, MANIFEST_FILE } from "./manifest.mjs";
8
+ import { readManifest, writeManifest, computeOrphans, removeArtifacts, catalogIds, MANIFEST_FILE } from "./manifest.mjs";
9
9
 
10
10
  const SEARCH_DEPTH = 5;
11
11
 
12
+ // Sync installs only what was picked, so a new skill or command would otherwise go unseen.
13
+ function announceNew(manifest, catalog, have) {
14
+ if (!manifest.known) return;
15
+ const known = new Set(manifest.known);
16
+ const fresh = [
17
+ ...catalog.skills.map((s) => ({ id: s.dirName, label: s.name, about: s.description })),
18
+ ...catalog.commands.map((c) => ({ id: c.fileName, label: `/${c.name}`, about: c.description })),
19
+ ].filter((x) => !known.has(x.id) && !have.has(x.id));
20
+ if (fresh.length === 0) return;
21
+ console.log();
22
+ for (const x of fresh) console.log(` ${chalk.cyan("new")} ${chalk.bold(x.label)} — ${x.about.split(" — ")[0].slice(0, 100)}`);
23
+ console.log(chalk.dim(" Run the CLI without --sync to pick any of them."));
24
+ }
25
+
12
26
  // Re-install everything the manifest records (refreshed to the latest catalog),
13
27
  // refresh hooks the project already has, and prune anything removed upstream.
14
28
  export async function runSync(dir, catalog, version) {
@@ -25,17 +39,21 @@ export async function runSync(dir, catalog, version) {
25
39
  }
26
40
 
27
41
  const skills = catalog.skills.filter((s) => manifest.skills.includes(s.dirName));
28
- const commands = catalog.commands.filter((c) => manifest.commands.includes(c.fileName));
42
+ const picked = catalog.commands.filter((c) => manifest.commands.includes(c.fileName));
43
+ const commands = withRequiredCommands(picked, catalog.commands);
44
+ for (const c of commands.slice(picked.length)) console.log(` ${chalk.green("+")} Added ${chalk.bold(`/${c.name}`)} — a command you have needs it.`);
29
45
  if (skills.length > 0) { console.log(); await installSkills(skills, dir); }
30
46
  if (commands.length > 0) { console.log(); await installCommands(commands, dir); }
31
47
  const { installed } = await installRequiredAgents(commands, catalog.agents, dir);
32
48
  if (existsSync(join(dir, ".claude", "hooks", "skill-gate.sh"))) { console.log(); await setupHook(dir); await setupClaudeMd(dir); }
49
+ announceNew(manifest, catalog, new Set([...skills.map((s) => s.dirName), ...commands.map((c) => c.fileName)]));
33
50
 
34
51
  await writeManifest(dir, {
35
52
  catalogVersion: version,
36
53
  skills: skills.map((s) => s.dirName),
37
54
  commands: commands.map((c) => c.fileName),
38
55
  agents: installed.map((a) => a.fileName),
56
+ known: catalogIds(catalog),
39
57
  });
40
58
  console.log(`\n ${chalk.green("✔")} ${chalk.bold(`Synced to catalog v${version}.`)}\n`);
41
59
  return true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spardutti/claude-skills",
3
- "version": "2.59.1",
3
+ "version": "2.60.0",
4
4
  "description": "Guardrails for Claude Code — best-practice skills, planning commands, and delivery gates.",
5
5
  "type": "module",
6
6
  "repository": {