@linchpinagency/skills 0.1.9 → 0.1.11

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/README.md CHANGED
@@ -12,7 +12,7 @@ GitHub Copilot, and other compatible coding agents.
12
12
  ![Zero dependencies](https://img.shields.io/badge/Dependencies-0-brightgreen)
13
13
 
14
14
  <!-- x-release-please-start-version -->
15
- ### Latest release: 0.1.9
15
+ ### Latest release: 0.1.11
16
16
  <!-- x-release-please-end -->
17
17
 
18
18
  | Release | Skill standard | Install |
@@ -66,9 +66,10 @@ Then start a new session in your project and ask for something real — "what ki
66
66
  is this?" should pull in `project-context` and get you a summary of the repo shape, local
67
67
  environment, and host.
68
68
 
69
- **Re-run the same command to update.** There's no upgrade command; the installer overwrites
70
- in place and always pulls the latest published version. Do it every few weeks, or when
71
- someone announces a new skill.
69
+ **Re-run the same command to update.** There's no upgrade command the installer diffs each
70
+ skill's version against what you have, shows what would change, and asks before applying it.
71
+ A run with nothing to change exits immediately, so re-running costs nothing. Do it every few
72
+ weeks, or when someone announces a new skill.
72
73
 
73
74
  Full flag reference: [Install options](#install-options).
74
75
 
@@ -87,7 +88,7 @@ The fastest way to understand the library is to run one loop end to end:
87
88
  | Handle a client support ticket | "the client says their contact form isn't sending" | `support-triage` |
88
89
  | Add guardrails before touching prod | "careful mode — I'm on production" | `safety-hooks` |
89
90
 
90
- The full list is in [Available skills](#available-skills) — 20 of them, each with a
91
+ The full list is in [Available skills](#available-skills) — 23 of them, each with a
91
92
  `When to use` section that says exactly when it applies and which skill to use instead.
92
93
 
93
94
  **When you want to be explicit**, name the skill: *"use the wp-audit skill on the homepage."*
@@ -177,6 +178,9 @@ npx @linchpinagency/skills --global
177
178
 
178
179
  # Install the Linchpin skills only, without the upstream base layer
179
180
  npx @linchpinagency/skills --skip-upstream
181
+
182
+ # Audit every scope for duplicate installs; install nothing
183
+ npx @linchpinagency/skills --check
180
184
  ```
181
185
 
182
186
  > Pin a version when you need reproducibility — `npx @linchpinagency/skills@0.1.1` — or omit
@@ -188,8 +192,38 @@ npx @linchpinagency/skills --skip-upstream
188
192
  > network access and a system `tar`; if either is missing it warns and still installs the
189
193
  > Linchpin skills. Pass `--skip-upstream` to install the Linchpin skills alone.
190
194
 
191
- **Updating:** re-run the same command. The installer overwrites each skill in place, so a
192
- fresh run always pulls the latest published version.
195
+ **Updating:** re-run the same command that *is* the update path. Rather than overwriting
196
+ silently, the installer compares each skill's own `version` against what is installed,
197
+ prints the diff, and asks before touching anything:
198
+
199
+ ```
200
+ @linchpinagency/skills v0.2.0 — Claude Code
201
+
202
+ task-tracking v1.3.0 -> v1.4.0 update
203
+ agent-capabilities -> v1.0.0 new
204
+ quality-gates v1.0.0 local edits will be lost
205
+ (20 unchanged)
206
+
207
+ 3 change(s): 1 update, 1 new, 1 modified
208
+
209
+ Apply? [y/N]
210
+ ```
211
+
212
+ A run with nothing to change says so and exits without prompting, so re-running is cheap
213
+ and safe. `--dry-run` shows the diff and writes nothing; `--yes` skips the prompt;
214
+ `--force` reinstalls everything regardless.
215
+
216
+ > A **non-interactive** run — piped stdin, CI, a script — proceeds without prompting, so
217
+ > existing automation keeps working. Use `--dry-run` when you want a preview rather than an
218
+ > install.
219
+
220
+ Three statuses are worth knowing:
221
+
222
+ - **local edits will be lost** — the installed copy was hand-edited. Skills are owned by
223
+ this package; change them here and re-install rather than editing an install in place.
224
+ - **DOWNGRADE** — the package you invoked is *older* than what is installed. Usually a
225
+ pinned `npx @linchpinagency/skills@0.1.1` you meant to drop.
226
+ - **new** — the skill did not exist in your installed version.
193
227
 
194
228
  ### Keeping skills current
195
229
 
@@ -231,6 +265,34 @@ skips itself whenever `CI` is set.
231
265
  A project that wants skills in more than one agent's directory should run
232
266
  `--agent all` rather than copying directories around by hand.
233
267
 
268
+ ### One scope per skill
269
+
270
+ Agents load **every** skills directory they find and **do not dedupe by name**. A skill
271
+ installed both globally and in a project is listed twice, and its `description` is loaded
272
+ twice in every session before any work starts.
273
+
274
+ So the installer refuses to create the second copy:
275
+
276
+ ```
277
+ Refusing to install: 22 of these skills are already installed at another scope.
278
+ ```
279
+
280
+ It reports which directory, what would be duplicated, and the command to remove just the
281
+ overlapping skills — never the whole directory, which usually holds skills from other
282
+ sources too. `--force` overrides it for the rare case where you want both.
283
+
284
+ ```bash
285
+ npx @linchpinagency/skills --check # audit; exits 1 if duplicates exist
286
+ npx @linchpinagency/skills --check --agent codex # a different agent's directories
287
+ ```
288
+
289
+ `--check` also catches the accident that is easiest to miss: an install in a *parent* of the
290
+ repo (running the installer from `~/GitHub` rather than inside a checkout), which shadows
291
+ nothing and duplicates everything below it.
292
+
293
+ Choosing a scope, the MCP-server equivalent of the same problem, and how to record the
294
+ decision are covered by [`agent-capabilities`](skills/agent-capabilities/SKILL.md).
295
+
234
296
  > Skills are loaded by the **agent/harness**, not the model — so "Copilot running Claude"
235
297
  > still needs the skill installed in Copilot's own directory. The installer handles that.
236
298
 
@@ -251,6 +313,7 @@ A project that wants skills in more than one agent's directory should run
251
313
  | `wp-implementation-choice` | WordPress | Decide what a request should become — theme work, content, a custom block, a functionality plugin, or an existing plugin — before any code is written. |
252
314
  | `design-previews` | Design | Generate three genuinely different visual directions as self-contained HTML previews, screenshot them at desktop and mobile via the Chrome DevTools MCP (or Playwright), and get a pick before theme or block work starts. |
253
315
  | `project-context` | Workflow | Orient before acting — repo shape, local environment, host, ClickUp space, and release model, read from the project's own config rather than assumed. Referenced by other skills' Preflight. |
316
+ | `agent-capabilities` | Workflow | Right-size what a project loads — audit skill installs for cross-scope duplicates (`--check`), decide which MCP servers the repo actually needs, and scope them so every session stops paying for all of them. |
254
317
  | `quality-gates` | Workflow | Run a project's own lint, PHPCS, PHPStan, and test gates before committing — detected from `composer.json`, `package.json`, `phpcs.xml.dist`, and `lint-staged`, never assumed. |
255
318
  | `web-qa` | Workflow | QA like a real user and fix what you find — front end, wp-admin, and block editor, with severity, evidence, one atomic commit per fix, and a report-only mode. |
256
319
  | `investigate` | Workflow | Root-cause a bug before changing anything — reproduce, read the real error, isolate the layer, explain the mechanism, with WordPress first checks. |
@@ -259,8 +322,8 @@ A project that wants skills in more than one agent's directory should run
259
322
  | `engagement-types` | Project mgmt | Tell support, site maintenance, projects, product/plugin work, and pre-sales apart — each lives somewhere different in ClickUp and is planned and closed differently. |
260
323
  | `support-triage` | Project mgmt | Run a client support request end to end — clarify the real need, reproduce, judge urgency and scope, fix in the right layer, verify, and close the loop with the requester. |
261
324
  | `dependency-updates` | Workflow | Handle the dependency work Renovate can't automerge — majors, breaking changes, failing or conflicted bot PRs, security advisories, `@wordpress/*` package sets. |
262
- | `commit-and-release` | Workflow | Write commits, branches, and PR titles that satisfy the repo's own commitlint rules, and stay out of release-please's way (it owns versions and `CHANGELOG.md`). |
263
- | `task-tracking` | Workflow | Tie every unit of work to a ClickUp task (or explicit `NO-TASK`) with minimal friction via the ClickUp MCP — resolve/search a task, create one on request ("create an issue" means ClickUp, not GitHub), update it when the work lands, and carry the task key in the conventional-commit scope. |
325
+ | `commit-and-release` | Workflow | Write commit messages and PR titles that satisfy the repo's own commitlint rules, and stay out of release-please's way (it owns versions and `CHANGELOG.md`). Branch naming lives in `task-tracking`. |
326
+ | `task-tracking` | Workflow | Tie every unit of work to a ClickUp task (or explicit `NO-TASK`) with minimal friction via the ClickUp MCP — resolve/search a task, create one on request ("create an issue" means ClickUp, not GitHub), split work that spans sessions or PRs into parent + subtasks, name the branch, update the task when the work lands, and carry the task key in the commit scope. |
264
327
  | `write-a-linchpin-skill` | Meta | The house standard for authoring skills in this library — placement test, tier model, required frontmatter, the section skeleton, and the four house rules. Enforced by `scripts/validate-skills.mjs`. |
265
328
 
266
329
  _(More WordPress, React, Cloudflare Workers, marketing, and design skills to come.)_
package/bin/install.mjs CHANGED
@@ -9,6 +9,8 @@ import fs from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import os from 'node:os';
11
11
  import { execFileSync } from 'node:child_process';
12
+ import crypto from 'node:crypto';
13
+ import readline from 'node:readline';
12
14
  import { fileURLToPath } from 'node:url';
13
15
 
14
16
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -40,13 +42,28 @@ function resolveAgents(id) {
40
42
  }
41
43
 
42
44
  function parseArgs(argv) {
43
- const opts = { agent: 'claude-code', global: false, list: false, help: false, skipUpstream: false, skills: [] };
45
+ const opts = {
46
+ agent: 'claude-code',
47
+ global: false,
48
+ list: false,
49
+ help: false,
50
+ skipUpstream: false,
51
+ force: false,
52
+ check: false,
53
+ yes: false,
54
+ dryRun: false,
55
+ skills: [],
56
+ };
44
57
  for (let i = 0; i < argv.length; i++) {
45
58
  const a = argv[i];
46
59
  if (a === '--global' || a === '-g') opts.global = true;
47
60
  else if (a === '--list' || a === '-l') opts.list = true;
48
61
  else if (a === '--help' || a === '-h') opts.help = true;
49
62
  else if (a === '--skip-upstream') opts.skipUpstream = true;
63
+ else if (a === '--force' || a === '-f') opts.force = true;
64
+ else if (a === '--check') opts.check = true;
65
+ else if (a === '--yes' || a === '-y') opts.yes = true;
66
+ else if (a === '--dry-run' || a === '-n') opts.dryRun = true;
50
67
  else if (a === '--agent') opts.agent = argv[++i];
51
68
  else if (a.startsWith('--agent=')) opts.agent = a.slice('--agent='.length);
52
69
  else if (a.startsWith('-')) {
@@ -78,6 +95,206 @@ function availableSkills() {
78
95
  .sort();
79
96
  }
80
97
 
98
+ // --- Scope collisions ----------------------------------------------------------------
99
+ // Agents load every skills directory they can see and do NOT dedupe by name. The same
100
+ // skill installed at two scopes is therefore listed twice and its `description` is paid
101
+ // for twice in the context window, every session, before any work starts. Installing on
102
+ // top of an install at another scope is always waste, never a merge — so we stop.
103
+
104
+ function installedSkillsIn(dir) {
105
+ try {
106
+ return fs
107
+ .readdirSync(dir, { withFileTypes: true })
108
+ .filter((e) => e.isDirectory() && fs.existsSync(path.join(dir, e.name, 'SKILL.md')))
109
+ .map((e) => e.name)
110
+ .sort();
111
+ } catch {
112
+ return [];
113
+ }
114
+ }
115
+
116
+ function readStamp(dir) {
117
+ try {
118
+ return JSON.parse(fs.readFileSync(path.join(dir, STAMP_DIR, STAMP_FILE), 'utf8'));
119
+ } catch {
120
+ return null;
121
+ }
122
+ }
123
+
124
+ // Every directory the selected agents also read, other than the ones we're about to write:
125
+ // the opposite scope, plus any project-scope dir in a parent directory. That last case is
126
+ // the one people hit by accident — running the installer from a checkouts folder like
127
+ // ~/GitHub instead of inside a repo seeds a directory that shadows nothing and duplicates
128
+ // everything.
129
+ function rivalDirs(agentIds, opts) {
130
+ const home = os.homedir();
131
+ const seen = new Set();
132
+ const out = [];
133
+ const add = (dir, scope) => {
134
+ const resolved = path.resolve(dir);
135
+ if (seen.has(resolved)) return;
136
+ seen.add(resolved);
137
+ out.push({ dir: resolved, scope });
138
+ };
139
+
140
+ for (const id of agentIds) {
141
+ const opposite = opts.global ? AGENTS[id].project : AGENTS[id].global;
142
+ const oppositeRoot = opts.global ? process.cwd() : home;
143
+ for (const rel of opposite) add(path.join(oppositeRoot, rel), opts.global ? 'project' : 'global');
144
+
145
+ // Walk up to, but not into, home — home is the global scope, already covered above.
146
+ let cur = path.dirname(process.cwd());
147
+ while (cur.startsWith(home + path.sep)) {
148
+ for (const rel of AGENTS[id].project) add(path.join(cur, rel), 'ancestor');
149
+ const next = path.dirname(cur);
150
+ if (next === cur) break;
151
+ cur = next;
152
+ }
153
+ }
154
+ return out;
155
+ }
156
+
157
+ function findCollisions(agentIds, opts, wanted, targetDirs) {
158
+ const mine = new Set(targetDirs.map((d) => path.resolve(d)));
159
+ const want = new Set(wanted);
160
+ return rivalDirs(agentIds, opts)
161
+ .filter((r) => !mine.has(r.dir))
162
+ .map((r) => {
163
+ const present = installedSkillsIn(r.dir);
164
+ return { ...r, present, overlap: present.filter((n) => want.has(n)), stamp: readStamp(r.dir) };
165
+ })
166
+ .filter((r) => r.present.length);
167
+ }
168
+
169
+ function stampLine(stamp) {
170
+ if (!stamp) return 'no install stamp — copied by hand, or by a pre-0.2 installer';
171
+ return `v${stamp.version}, ${stamp.scope} scope, installed ${String(stamp.installedAt).slice(0, 10)}`;
172
+ }
173
+
174
+ const SCOPE_HINT = {
175
+ global: 'the user-global directory — loaded in every project',
176
+ project: 'a project directory — loaded when working in that repo',
177
+ ancestor: 'a parent of the current directory — almost certainly an installer run from the wrong folder',
178
+ };
179
+
180
+ // --- Update planning -----------------------------------------------------------------
181
+ // Re-running the installer is the update path, so a re-run should say what it is about to
182
+ // change before it changes it. Skills carry their own `version` in frontmatter, so the diff
183
+ // is per skill rather than per package — a release usually touches two or three of them.
184
+
185
+ function readSkillVersion(skillDir) {
186
+ try {
187
+ const md = fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8');
188
+ const fm = md.match(/^---\n([\s\S]*?)\n---/);
189
+ if (!fm) return null;
190
+ const v = fm[1].match(/^version:\s*(.*)$/m);
191
+ return v ? v[1].replace(/^["']|["']$/g, '').trim() : null;
192
+ } catch {
193
+ return null;
194
+ }
195
+ }
196
+
197
+ // Content fingerprint over every file in the skill, so we can tell "same version, edited
198
+ // in place" from "same version, untouched". Hand-edits in a consuming project get silently
199
+ // overwritten by an install; the least we can do is name them first.
200
+ function hashSkillDir(dir) {
201
+ const h = crypto.createHash('sha1');
202
+ const walk = (cur, rel) => {
203
+ let entries;
204
+ try {
205
+ entries = fs.readdirSync(cur, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
206
+ } catch {
207
+ return;
208
+ }
209
+ for (const e of entries) {
210
+ const next = path.join(cur, e.name);
211
+ const nextRel = rel ? `${rel}/${e.name}` : e.name;
212
+ if (e.isDirectory()) walk(next, nextRel);
213
+ else {
214
+ h.update(nextRel);
215
+ h.update('\0');
216
+ try {
217
+ h.update(fs.readFileSync(next));
218
+ } catch {
219
+ /* unreadable file — its absence from the hash is itself a difference */
220
+ }
221
+ }
222
+ }
223
+ };
224
+ walk(dir, '');
225
+ return h.digest('hex');
226
+ }
227
+
228
+ function compareSemver(a, b) {
229
+ if (!a || !b) return null;
230
+ const pa = String(a).split('.').map((n) => parseInt(n, 10));
231
+ const pb = String(b).split('.').map((n) => parseInt(n, 10));
232
+ for (let i = 0; i < 3; i++) {
233
+ const x = pa[i] || 0;
234
+ const y = pb[i] || 0;
235
+ if (x !== y) return x < y ? -1 : 1;
236
+ }
237
+ return 0;
238
+ }
239
+
240
+ // What installing `wanted` into `base` would actually do, per skill.
241
+ function planFor(base, wanted) {
242
+ return wanted.map((name) => {
243
+ const src = path.join(SKILLS_ROOT, name);
244
+ const dest = path.join(base, name);
245
+ const to = readSkillVersion(src);
246
+ if (!fs.existsSync(path.join(dest, 'SKILL.md'))) return { name, from: null, to, status: 'new' };
247
+
248
+ const from = readSkillVersion(dest);
249
+ const cmp = compareSemver(from, to);
250
+ if (cmp === -1) return { name, from, to, status: 'update' };
251
+ if (cmp === 1) return { name, from, to, status: 'downgrade' };
252
+ if (hashSkillDir(src) !== hashSkillDir(dest)) return { name, from, to, status: 'modified' };
253
+ return { name, from, to, status: 'unchanged' };
254
+ });
255
+ }
256
+
257
+ const PLAN_LABEL = {
258
+ new: 'new',
259
+ update: 'update',
260
+ downgrade: 'DOWNGRADE',
261
+ modified: 'local edits will be lost',
262
+ unchanged: 'unchanged',
263
+ };
264
+
265
+ function renderPlan(plan) {
266
+ const shown = plan.filter((p) => p.status !== 'unchanged');
267
+ const width = Math.max(0, ...shown.map((p) => p.name.length));
268
+ for (const p of shown) {
269
+ const ver =
270
+ p.status === 'new'
271
+ ? ` -> v${p.to}`
272
+ : p.from === p.to
273
+ ? ` v${p.from}`
274
+ : ` v${p.from} -> v${p.to}`;
275
+ console.log(` ${p.name.padEnd(width)}${ver} ${PLAN_LABEL[p.status]}`);
276
+ }
277
+ const same = plan.length - shown.length;
278
+ if (same) console.log(` (${same} unchanged)`);
279
+ }
280
+
281
+ function confirm(question) {
282
+ return new Promise((resolve) => {
283
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
284
+ let done = false;
285
+ const finish = (value) => {
286
+ if (done) return;
287
+ done = true;
288
+ rl.close();
289
+ resolve(value);
290
+ };
291
+ // Ctrl-D or a closed stdin resolves as "no" — without this the promise never settles
292
+ // and the installer exits silently having done nothing.
293
+ rl.on('close', () => finish(false));
294
+ rl.question(question, (answer) => finish(/^y(es)?$/i.test(answer.trim())));
295
+ });
296
+ }
297
+
81
298
  function readUpstreamManifest() {
82
299
  try {
83
300
  const m = JSON.parse(fs.readFileSync(UPSTREAM_MANIFEST, 'utf8'));
@@ -199,15 +416,35 @@ Arguments:
199
416
 
200
417
  Options:
201
418
  -l, --list List available skills (Linchpin + pinned base layer) and exit
419
+ --check Audit every scope this agent reads for duplicate skills; install nothing
202
420
  -g, --global Install to the user-global skills dir instead of the project
203
421
  --agent <id> Target agent: claude-code (default) | github-copilot | codex | cursor
204
422
  | all (installs into every agent's directory)
205
423
  --skip-upstream Install only Linchpin skills; don't vendor the upstream base layer
424
+ -f, --force Reinstall everything, and install even if these skills already exist
425
+ at another scope
426
+ -y, --yes Skip the confirmation prompt (implied when not a TTY)
427
+ -n, --dry-run Show what would change and exit without writing anything
206
428
  -h, --help Show this help
207
429
 
430
+ Updating:
431
+ Re-running is the update path. The installer compares each skill's version against what
432
+ is installed, prints what would change, and asks before touching anything. A run with
433
+ nothing to change exits early. --dry-run shows the diff and writes nothing; note that a
434
+ non-interactive run (piped stdin, CI) proceeds without prompting.
435
+
436
+ Scopes:
437
+ Agents load every skills directory they find and do not dedupe by name, so a skill
438
+ installed both globally and in a project is listed twice and costs its description
439
+ twice in every session. Installing over another scope aborts unless you pass --force.
440
+ Use --check to see what is already installed where.
441
+
208
442
  Examples:
209
443
  npx @linchpinagency/skills # Linchpin skills + base layer -> ./.claude/skills
210
444
  npx @linchpinagency/skills wp-studio-cli # one Linchpin skill (+ base layer)
445
+ npx @linchpinagency/skills --check # audit scopes, install nothing
446
+ npx @linchpinagency/skills --dry-run # preview an update, write nothing
447
+ npx @linchpinagency/skills --yes # update without the confirmation prompt
211
448
  npx @linchpinagency/skills --skip-upstream # Linchpin skills only
212
449
  npx @linchpinagency/skills --agent github-copilot
213
450
  npx @linchpinagency/skills --agent all # every agent dir in this project
@@ -263,16 +500,150 @@ async function main() {
263
500
  }
264
501
  if (!wanted.length && !sources.length) return console.log('No skills to install.');
265
502
 
503
+ const labels = agentIds.map((id) => AGENTS[id].label).join(', ');
504
+
505
+ if (opts.check) {
506
+ // An audit reports what is actually installed, not just what this package ships —
507
+ // duplicates seeded by an older version or another library still cost context.
508
+ const installedHere = new Set(targets.flatMap((t) => installedSkillsIn(t.dir)));
509
+ const audit = findCollisions(agentIds, opts, [...installedHere], bases);
510
+ console.log(`Skill directories ${labels} reads, for this project:\n`);
511
+ for (const t of targets) {
512
+ const have = installedSkillsIn(t.dir);
513
+ console.log(` [target] ${t.dir}`);
514
+ console.log(` ${have.length} skill(s) — ${stampLine(readStamp(t.dir))}\n`);
515
+ }
516
+ for (const c of audit) {
517
+ console.log(` [${c.scope}] ${c.dir}`);
518
+ console.log(` ${c.present.length} skill(s) — ${stampLine(c.stamp)}`);
519
+ console.log(` ${SCOPE_HINT[c.scope]}`);
520
+ if (c.overlap.length) console.log(` ${c.overlap.length} duplicate(s): ${c.overlap.join(', ')}`);
521
+ console.log();
522
+ }
523
+ const dupes = audit.reduce((n, c) => n + c.overlap.length, 0);
524
+ if (!dupes) {
525
+ console.log('No duplicate skills across scopes.');
526
+ return;
527
+ }
528
+ console.log(`${dupes} duplicate skill copies across scopes. Each one's description is loaded`);
529
+ console.log('once per copy, every session. Remove whichever copy you do not want.');
530
+ process.exitCode = 1;
531
+ return;
532
+ }
533
+
534
+ // Plan first: whether a collision is a problem depends on whether this run would be
535
+ // *creating* the second copy or merely maintaining one that already exists.
536
+ const version = packageVersion();
537
+ const plans = targets.map((t) => ({ target: t, plan: planFor(t.dir, wanted) }));
538
+ const changed = plans.flatMap((p) => p.plan).filter((p) => p.status !== 'unchanged');
539
+
540
+ // A skill that is 'new' in every target but already present at another scope is a
541
+ // duplicate about to be born — that we refuse. A skill already installed here is an
542
+ // update; refusing it would only strand someone on a stale copy without removing the
543
+ // duplication, so it warns instead.
544
+ const arriving = new Set(
545
+ plans.flatMap(({ plan }) => plan.filter((p) => p.status === 'new').map((p) => p.name))
546
+ );
547
+ const existing = new Set(
548
+ plans.flatMap(({ plan }) => plan.filter((p) => p.status !== 'new').map((p) => p.name))
549
+ );
550
+ const collisions = findCollisions(agentIds, opts, wanted, bases);
551
+ const wouldCreate = collisions
552
+ .map((c) => ({ ...c, overlap: c.overlap.filter((n) => arriving.has(n) && !existing.has(n)) }))
553
+ .filter((c) => c.overlap.length);
554
+ const preExisting = collisions
555
+ .map((c) => ({ ...c, overlap: c.overlap.filter((n) => existing.has(n)) }))
556
+ .filter((c) => c.overlap.length);
557
+
558
+ if (wouldCreate.length && !opts.force) {
559
+ const dupes = wouldCreate.reduce((n, c) => n + c.overlap.length, 0);
560
+ console.error(`Refusing to install: ${dupes} of these skills are already installed at another scope.\n`);
561
+ for (const c of wouldCreate) {
562
+ console.error(` ${c.dir}`);
563
+ console.error(` ${SCOPE_HINT[c.scope]}`);
564
+ console.error(` ${stampLine(c.stamp)}`);
565
+ console.error(` would duplicate: ${c.overlap.join(', ')}\n`);
566
+ }
567
+ console.error(`${labels} loads every directory it finds and does not dedupe by name, so each`);
568
+ console.error('duplicate is listed twice and costs its description twice in every session.\n');
569
+ console.error('Pick one scope:');
570
+ console.error(' - keep the existing copy — nothing to do here; this install would be redundant');
571
+ console.error(' - move them here — drop just the duplicates at the other scope, then re-run:');
572
+ for (const c of wouldCreate) {
573
+ console.error(` (cd ${c.dir} && rm -rf ${c.overlap.join(' ')})`);
574
+ }
575
+ console.error(' - keep both anyway — re-run with --force');
576
+ console.error('\nRun with --check to audit every scope without installing.');
577
+ process.exit(1);
578
+ }
579
+
580
+ if (preExisting.length) {
581
+ const dupes = preExisting.reduce((n, c) => n + c.overlap.length, 0);
582
+ console.log(`! ${dupes} of these skills are also installed at another scope, and were before this run:`);
583
+ for (const c of preExisting) console.log(` ${c.dir} (${c.scope})`);
584
+ console.log(' Updating here does not fix that. Run --check for the duplicates and how to drop them.\n');
585
+ }
586
+
587
+ // A re-run is the update path, so most runs land here with a handful of skills to
588
+ // update and the rest already current.
589
+ if (!changed.length && !opts.force) {
590
+ console.log(`Already up to date — ${wanted.length} skill(s) for ${labels}, nothing to change.`);
591
+ if (!opts.dryRun) console.log('Re-run with --force to reinstall anyway.');
592
+ return;
593
+ }
594
+
595
+ if (changed.length) {
596
+ console.log(`@linchpinagency/skills v${version} — ${labels}\n`);
597
+ for (const { target, plan } of plans) {
598
+ if (plans.length > 1) console.log(`${target.dir}`);
599
+ renderPlan(plan);
600
+ if (plans.length > 1) console.log();
601
+ }
602
+
603
+ const edited = changed.filter((p) => p.status === 'modified');
604
+ if (edited.length) {
605
+ console.log(`\n! ${edited.length} skill(s) were edited in place and will be overwritten.`);
606
+ console.log(' Skills are owned by this package — change them in the library, not the install.');
607
+ }
608
+ const down = changed.filter((p) => p.status === 'downgrade');
609
+ if (down.length) {
610
+ console.log(`\n! ${down.length} skill(s) would go BACKWARDS — this package is older than what is installed.`);
611
+ console.log(' Check the version you invoked before continuing.');
612
+ }
613
+
614
+ const counts = ['new', 'update', 'downgrade', 'modified']
615
+ .map((k) => [k, changed.filter((p) => p.status === k).length])
616
+ .filter(([, n]) => n)
617
+ .map(([k, n]) => `${n} ${k}`)
618
+ .join(', ');
619
+ console.log(`\n${changed.length} change(s): ${counts}`);
620
+
621
+ if (opts.dryRun) {
622
+ console.log('\nDry run — nothing was written.');
623
+ return;
624
+ }
625
+
626
+ if (!opts.yes && !opts.force) {
627
+ if (!process.stdin.isTTY) {
628
+ console.log('Non-interactive — proceeding. Pass --yes to silence this notice.');
629
+ } else if (!(await confirm('\nApply? [y/N] '))) {
630
+ console.log('Nothing changed.');
631
+ return;
632
+ }
633
+ }
634
+ console.log();
635
+ }
636
+
637
+ const applied = new Set(changed.map((p) => p.name));
266
638
  for (const base of bases) {
267
639
  fs.mkdirSync(base, { recursive: true });
268
640
  for (const name of wanted) {
269
641
  const dest = path.join(base, name);
270
642
  fs.rmSync(dest, { recursive: true, force: true });
271
643
  fs.cpSync(path.join(SKILLS_ROOT, name), dest, { recursive: true });
272
- console.log(`✓ ${name} -> ${dest}`);
644
+ if (applied.has(name) || opts.force) console.log(`✓ ${name} -> ${dest}`);
273
645
  }
274
646
  }
275
- const labels = agentIds.map((id) => AGENTS[id].label).join(', ');
276
647
  console.log(`\nInstalled ${wanted.length} Linchpin skill(s) for ${labels}.`);
277
648
 
278
649
  const upstream = [];
@@ -286,7 +657,6 @@ async function main() {
286
657
  }
287
658
 
288
659
  // Stamp last, so `upstream` reflects what actually landed rather than what was intended.
289
- const version = packageVersion();
290
660
  const stamped = targets.filter((t) => writeStamp(t, { version, opts, skills: wanted, upstream }));
291
661
  if (stamped.length && agentIds.includes('claude-code')) {
292
662
  const rel = path.join(STAMP_DIR, CHECKER);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linchpinagency/skills",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "Linchpin's library of reusable AI agent skills for WordPress projects.",
5
5
  "type": "module",
6
6
  "bin": {