@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 +72 -9
- package/bin/install.mjs +374 -4
- package/package.json +1 -1
- package/skills/agent-capabilities/SKILL.md +139 -0
- package/skills/agent-capabilities/references/mcp-scoping.md +96 -0
- package/skills/agent-capabilities/references/skills-audit.md +116 -0
- package/skills/browser-automation/SKILL.md +7 -1
- package/skills/commit-and-release/SKILL.md +66 -11
- package/skills/design-previews/SKILL.md +7 -6
- package/skills/project-context/SKILL.md +16 -1
- package/skills/quality-gates/SKILL.md +54 -1
- package/skills/task-tracking/SKILL.md +157 -174
- package/skills/task-tracking/references/clickup-json.md +14 -0
- package/skills/task-tracking/references/clickup-mcp-tools.md +37 -0
- package/skills/task-tracking/references/handoff.md +40 -0
- package/skills/wp-audit/SKILL.md +2 -2
- package/skills/wp-local-setup/SKILL.md +8 -85
- package/skills/wp-local-setup/references/scaffold-baselines.md +83 -0
- package/skills/wp-studio-cli/SKILL.md +5 -0
- package/skills/write-a-linchpin-skill/SKILL.md +7 -0
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ GitHub Copilot, and other compatible coding agents.
|
|
|
12
12
|

|
|
13
13
|
|
|
14
14
|
<!-- x-release-please-start-version -->
|
|
15
|
-
### Latest release: 0.1.
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
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) —
|
|
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
|
|
192
|
-
|
|
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
|
|
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
|
|
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 = {
|
|
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);
|