sshlg-skills 0.22.2 → 0.23.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/CHANGELOG.md +71 -0
- package/README.md +71 -3
- package/bin/sshlg-skills.js +121 -5
- package/lib/apply.js +30 -5
- package/lib/config.js +81 -0
- package/lib/consent.js +10 -17
- package/lib/migrate.js +124 -8
- package/lib/router-texts.js +50 -70
- package/lib/routers-registry.js +303 -0
- package/lib/routers.js +85 -18
- package/lib/store.js +50 -0
- package/package.json +5 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,76 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v0.23.0 — 2026-08-06
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **The second `routers` run destroyed the block the first one wrote.** The
|
|
8
|
+
block's own heading is `## Роутинг работы — семья ssheleg`; migration's
|
|
9
|
+
pattern for the hand-written rule is `## Роутинг работы`. On the second run
|
|
10
|
+
it took the block's heading for a hand-written rule and cut from there to the
|
|
11
|
+
next H1 or end of file — carrying the closing sentinel, and every rule the
|
|
12
|
+
operator kept below it, out of `~/.claude/CLAUDE.md`. The command then
|
|
13
|
+
reported the block malformed and refused to touch it again. Shipped in
|
|
14
|
+
v0.22.0 and reproduced against it.
|
|
15
|
+
|
|
16
|
+
It hid because idempotence was proven on `upsert`, which is pure, while the
|
|
17
|
+
damage happened one layer up in the command that runs migration first. On
|
|
18
|
+
this machine it cost nothing only because the block had never been written.
|
|
19
|
+
Migration now never reads inside the managed block, and **skips past it
|
|
20
|
+
rather than stopping at it**, so a hand-written rule *below* the block is
|
|
21
|
+
still found.
|
|
22
|
+
|
|
23
|
+
- **A section ended at the next `## `, and an `# ` heading did not stop it.**
|
|
24
|
+
In the operator's real file the section to be cut is followed by exactly
|
|
25
|
+
that, so the cut ran to end of file. A `### ` subheading still does not end
|
|
26
|
+
its parent.
|
|
27
|
+
|
|
28
|
+
- **`npm test` did not exist.** Eight suites and two validators ran only in
|
|
29
|
+
CI; locally the standard command failed with `Missing script: "test"`.
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
|
|
33
|
+
- **Eight routers, declared once each.** A router used to be written twice —
|
|
34
|
+
its text in `router-texts.js`, its table row in `routers.js` — with nothing
|
|
35
|
+
comparing the halves. One registry entry now carries what a router is: the
|
|
36
|
+
members it needs, its two table cells, its text. Key order is table order.
|
|
37
|
+
- New: `sheleg-design` (how it looks and moves), `make-skill` (how the skill
|
|
38
|
+
itself is built), `agent-sync` (who is holding this file).
|
|
39
|
+
- New and backed by **no skill at all**: `seo-llmo` and `evidence-docs` are
|
|
40
|
+
rules rather than tools, so they hold whether or not anything is
|
|
41
|
+
installed. That is why a router stopped being a property of a member.
|
|
42
|
+
- `task-pipeline`'s text now claims **planning** as its own stages 2–4,
|
|
43
|
+
which retires the competing planning rule beside it: a superseded heading
|
|
44
|
+
is removed only when the router replacing it is actually written, and its
|
|
45
|
+
body is kept rather than dropped.
|
|
46
|
+
- **`sshlg-skills config`** — every router on or off by name. Switching one
|
|
47
|
+
off removes its section and its table row; switching it back on restores the
|
|
48
|
+
**exact bytes**, including wording of the operator's that migration had
|
|
49
|
+
moved in. `~/.claude/CLAUDE.md` has no version control behind it, so a
|
|
50
|
+
toggle that regenerated the packaged text would erase a person's words
|
|
51
|
+
silently.
|
|
52
|
+
- Settings store **deviations only**, so a router added in a later release
|
|
53
|
+
arrives switched on rather than silently off.
|
|
54
|
+
- Sections are kept in registry order, so the block's reading order cannot
|
|
55
|
+
drift from the table it renders.
|
|
56
|
+
|
|
57
|
+
### Changed
|
|
58
|
+
|
|
59
|
+
- `npm test` is the one entry point, and it **discovers** `test/*_test.js`
|
|
60
|
+
instead of listing them. An empty run fails rather than reporting green, and
|
|
61
|
+
`validate.py` fails if CI stops calling `npm test` — a guard that shipped
|
|
62
|
+
wrong the first time (it matched the substring anywhere in the workflow,
|
|
63
|
+
which the negative self-tests satisfy) and was tightened after being planted
|
|
64
|
+
and watched.
|
|
65
|
+
- `node --check` on the launcher is replaced by running the launcher, per the
|
|
66
|
+
retrospective's first standing instruction.
|
|
67
|
+
- **179 fixtures across eight suites, up from 75 across five** — both numbers
|
|
68
|
+
counted by running the suites, not carried over. The previous release notes
|
|
69
|
+
claimed 71 and its acceptance record claimed 74; the count at that commit is
|
|
70
|
+
75. Three numbers, one measurement, and the two that were restated are the
|
|
71
|
+
two that were wrong. That is the `evidence-docs` router's entire argument,
|
|
72
|
+
arriving from this repository's own paperwork.
|
|
73
|
+
|
|
3
74
|
## v0.22.2 — 2026-08-05
|
|
4
75
|
|
|
5
76
|
### Fixed
|
package/README.md
CHANGED
|
@@ -117,6 +117,59 @@ installed, so it takes no `--agent`.
|
|
|
117
117
|
> plugin. The launcher passes the agent list explicitly and prunes those copies
|
|
118
118
|
> after every run.
|
|
119
119
|
|
|
120
|
+
## Routing — making the family engage by default
|
|
121
|
+
|
|
122
|
+
A skill's `description` influences whether a model reaches for it. It does not
|
|
123
|
+
oblige. So the pack writes a **managed routing block** into your global agent
|
|
124
|
+
instructions (`~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md`), and the rules
|
|
125
|
+
engage in every project instead of only when someone remembers to ask.
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
npx sshlg-skills routers # write it (asks once)
|
|
129
|
+
npx sshlg-skills routers --dry-run # show the diff, change nothing
|
|
130
|
+
npx sshlg-skills routers --update # refresh an existing block, never create one
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Eight routers, and they are **different axes rather than competing
|
|
134
|
+
priorities** — a landing page passes several, an internal script passes none:
|
|
135
|
+
|
|
136
|
+
| Router | Answers | When | Needs installed |
|
|
137
|
+
|---|---|---|---|
|
|
138
|
+
| `super-ux` | what the interface must do | there is user-facing behaviour | super-ux |
|
|
139
|
+
| `sheleg-design` | how it looks and moves | there is a visual layer | sheleg-design |
|
|
140
|
+
| `copywriting` | how it sounds | text a product user will read | super-ux |
|
|
141
|
+
| `seo-llmo` | whether a machine will find it | a logged-out reader can see it | — |
|
|
142
|
+
| `evidence-docs` | what proves it | something is stated as true | — |
|
|
143
|
+
| `task-pipeline` | how the change reaches the repo | the repository changes | task-pipeline |
|
|
144
|
+
| `make-skill` | how the skill itself is built | a skill or plugin changes shape | make-skill |
|
|
145
|
+
| `agent-sync` | who is holding this file | the project has coordination on | agent-sync |
|
|
146
|
+
|
|
147
|
+
Two of them need no skill behind them. `seo-llmo` and `evidence-docs` are
|
|
148
|
+
rules, not tools, so they hold whether or not anything is installed.
|
|
149
|
+
|
|
150
|
+
**Your own wording wins.** Where you already wrote a rule by hand, migration
|
|
151
|
+
moves *your* text in verbatim — asides included — and the packaged default is
|
|
152
|
+
used only for a router you never wrote.
|
|
153
|
+
|
|
154
|
+
**Consent is asked once**, recorded, and never asked again. Declining leaves an
|
|
155
|
+
`SSHLG:ROUTERS:OPTOUT` marker, which the block's own header names as the way
|
|
156
|
+
out and which survives a reinstall and a restored dotfile. Everything outside
|
|
157
|
+
the sentinels is preserved byte for byte.
|
|
158
|
+
|
|
159
|
+
### Turning routers off
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
npx sshlg-skills config # what this machine wants
|
|
163
|
+
npx sshlg-skills config set routers.seo-llmo off
|
|
164
|
+
npx sshlg-skills routers --update # apply it
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Switching a router off **removes its section** and drops its table row.
|
|
168
|
+
Switching it back on restores the exact bytes that were there — including
|
|
169
|
+
wording of yours that migration had moved in. Settings live in
|
|
170
|
+
`~/.sshlg-skills/config.json` (mode 0600) and store deviations only, so a
|
|
171
|
+
router added in a later release arrives switched on rather than silently off.
|
|
172
|
+
|
|
120
173
|
## Other commands
|
|
121
174
|
|
|
122
175
|
```bash
|
|
@@ -136,8 +189,14 @@ get wrong: one channel per agent, exact agent ids, repeated `--agent` flags, ful
|
|
|
136
189
|
```
|
|
137
190
|
skills.json registry — repos, plugin ids, skill names, pins
|
|
138
191
|
skills/* the six skills as pinned git submodules
|
|
139
|
-
bin/sshlg-skills.js the launcher (install / update / list / agents)
|
|
192
|
+
bin/sshlg-skills.js the launcher (install / update / routers / config / list / agents)
|
|
193
|
+
lib/routers-registry.js the eight routers — text, table row and required members, in one entry
|
|
194
|
+
lib/routers.js block parsing and rendering; touches no file, by construction
|
|
195
|
+
lib/apply.js the only module that writes to the instruction files
|
|
196
|
+
lib/migrate.js moves hand-written rules in; never reads inside the block
|
|
197
|
+
lib/config.js, lib/store.js the pack's settings, and the 0600 discipline they share
|
|
140
198
|
install.sh POSIX fallback (macOS/Linux; use npx on Windows)
|
|
199
|
+
test/run.js `npm test` — the validator, then every discovered suite
|
|
141
200
|
test/validate.py registry / submodules / version validation
|
|
142
201
|
.github/workflows validation on push and PR + tag-driven release
|
|
143
202
|
```
|
|
@@ -150,13 +209,22 @@ commit installs exactly the skill versions that commit was tested with.
|
|
|
150
209
|
## Development
|
|
151
210
|
|
|
152
211
|
```bash
|
|
153
|
-
|
|
212
|
+
npm test
|
|
154
213
|
```
|
|
155
214
|
|
|
215
|
+
One entry point: it runs the structural validator, then **discovers** every
|
|
216
|
+
`test/*_test.js` rather than listing them — a list would live in
|
|
217
|
+
`package.json` and in the workflow at once, and drift the first time a suite
|
|
218
|
+
was added to one side. An empty run fails rather than reporting green, and
|
|
219
|
+
`validate.py` fails if CI stops calling `npm test`.
|
|
220
|
+
|
|
156
221
|
```bash
|
|
157
|
-
|
|
222
|
+
python3 test/check_pins.py
|
|
158
223
|
```
|
|
159
224
|
|
|
225
|
+
Kept out of `npm test` on purpose: it queries the npm registry, and the rest
|
|
226
|
+
must work offline.
|
|
227
|
+
|
|
160
228
|
Releases are tag-driven: bump `skills.json`, `package.json` and the top
|
|
161
229
|
`CHANGELOG.md` entry together, tag `vX.Y.Z`, and the release workflow cuts the
|
|
162
230
|
GitHub release from the matching changelog section.
|
package/bin/sshlg-skills.js
CHANGED
|
@@ -92,6 +92,9 @@ Skills: ${SKILLS.map(s => s.name).join(', ')}
|
|
|
92
92
|
Usage:
|
|
93
93
|
npx sshlg-skills install [--agent a,b | --all] [--no-claude] [--claude-only]
|
|
94
94
|
npx sshlg-skills update [--agent a,b | --all] [--no-claude] [--claude-only] [--bump-pins]
|
|
95
|
+
npx sshlg-skills routers [--member <name>] [--update] [--dry-run]
|
|
96
|
+
npx sshlg-skills config [list]
|
|
97
|
+
npx sshlg-skills config set routers.<name> on|off
|
|
95
98
|
npx sshlg-skills list
|
|
96
99
|
npx sshlg-skills agents
|
|
97
100
|
|
|
@@ -208,7 +211,8 @@ function cmdAgents() {
|
|
|
208
211
|
* worse than no table.
|
|
209
212
|
*/
|
|
210
213
|
function cmdRouters(f) {
|
|
211
|
-
const
|
|
214
|
+
const registry = require('../lib/routers-registry.js');
|
|
215
|
+
const configLib = require('../lib/config.js');
|
|
212
216
|
const apply = require('../lib/apply.js');
|
|
213
217
|
const consent = require('../lib/consent.js');
|
|
214
218
|
const migrate = require('../lib/migrate.js');
|
|
@@ -216,9 +220,31 @@ function cmdRouters(f) {
|
|
|
216
220
|
const path = require('path');
|
|
217
221
|
const home = process.env.HOME || require('os').homedir();
|
|
218
222
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
223
|
+
// The settings decide which routers this machine wants; the registry decides
|
|
224
|
+
// which ones it may speak for. With --member, only that member's own — a
|
|
225
|
+
// lone installer speaks for itself and never for the family's rules.
|
|
226
|
+
const config = configLib.readConfig(home);
|
|
227
|
+
const isEnabled = (n) => configLib.isEnabled(config, n);
|
|
228
|
+
const scopeOpts = f.member
|
|
229
|
+
? { member: f.member, isEnabled }
|
|
230
|
+
: { installed: manifest.skills.map((s) => s.name), isEnabled };
|
|
231
|
+
|
|
232
|
+
const packaged = registry.resolve(scopeOpts);
|
|
233
|
+
const remove = registry.disabled(scopeOpts);
|
|
234
|
+
|
|
235
|
+
// A stashed body outranks the packaged text: it is what was in the block
|
|
236
|
+
// before the router was switched off, which on this machine may be the
|
|
237
|
+
// operator's own wording that migration moved in.
|
|
238
|
+
const restored = [];
|
|
239
|
+
for (const name of Object.keys(packaged)) {
|
|
240
|
+
const stashed = configLib.stashGet(config, name);
|
|
241
|
+
if (stashed !== undefined) {
|
|
242
|
+
packaged[name] = stashed;
|
|
243
|
+
restored.push(name);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (!Object.keys(packaged).length && !remove.length) {
|
|
222
248
|
log('routers: ни один установленный участник не даёт роутера — нечего писать');
|
|
223
249
|
return true;
|
|
224
250
|
}
|
|
@@ -243,6 +269,8 @@ function cmdRouters(f) {
|
|
|
243
269
|
|
|
244
270
|
// Hand-written rules win over the packaged text, so migration runs first and
|
|
245
271
|
// its result is what gets written.
|
|
272
|
+
const superseded = {};
|
|
273
|
+
const sources = {};
|
|
246
274
|
for (const t of apply.TARGETS) {
|
|
247
275
|
const file = path.join(home, t.dir, t.file);
|
|
248
276
|
if (!fs.existsSync(file)) continue;
|
|
@@ -251,18 +279,103 @@ function cmdRouters(f) {
|
|
|
251
279
|
if (moved.text !== src && decision === 'yes' && !f.dryRun) {
|
|
252
280
|
fs.writeFileSync(file, moved.text, 'utf8');
|
|
253
281
|
}
|
|
282
|
+
// Hand the migrated text on rather than letting apply re-read the file:
|
|
283
|
+
// on a dry run nothing was written, and re-reading would preview the
|
|
284
|
+
// additions without the removals that come with them.
|
|
285
|
+
sources[file] = decision === 'yes' ? moved.text : src;
|
|
254
286
|
Object.assign(packaged, moved.routers);
|
|
287
|
+
Object.assign(superseded, moved.superseded);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const res = apply.apply({
|
|
291
|
+
home, mode, consent: decision, routers: packaged,
|
|
292
|
+
remove, sources, dryRun: f.dryRun, log,
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// Whatever left the block is kept, not dropped. A switch that loses the
|
|
296
|
+
// operator's wording on the way out is a switch nobody dares use twice, and
|
|
297
|
+
// the file it was taken from has no version control behind it.
|
|
298
|
+
if (!f.dryRun && decision === 'yes') {
|
|
299
|
+
for (const r of res.targets) {
|
|
300
|
+
for (const [name, body] of Object.entries(r.removed || {})) {
|
|
301
|
+
configLib.stashSet(home, name, body);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
for (const [id, body] of Object.entries(superseded)) {
|
|
305
|
+
configLib.stashSet(home, 'superseded:' + id, body);
|
|
306
|
+
}
|
|
307
|
+
// A restored section is live again, so its copy in the settings is now a
|
|
308
|
+
// stale duplicate of text that has a home.
|
|
309
|
+
for (const name of restored) configLib.stashClear(home, name);
|
|
255
310
|
}
|
|
256
311
|
|
|
257
|
-
const res = apply.apply({ home, mode, consent: decision, routers: packaged, dryRun: f.dryRun, log });
|
|
258
312
|
for (const r of res.targets) {
|
|
259
313
|
if (r.action === 'agent-absent') continue;
|
|
260
314
|
log(`routers: ${r.file} — ${r.action}`);
|
|
261
315
|
if (r.diff) log(r.diff);
|
|
262
316
|
}
|
|
317
|
+
if (remove.length) log(`routers: выключены настройкой — ${remove.join(', ')}`);
|
|
318
|
+
for (const id of Object.keys(superseded)) {
|
|
319
|
+
log(`routers: вытеснен рукописный раздел "${id}" — тело сохранено в ${configLib.configPath(home)}`);
|
|
320
|
+
}
|
|
263
321
|
return true;
|
|
264
322
|
}
|
|
265
323
|
|
|
324
|
+
/**
|
|
325
|
+
* `config` — the pack's settings.
|
|
326
|
+
*
|
|
327
|
+
* Kept out of `parseFlags` on purpose: its arguments are positional, and that
|
|
328
|
+
* parser exits on anything that is not a known flag.
|
|
329
|
+
*/
|
|
330
|
+
function cmdConfig(argv) {
|
|
331
|
+
const registry = require('../lib/routers-registry.js');
|
|
332
|
+
const configLib = require('../lib/config.js');
|
|
333
|
+
const home = process.env.HOME || require('os').homedir();
|
|
334
|
+
const [sub, key, value] = argv;
|
|
335
|
+
|
|
336
|
+
if (!sub || sub === 'list') {
|
|
337
|
+
const config = configLib.readConfig(home);
|
|
338
|
+
log(`Настройки пака — ${configLib.configPath(home)}\n`);
|
|
339
|
+
for (const name of registry.order()) {
|
|
340
|
+
log(` routers.${name.padEnd(16)} ${configLib.isEnabled(config, name) ? 'on' : 'off'}`);
|
|
341
|
+
}
|
|
342
|
+
log('\nПоменять: npx sshlg-skills config set routers.<имя> on|off');
|
|
343
|
+
return 0;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (sub !== 'set') {
|
|
347
|
+
log(`config: неизвестная подкоманда "${sub}" — есть list и set`);
|
|
348
|
+
return 2;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (!key || key.indexOf('routers.') !== 0) {
|
|
352
|
+
log('config set: ключ должен начинаться с "routers." — других разделов пока нет');
|
|
353
|
+
return 2;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const name = key.slice('routers.'.length);
|
|
357
|
+
if (!Object.prototype.hasOwnProperty.call(registry.REGISTRY, name)) {
|
|
358
|
+
log(`config set: роутера "${name}" нет. Валидные имена:`);
|
|
359
|
+
for (const n of registry.order()) log(` routers.${n}`);
|
|
360
|
+
return 2;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (configLib.STATES.indexOf(value) === -1) {
|
|
364
|
+
log(`config set: состояние должно быть on или off — получено "${value === undefined ? '' : value}"`);
|
|
365
|
+
return 2;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const was = configLib.isEnabled(configLib.readConfig(home), name) ? 'on' : 'off';
|
|
369
|
+
if (was === value) {
|
|
370
|
+
log(`routers.${name}: ${value} (без изменений)`);
|
|
371
|
+
return 0;
|
|
372
|
+
}
|
|
373
|
+
configLib.setRouter(home, name, value);
|
|
374
|
+
log(`routers.${name}: ${was} → ${value}`);
|
|
375
|
+
log('Запусти `npx sshlg-skills routers --update`, чтобы применить.');
|
|
376
|
+
return 0;
|
|
377
|
+
}
|
|
378
|
+
|
|
266
379
|
function readLineSync() {
|
|
267
380
|
const fs = require('fs');
|
|
268
381
|
const buf = Buffer.alloc(1024);
|
|
@@ -279,6 +392,9 @@ function main(argv) {
|
|
|
279
392
|
if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') { usage(); return 0; }
|
|
280
393
|
if (cmd === 'list' || cmd === 'ls') { cmdList(); return 0; }
|
|
281
394
|
if (cmd === 'agents') { cmdAgents(); return 0; }
|
|
395
|
+
// Before parseFlags: `config` takes positional arguments, and that parser
|
|
396
|
+
// exits on any token it does not recognise as a flag.
|
|
397
|
+
if (cmd === 'config') return cmdConfig(rest);
|
|
282
398
|
const f = parseFlags(rest);
|
|
283
399
|
if (cmd === 'install' || cmd === 'i') return cmdInstall(f) ? 0 : 1;
|
|
284
400
|
if (cmd === 'update' || cmd === 'up') return cmdUpdate(f) ? 0 : 1;
|
package/lib/apply.js
CHANGED
|
@@ -53,9 +53,30 @@ function writeOptOut(file) {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* `opts.sources[file]` is the text to work FROM, when the caller already has
|
|
58
|
+
* a version the disk does not.
|
|
59
|
+
*
|
|
60
|
+
* Migration runs before this module and, on a dry run, deliberately does not
|
|
61
|
+
* write. Re-reading the file then showed a preview of the block being added
|
|
62
|
+
* with none of the hand-written sections being removed — +361 lines against
|
|
63
|
+
* -1, where the real run removes about a hundred and thirty. A preview that
|
|
64
|
+
* under-reports its own removals is worse than no preview: it is read as
|
|
65
|
+
* permission.
|
|
66
|
+
*
|
|
67
|
+
* Passing the source in also makes the dry and the wet path compute from the
|
|
68
|
+
* same bytes, rather than agreeing because the wet one happened to write
|
|
69
|
+
* first.
|
|
70
|
+
*/
|
|
56
71
|
function applyOne(file, routers, opts) {
|
|
57
72
|
const exists = fs.existsSync(file);
|
|
58
|
-
|
|
73
|
+
// Two different texts, and conflating them is the bug this exists to fix.
|
|
74
|
+
// `onDisk` is what the operator has right now and the only honest baseline
|
|
75
|
+
// for a diff. `before` is what to compute from — the migrated text on a dry
|
|
76
|
+
// run, where migration deliberately wrote nothing.
|
|
77
|
+
const onDisk = exists ? fs.readFileSync(file, 'utf8') : '';
|
|
78
|
+
const supplied = opts.sources && Object.prototype.hasOwnProperty.call(opts.sources, file);
|
|
79
|
+
const before = supplied ? opts.sources[file] : onDisk;
|
|
59
80
|
const parsed = R.parse(before);
|
|
60
81
|
|
|
61
82
|
if (parsed.state === R.STATE.OPTED_OUT) return { file, action: 'opted-out' };
|
|
@@ -75,14 +96,17 @@ function applyOne(file, routers, opts) {
|
|
|
75
96
|
source = before + gap + EMPTY_BLOCK;
|
|
76
97
|
}
|
|
77
98
|
|
|
78
|
-
const result = R.upsert(source, routers);
|
|
99
|
+
const result = R.upsert(source, routers, { remove: opts.remove || [] });
|
|
79
100
|
const next = result.text;
|
|
80
|
-
|
|
101
|
+
const removed = result.removed || {};
|
|
102
|
+
if (next === onDisk) return { file, action: 'unchanged', removed };
|
|
81
103
|
|
|
82
|
-
if (opts.dryRun)
|
|
104
|
+
if (opts.dryRun) {
|
|
105
|
+
return { file, action: 'would-write', diff: R.diff(onDisk, next), removed };
|
|
106
|
+
}
|
|
83
107
|
|
|
84
108
|
fs.writeFileSync(file, next, 'utf8');
|
|
85
|
-
return { file, action: exists ? 'updated' : 'created' };
|
|
109
|
+
return { file, action: exists ? 'updated' : 'created', removed };
|
|
86
110
|
}
|
|
87
111
|
|
|
88
112
|
/**
|
|
@@ -102,6 +126,7 @@ function apply(opts) {
|
|
|
102
126
|
continue;
|
|
103
127
|
}
|
|
104
128
|
const record = applyOne(path.join(dir, t.file), opts.routers || {}, opts);
|
|
129
|
+
record.agent = t.agent;
|
|
105
130
|
targets.push(record);
|
|
106
131
|
|
|
107
132
|
if (record.action === 'malformed') {
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* The pack's settings — which routers this machine wants.
|
|
4
|
+
*
|
|
5
|
+
* Shape:
|
|
6
|
+
*
|
|
7
|
+
* {
|
|
8
|
+
* "routers": { "seo-llmo": "off" },
|
|
9
|
+
* "stash": { "task-pipeline": "…the section body that was removed…" }
|
|
10
|
+
* }
|
|
11
|
+
*
|
|
12
|
+
* **Only deviations are stored.** A router absent from `routers` is on. That
|
|
13
|
+
* is what lets a new family member's router appear on its own: a file listing
|
|
14
|
+
* every router as "on" would freeze today's list, and the next release's
|
|
15
|
+
* router would arrive switched off by a file nobody remembered writing.
|
|
16
|
+
*
|
|
17
|
+
* **The stash is not a cache.** Migration moves the operator's own wording
|
|
18
|
+
* into the block, asides and all. Switching a router off and back on would
|
|
19
|
+
* otherwise return the packaged default, silently replacing text a person
|
|
20
|
+
* wrote — against `~/.claude/CLAUDE.md`, which is not under version control
|
|
21
|
+
* and has nothing to roll back to.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const path = require('path');
|
|
25
|
+
const store = require('./store.js');
|
|
26
|
+
|
|
27
|
+
const STATES = ['on', 'off'];
|
|
28
|
+
|
|
29
|
+
function configPath(home) {
|
|
30
|
+
return path.join(home, '.sshlg-skills', 'config.json');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The settings, or `{}`. A file we cannot read falls back to the defaults. */
|
|
34
|
+
function readConfig(home) {
|
|
35
|
+
return store.readJson(configPath(home));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Absent means on. Only an explicit "off" disables a router. */
|
|
39
|
+
function isEnabled(config, name) {
|
|
40
|
+
const routers = (config && config.routers) || {};
|
|
41
|
+
return routers[name] !== 'off';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Record a router's state. `on` DELETES the key rather than writing it, so
|
|
46
|
+
* the file keeps holding deviations only.
|
|
47
|
+
*/
|
|
48
|
+
function setRouter(home, name, state) {
|
|
49
|
+
if (!STATES.includes(state)) {
|
|
50
|
+
throw new Error(`state must be one of on, off — got "${state}"`);
|
|
51
|
+
}
|
|
52
|
+
const config = readConfig(home);
|
|
53
|
+
const routers = Object.assign({}, config.routers);
|
|
54
|
+
if (state === 'off') routers[name] = 'off';
|
|
55
|
+
else delete routers[name];
|
|
56
|
+
return store.writeJson(configPath(home), { routers });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function stashGet(config, name) {
|
|
60
|
+
const stash = (config && config.stash) || {};
|
|
61
|
+
return Object.prototype.hasOwnProperty.call(stash, name) ? stash[name] : undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function stashSet(home, name, body) {
|
|
65
|
+
const config = readConfig(home);
|
|
66
|
+
const stash = Object.assign({}, config.stash);
|
|
67
|
+
stash[name] = body;
|
|
68
|
+
return store.writeJson(configPath(home), { stash });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function stashClear(home, name) {
|
|
72
|
+
const config = readConfig(home);
|
|
73
|
+
const stash = Object.assign({}, config.stash);
|
|
74
|
+
delete stash[name];
|
|
75
|
+
return store.writeJson(configPath(home), { stash });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = {
|
|
79
|
+
configPath, readConfig, isEnabled, setRouter,
|
|
80
|
+
stashGet, stashSet, stashClear, STATES,
|
|
81
|
+
};
|
package/lib/consent.js
CHANGED
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
* against a temp directory and cannot reach the real state file.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
const fs = require('fs');
|
|
15
14
|
const path = require('path');
|
|
15
|
+
const store = require('./store.js');
|
|
16
16
|
|
|
17
17
|
function statePath(home) {
|
|
18
18
|
return path.join(home, '.sshlg-skills', 'state.json');
|
|
@@ -20,25 +20,18 @@ function statePath(home) {
|
|
|
20
20
|
|
|
21
21
|
/** The recorded state, or `{}`. A file we cannot parse is not consent. */
|
|
22
22
|
function readState(home) {
|
|
23
|
-
|
|
24
|
-
const raw = fs.readFileSync(statePath(home), 'utf8');
|
|
25
|
-
const parsed = JSON.parse(raw);
|
|
26
|
-
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
27
|
-
} catch (e) {
|
|
28
|
-
return {};
|
|
29
|
-
}
|
|
23
|
+
return store.readJson(statePath(home));
|
|
30
24
|
}
|
|
31
25
|
|
|
32
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* Merge `patch` into the recorded state. Owner-only, like anything else here.
|
|
28
|
+
*
|
|
29
|
+
* The reading, the merge and the 0600 now live in `store.js`, shared with the
|
|
30
|
+
* settings file — one implementation of the permission rule instead of two
|
|
31
|
+
* that agree today.
|
|
32
|
+
*/
|
|
33
33
|
function writeState(home, patch) {
|
|
34
|
-
|
|
35
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
36
|
-
const next = Object.assign(readState(home), patch);
|
|
37
|
-
fs.writeFileSync(file, JSON.stringify(next, null, 2) + '\n', { mode: 0o600 });
|
|
38
|
-
// writeFileSync's mode is subject to umask on create and ignored entirely on
|
|
39
|
-
// an existing file, so the permission is set rather than requested.
|
|
40
|
-
fs.chmodSync(file, 0o600);
|
|
41
|
-
return next;
|
|
34
|
+
return store.writeJson(statePath(home), patch);
|
|
42
35
|
}
|
|
43
36
|
|
|
44
37
|
function defaultPrompt() {
|