@sassywares/skills 0.3.2 → 0.4.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/README.md CHANGED
@@ -271,44 +271,39 @@ sassy-skills/
271
271
 
272
272
  ## Installation
273
273
 
274
- The suite ships as the npm package **`@sassywares/skills`**. Pin it, don't symlink it into a global directory — the version in your lockfile is the pin, and upgrading is one command.
275
-
276
- ### As a project dependency (recommended for teams)
277
-
278
- ```bash
279
- bun add --dev --exact @sassywares/skills@0.3.2
280
- ```
281
-
282
- Nothing scans `node_modules` on its own, so link it where harnesses look — the package does that itself:
274
+ The suite installs from this repository with the open `skills` CLI. That CLI vendors the skill files into your project and pins them, so a clone needs no network and every checkout runs the same skills.
283
275
 
284
276
  ```bash
285
- bunx sassy-skills sync
277
+ npx skills add sassywares/sassy-skills
286
278
  ```
287
279
 
288
- That writes `.agents/skills/sassy-*` (read by pi and Cursor) and the `.claude/skills` link Claude Code needs. Add it as `"postinstall": "sassy-skills sync"` and teammates need nothing beyond `bun install`.
289
-
290
- Ignore both paths — they're derived from the lockfile, like any build output. `sassy-setup` does all of this for you, including the gitignore lines and the pinned version.
280
+ That writes three things:
291
281
 
292
- ### As a pi package
282
+ - `.agents/skills/sassy-*` — the skill files themselves, read by pi and Cursor
283
+ - `.claude/skills/sassy-*` — links for Claude Code, which reads only its own directory
284
+ - `skills-lock.json` — source and content hash per skill
293
285
 
294
- pi resolves packages directly, so no links are needed:
286
+ Commit the vendored skills and the lock; ignore `.claude/skills`, which is a link created at install time.
295
287
 
296
288
  ```bash
297
- pi install npm:@sassywares/skills@0.3.2 # user-level, all projects
298
- pi install npm:@sassywares/skills@0.3.2 --local # project-level → commit .pi/settings.json
289
+ npx skills update -y # pull the latest from this repo
290
+ npx skills experimental_install # restore exactly what the lock pins
291
+ npx skills remove # take the suite out again
299
292
  ```
300
293
 
294
+ `sassy-setup` does all of this for you, including the ignore line and the two package scripts.
295
+
301
296
  ### While editing the suite
302
297
 
303
- Point a consumer at your checkout instead of a published version:
298
+ `.pi/settings.json` in this repo declares `./skills`, so pi loads your working copy directly. Consumers see a change once it is pushed to `main` and they run `npx skills update` — no release step in between.
304
299
 
305
- ```bash
306
- bun link @sassywares/skills
307
- ```
300
+ ### Also published as a package
308
301
 
309
- Inside this repo, `.pi/settings.json` already declares `./skills`, so pi loads the working copy directly.
302
+ `@sassywares/skills` is on npm for harnesses that install packages rather than skill directories:
310
303
 
311
- Get the skills however they arrive — package, link, or vendored copy — and invoke them with your harness's skill syntax.
304
+ ```bash
305
+ pi install npm:@sassywares/skills@0.4.0
306
+ ```
312
307
 
313
308
  ## Repository maintenance
314
309
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sassywares/skills",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "Lean, explicit-invocation Agent Skills for shipping fast: plan, work, review, validate, ship.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -24,7 +24,6 @@
24
24
  ],
25
25
  "files": [
26
26
  "skills",
27
- "bin",
28
27
  "README.md",
29
28
  "CONCEPTS.md",
30
29
  "LICENSE"
@@ -37,9 +36,6 @@
37
36
  "./skills"
38
37
  ]
39
38
  },
40
- "bin": {
41
- "sassy-skills": "./bin/sassy-skills.mjs"
42
- },
43
39
  "engines": {
44
40
  "node": ">=18"
45
41
  }
@@ -25,8 +25,6 @@ Trivial bugs whose cause is readable from the input may skip the scout, but stil
25
25
 
26
26
  **Search prior learnings first.** Resolve `.sassywares/config.yaml` (nearest, walking up) per the `sassy-setup` skill's `references/config.md`. For every existing path in `knowledge.read`, search the docs for the symptom's keywords, the module, and the failure type. A matching doc **seeds or confirms** a hypothesis — it never replaces the causal chain or the reproduction. Name the docs you consulted, and say plainly when none matched: no match is not evidence that nothing similar has happened.
27
27
 
28
- **Update nudge.** Once per run, if `node_modules/.bin/sassy-skills` exists, run `node_modules/.bin/sassy-skills outdated`. It is throttled to once a day per machine and silently does nothing offline. Pass any newer version on in one line; never block, retry, or fail on it.
29
-
30
28
  ## Phase 1 — Diagnose inline, with `scout` as a helper
31
29
 
32
30
  You own the diagnosis. The causal reasoning happens here, in the main context, because that is where the full picture lives and where the fix gate is presented.
@@ -16,8 +16,6 @@ disable-model-invocation: true
16
16
 
17
17
  **Config.** Resolve `.sassywares/config.yaml` (nearest, walking up) per the `sassy-setup` skill's `references/config.md`. Precedence: explicit token > session instruction > config > skill default. Plans live under `artifacts.plans`; `git.repo` decides how the close-out persists.
18
18
 
19
- **Update nudge.** Once per run, if `node_modules/.bin/sassy-skills` exists, run `node_modules/.bin/sassy-skills outdated`. It is throttled to once a day per machine and silently does nothing offline. Pass any newer version on in one line; never block, retry, or fail on it.
20
-
21
19
  ## Input
22
20
 
23
21
  Detect the shape from the argument:
@@ -17,8 +17,6 @@ disable-model-invocation: true
17
17
 
18
18
  **Config.** Resolve `.sassywares/config.yaml` (nearest, walking up) per the `sassy-setup` skill's `references/config.md`. Precedence: explicit token > session instruction > config > skill default. Plans land in `artifacts.plans`.
19
19
 
20
- **Update nudge.** Once per run, if `node_modules/.bin/sassy-skills` exists, run `node_modules/.bin/sassy-skills outdated`. It is throttled to once a day per machine and silently does nothing offline. Pass any newer version on in one line; never block, retry, or fail on it.
21
-
22
20
  ## Phase A — Discuss (brainstorm)
23
21
 
24
22
  Have one focused discussion to turn the idea into something plannable.
@@ -13,8 +13,6 @@ disable-model-invocation: true
13
13
 
14
14
  **Outcome:** the scope reviewed with concrete findings; the approved fixes applied; everything left uncommitted.
15
15
 
16
- **Update nudge.** Once per run, if `node_modules/.bin/sassy-skills` exists, run `node_modules/.bin/sassy-skills outdated`. It is throttled to once a day per machine and silently does nothing offline. Pass any newer version on in one line; never block, retry, or fail on it.
17
-
18
16
  **Orchestrator only.** Never edit code yourself. The `reviewer` finds; the `worker` fixes.
19
17
 
20
18
  ## Review dimensions
@@ -30,16 +30,14 @@ disable-model-invocation: true
30
30
  - **`pr.*`** — default `babysit: true`, `merge: false`, `delete_branch: false`. Say plainly that `merge: true` authorizes merging every babysat PR in this project without asking.
31
31
  - **Hooks** — offer, do not impose. Ask whether the project wants steps injected at `before_create`, `after_create`, `before_merge`, or `after_merge`, and write the skills they name. Leave them `[]` when they have nothing to add. **Never write a hook skill yourself** — hooks are project skills, and copying one of ours into that role is the fork this mechanism exists to prevent.
32
32
  4. **Write the config.** Build the file from the proposal plus the answers. It is written **fully commented**: every key keeps a description and its allowed values, exactly as the detector emits them. Never strip those comments to save lines — for a teammate opening the file, the comments are the documentation. If a config already exists, show what changes and ask before writing — never silently overwrite. Keep every key; an unset key falls back to its documented default, so a short file is legal when nothing deviates.
33
- 5. **Wire the skills.** The suite is the package `@sassywares/skills`, and its own CLI creates the links. Never copy skill files into a project.
33
+ 5. **Wire the skills.** The suite is installed from upstream with the open `skills` CLI, which vendors the skill files into the project and pins them. Never copy skill files in by hand, never add the suite as a dependency.
34
34
 
35
- - **It needs a `package.json`.** With none, say so and ask whether to add a minimal manifest or skip the wiring. Do not create one unasked, and do not invent a second distribution mechanism for non-JS projects.
36
- - **Detect the package manager** from the lockfile: `bun.lock`/`bun.lockb`, `pnpm-lock.yaml`, `yarn.lock`, `package-lock.json`. Ask when several disagree or none exists.
37
- - **Add the dependency pinned exactly** — `bun add --dev --exact @sassywares/skills@<version>`, `npm install --save-dev --save-exact …`, `pnpm add --save-dev --save-exact …`, `yarn add --dev --exact …`. Exact, because one pinned version for every teammate and every harness is the entire point.
38
- - **Scripts**, adding only what is missing: `"skills:sync": "sassy-skills sync"`, `"skills:update": "sassy-skills update"`, and `"postinstall": "sassy-skills sync"`. **Never overwrite an existing `postinstall`** — if one is there, leave it alone and tell the user to chain `&& sassy-skills sync`, so setup cannot erase someone's install steps.
39
- - **Run `sassy-skills sync`.** The package's CLI is now in `node_modules/.bin`; it links `.agents/skills/sassy-*` and creates `.claude/skills → ../.agents/skills`. The linking rules travel with the lockfile, so they upgrade with the suite instead of drifting in the repo.
40
- - **Ignore the derived links — `repo` topology only.** Add `/.agents/skills/sassy-*` and `/.claude/skills` to `.gitignore` when missing, creating it only when there is none. In `workspace` topology never touch the project's root `.gitignore`; the snapshot's own ignore file covers that case.
35
+ - **Install it**: `npx -y skills add sassywares/sassy-skills -y`. That writes `.agents/skills/sassy-*` (real files), per-agent links such as `.claude/skills/sassy-*`, and `skills-lock.json`.
36
+ - **Scripts**, adding only what is missing: `"skills:sync": "npx -y skills experimental_install"` (restores exactly what the lock pins) and `"skills:update": "npx -y skills update -y"` (pulls the latest from upstream). **Never rewrite an existing `postinstall`** — leave it alone and tell the user to chain what they need.
37
+ - **Commit the vendored skills and the lock.** `.agents/skills/sassy-*` and `skills-lock.json` are the pin; a clone then needs no network.
38
+ - **Ignore `.claude/skills` only.** It is a link created at install time. Create `.gitignore` only when there is none — and in `workspace` topology never touch the project's root `.gitignore` at all.
41
39
  - **Write `.sassywares/README.md`** from `assets/sassywares-readme.md` when absent. Never overwrite it — it may carry project notes.
42
- - **Tell the user to `/reload`.** The links are invisible to the harness until it rescans.
40
+ - **Tell the user to `/reload`.** The skills are invisible to the harness until it rescans.
43
41
 
44
42
  6. **Install the snapshot, `workspace` only.** Read `references/workspace.md` and follow it.
45
43
 
@@ -55,9 +53,9 @@ disable-model-invocation: true
55
53
  | No silent overwrite | An existing config or an existing `.sassywares/workspace/snapshot` is shown and asked about, never replaced blind. |
56
54
  | Version is reported | Setup names the snapshot version it installed; `--check` flags an installed copy older than the skill's. |
57
55
  | Never commit | Setup writes files and stops. Committing is `sassy-commit`. |
58
- | Skills are a dependency | Pin `@sassywares/skills`; never copy skill files into a project or maintain hand-made links. |
56
+ | Skills come from upstream | Install with `npx skills add`; never hand-copy skill files or maintain the links yourself. |
59
57
  | Scripts are shared | Adding a missing script is fine; rewriting an existing `postinstall` or `skills:*` entry is not. |
60
- | Write boundary | Only `.sassywares/`, the `package.json` scripts and devDependency, the lockfile, and — in `repo` topology — two `.gitignore` lines. Never skill content, and never a root `.gitignore` in a workspace. |
58
+ | Write boundary | Only `.sassywares/`, the vendored `.agents/skills/sassy-*`, `skills-lock.json`, two `package.json` scripts, and one `.gitignore` line. Never a root `.gitignore` in a workspace. |
61
59
  | Detect, don't guess | An unknown base branch stays unknown and gets asked about. A probed value is reported as probed. |
62
60
  | Comments are part of the file | Every key keeps its description and allowed values. A stripped config is an incomplete config. |
63
61
  | Hooks are named, not written | Setup records hook skill names. Creating those skills is the project's work. |
@@ -4,14 +4,16 @@ Project config and tooling for the Sassy Skills suite. Written by `sassy-setup`.
4
4
 
5
5
  ## Skills
6
6
 
7
- Installed as the npm package `@sassywares/skills`, pinned exactly in `package.json`. The entries in `.agents/skills/` and the `.claude/skills` symlink are **derived** from that package — never edit them by hand and never commit them.
7
+ Installed from upstream with the open `skills` CLI, which vendors the skills into
8
+ `.agents/skills/` and pins them in `skills-lock.json`:
8
9
 
9
10
  ```bash
10
- bun run skills:sync # recreate the links after a fresh clone
11
- bun run skills:update # bump to the latest published version, then relink
11
+ npx skills add sassywares/sassy-skills # first install
12
+ bun run skills:sync # restore exactly what the lock pins
13
+ bun run skills:update # pull the latest from upstream
12
14
  ```
13
15
 
14
- Skills print a one-line nudge when a newer version is published — once a day, and silently nothing when offline. It is never a blocker.
16
+ The vendored skills and the lock are committed; `.claude/skills` is a link and is not.
15
17
 
16
18
  `.agents/skills/` is read by pi and Cursor; Claude Code reads `.claude/skills/` only, which is why that symlink exists. After any change, `/reload` your harness.
17
19
 
@@ -22,7 +24,7 @@ If a skill tells you another skill is missing, run `skills:sync` and restart. Su
22
24
  `config.yaml` holds the project's paths, git topology, and PR defaults. Every key is documented inline, including its allowed values, and the full schema is in the `sassy-setup` skill's `references/config.md`. Verify the file still matches the tree:
23
25
 
24
26
  ```bash
25
- node_modules/@sassywares/skills/skills/sassy-setup/scripts/detect.sh --root . --check
27
+ .agents/skills/sassy-setup/scripts/detect.sh --root . --check
26
28
  ```
27
29
 
28
30
  ## Hooks
@@ -18,8 +18,6 @@ disable-model-invocation: true
18
18
 
19
19
  **Config.** Resolve `.sassywares/config.yaml` (nearest, walking up) per the `sassy-setup` skill's `references/config.md`. Precedence: explicit token > session instruction > config > skill default. `pr.babysit`, `pr.merge`, and `pr.delete_branch` seed the close-out; `git.repo` decides whether this run commits, snapshots, or refuses; `hooks.pr.*` adds project steps to the PR pipeline.
20
20
 
21
- **Update nudge.** Once per run, if `node_modules/.bin/sassy-skills` exists, run `node_modules/.bin/sassy-skills outdated`. It is throttled to once a day per machine and silently does nothing offline. Pass any newer version on in one line; never block, retry, or fail on it.
22
-
23
21
  **Subagent-maximal by design.** The orchestrator delegates every heavy step (docs, tree reads, git, gh) to subagents and only receives compact summaries. It never reads diffs, file contents, or the tree itself.
24
22
 
25
23
  ## Steps (in order)
@@ -13,8 +13,6 @@ disable-model-invocation: true
13
13
 
14
14
  **Outcome:** a grounded conformance judgment against an intent; the approved gaps closed; everything left uncommitted.
15
15
 
16
- **Update nudge.** Once per run, if `node_modules/.bin/sassy-skills` exists, run `node_modules/.bin/sassy-skills outdated`. It is throttled to once a day per machine and silently does nothing offline. Pass any newer version on in one line; never block, retry, or fail on it.
17
-
18
16
  **Orchestrator only.** Never edit code yourself. The `validator` finds gaps; the `worker` closes them.
19
17
 
20
18
  **This skill needs an intent.** Validation without something to validate against is meaningless. If the user gave no plan path or intent, ask for one before doing anything.
@@ -19,8 +19,6 @@ disable-model-invocation: true
19
19
 
20
20
  **Config.** Resolve `.sassywares/config.yaml` (nearest, walking up) per the `sassy-setup` skill's `references/config.md`. Precedence: explicit token > session instruction > config > skill default. `git.repo` decides what the phase commit does, `artifacts.plans` decides where the plan lives.
21
21
 
22
- **Update nudge.** Once per run, if `node_modules/.bin/sassy-skills` exists, run `node_modules/.bin/sassy-skills outdated`. It is throttled to once a day per machine and silently does nothing offline. Pass any newer version on in one line; never block, retry, or fail on it.
23
-
24
22
  ## Input
25
23
 
26
24
  Detect the shape from the argument:
@@ -1,250 +0,0 @@
1
- #!/usr/bin/env node
2
- // sassy-skills — wire this package's skills into a project.
3
- //
4
- // Nothing scans node_modules on its own, so the harnesses that read a project
5
- // skills directory need links. This tool creates them, checks them, and updates
6
- // the pinned version.
7
- //
8
- // sassy-skills sync link skills into .agents/skills (and .claude/skills)
9
- // sassy-skills check exit 1 if the wiring is missing or stale
10
- // sassy-skills update bump to the latest published version, then sync
11
- // sassy-skills outdated one-line nudge when a newer version is published
12
- //
13
- // Options: --root <dir> --quiet --force --help
14
- import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
15
- import { tmpdir } from "node:os";
16
- import { execFileSync } from "node:child_process";
17
- import { dirname, join, resolve } from "node:path";
18
- import { fileURLToPath } from "node:url";
19
-
20
- const HERE = dirname(fileURLToPath(import.meta.url));
21
- const SKILLS_DIR = resolve(HERE, "..", "skills");
22
- const PKG = JSON.parse(readFileSync(resolve(HERE, "..", "package.json"), "utf8"));
23
- const NAME = PKG.name;
24
- const VERSION = PKG.version;
25
- const REGISTRY = process.env.SASSY_SKILLS_REGISTRY ?? "https://registry.npmjs.org";
26
-
27
- const args = process.argv.slice(2);
28
- const quiet = args.includes("--quiet");
29
- const rootFlag = args.indexOf("--root");
30
- const rootValueIndex = rootFlag >= 0 ? rootFlag + 1 : -1;
31
- const rootArg = rootFlag >= 0 ? args[rootFlag + 1] : undefined;
32
- if (rootFlag >= 0 && (!rootArg || rootArg.startsWith("-"))) {
33
- console.error(" ✗ --root needs a directory");
34
- process.exit(2);
35
- }
36
- const ROOT = rootArg ? resolve(rootArg) : process.cwd();
37
- if (rootFlag >= 0) {
38
- let ok = false;
39
- try { ok = statSync(ROOT).isDirectory(); } catch { ok = false; }
40
- if (!ok) { console.error(` ✗ --root ${ROOT} is not an existing directory`); process.exit(2); }
41
- }
42
- if (args.includes("--help") || args.includes("-h")) {
43
- console.log(`sassy-skills@${VERSION}\n\n sync link skills into .agents/skills and .claude/skills\n check exit 1 when the wiring is missing or stale\n update bump ${NAME} to the latest version, then sync\n outdated print a one-line nudge when a newer version is published (throttled, offline-safe)\n\nOptions: --root <dir> --quiet --force`);
44
- process.exit(0);
45
- }
46
-
47
- const command = args.find((a, i) => !a.startsWith("-") && i !== rootValueIndex) ?? "sync";
48
-
49
- const log = (...a) => { if (!quiet) console.log(...a); };
50
- const ok = (m) => log(` ✓ ${m}`);
51
- const warn = (m) => console.error(` ! ${m}`);
52
- const die = (m) => { console.error(` ✗ ${m}`); process.exit(1); };
53
-
54
- const IS_WIN = process.platform === "win32";
55
-
56
- function linkedSkills() {
57
- if (!existsSync(SKILLS_DIR)) die(`package has no skills/ directory at ${SKILLS_DIR}`);
58
- return readdirSync(SKILLS_DIR, { withFileTypes: true })
59
- .filter((e) => e.isDirectory() && existsSync(join(SKILLS_DIR, e.name, "SKILL.md")))
60
- .map((e) => e.name)
61
- .sort();
62
- }
63
-
64
- // Returns "linked", "kept", or "blocked". A blocked path is a real directory the
65
- // user owns: never replaced, and always a failure, because the wiring is then
66
- // incomplete and nothing the tool can do will fix it.
67
- function makeLink(target, linkPath) {
68
- try {
69
- const st = lstatSync(linkPath);
70
- if (st.isSymbolicLink()) {
71
- if (resolve(dirname(linkPath), readlinkSync(linkPath)) === target) return "kept";
72
- rmSync(linkPath);
73
- } else {
74
- warn(`${linkPath} is a real directory, not a link — move it aside; nothing was replaced`);
75
- return "blocked";
76
- }
77
- } catch { /* does not exist */ }
78
- symlinkSync(target, linkPath, IS_WIN ? "junction" : "dir");
79
- return "linked";
80
- }
81
-
82
- function sync() {
83
- const skills = linkedSkills();
84
- const agents = join(ROOT, ".agents", "skills");
85
- mkdirSync(agents, { recursive: true });
86
-
87
- let linked = 0;
88
- let blocked = 0;
89
- for (const name of skills) {
90
- const target = resolve(SKILLS_DIR, name);
91
- const result = makeLink(target, join(agents, name));
92
- if (result === "linked") linked++;
93
- else if (result === "blocked") blocked++;
94
- }
95
- if (blocked) {
96
- console.error(` ✗ ${blocked} of ${skills.length} skills could not be linked — see above`);
97
- return 1;
98
- }
99
- ok(linked
100
- ? `${skills.length} skills linked into .agents/skills (${NAME}@${VERSION})`
101
- : `${skills.length} skills already linked (${NAME}@${VERSION})`);
102
-
103
- // Claude Code reads .claude/skills only. A directory symlink is enough, and it
104
- // is the least invasive form when the directory does not already exist.
105
- const claude = join(ROOT, ".claude", "skills");
106
- try {
107
- const st = lstatSync(claude);
108
- if (!st.isSymbolicLink()) {
109
- warn(".claude/skills is a real directory — Claude Code will not see the project skills until you link them");
110
- return 1;
111
- }
112
- const points = resolve(dirname(claude), readlinkSync(claude));
113
- if (points !== resolve(agents)) {
114
- warn(`.claude/skills points at ${points}, not .agents/skills — relink it`);
115
- return 1;
116
- }
117
- } catch {
118
- mkdirSync(dirname(claude), { recursive: true });
119
- symlinkSync(IS_WIN ? join(ROOT, ".agents", "skills") : "../.agents/skills", claude, IS_WIN ? "junction" : "dir");
120
- ok("linked .claude/skills → .agents/skills");
121
- }
122
-
123
- if (linked) log(`\nRestart or /reload your harness to pick up the new skills.`);
124
- return 0;
125
- }
126
-
127
- function check() {
128
- const skills = linkedSkills();
129
- const agents = join(ROOT, ".agents", "skills");
130
- let problems = 0;
131
-
132
- for (const name of skills) {
133
- const link = join(agents, name);
134
- const target = resolve(SKILLS_DIR, name);
135
- try {
136
- const st = lstatSync(link);
137
- if (!st.isSymbolicLink()) { warn(`${name}: real directory, not a link`); problems++; continue; }
138
- const points = resolve(agents, readlinkSync(link));
139
- if (points !== target) { warn(`${name}: points at ${points}, expected ${target}`); problems++; }
140
- } catch {
141
- warn(`${name}: not linked`);
142
- problems++;
143
- }
144
- }
145
- const claude = join(ROOT, ".claude", "skills");
146
- try {
147
- const st = lstatSync(claude);
148
- if (!st.isSymbolicLink()) { warn(".claude/skills is a real directory, so Claude Code sees none of these"); problems++; }
149
- else if (resolve(dirname(claude), readlinkSync(claude)) !== resolve(agents)) {
150
- warn(".claude/skills points somewhere else"); problems++;
151
- }
152
- } catch { warn(".claude/skills missing (Claude Code only)"); }
153
- if (problems) { console.error(` ✗ ${problems} skill link(s) need attention — run: sassy-skills sync`); return 1; }
154
- ok(`${skills.length} skills correctly linked (${NAME}@${VERSION})`);
155
- return 0;
156
- }
157
-
158
- function detectPM() {
159
- if (existsSync(join(ROOT, "bun.lock")) || existsSync(join(ROOT, "bun.lockb"))) return "bun";
160
- if (existsSync(join(ROOT, "pnpm-lock.yaml"))) return "pnpm";
161
- if (existsSync(join(ROOT, "yarn.lock"))) return "yarn";
162
- if (existsSync(join(ROOT, "package-lock.json"))) return "npm";
163
- return null;
164
- }
165
-
166
- function update() {
167
- const pm = detectPM();
168
- if (!pm) die("no lockfile found — cannot tell which package manager to update with");
169
- const spec = `${NAME}@latest`;
170
- const cmd = {
171
- bun: ["bun", ["add", "--dev", "--exact", spec]],
172
- npm: ["npm", ["install", "--save-dev", "--save-exact", spec]],
173
- pnpm: ["pnpm", ["add", "--save-dev", "--save-exact", spec]],
174
- yarn: ["yarn", ["add", "--dev", "--exact", spec]],
175
- }[pm];
176
- log(`Updating ${NAME} with ${pm}…`);
177
- try {
178
- execFileSync(cmd[0], cmd[1], { cwd: ROOT, stdio: "inherit" });
179
- } catch {
180
- die(`${pm} could not update ${NAME} — nothing was relinked`);
181
- }
182
- // The running copy is the old version; its skills directory may be gone. Hand
183
- // over to the freshly installed binary so the linker matches the new package.
184
- const fresh = join(ROOT, "node_modules", ".bin", "sassy-skills");
185
- if (existsSync(fresh)) {
186
- try {
187
- execFileSync(fresh, ["sync", "--root", ROOT], { stdio: "inherit" });
188
- } catch {
189
- die("updated, but the new binary could not relink — run: sassy-skills sync");
190
- }
191
- return 0;
192
- }
193
- return sync();
194
- }
195
-
196
- function newerThan(a, b) {
197
- const pa = String(a).split(/[.+-]/).map((x) => parseInt(x, 10) || 0);
198
- const pb = String(b).split(/[.+-]/).map((x) => parseInt(x, 10) || 0);
199
- for (let i = 0; i < 3; i++) {
200
- if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) > (pb[i] ?? 0);
201
- }
202
- return false;
203
- }
204
-
205
- function updateHint() {
206
- const pm = detectPM();
207
- const script = pm === "bun" ? "bun run skills:update"
208
- : pm ? `${pm} run skills:update`
209
- : "sassy-skills update";
210
- return script;
211
- }
212
-
213
- // Non-blocking version nudge. Throttled to once a day per machine, silent when
214
- // offline, and it never fails a run — a stale pin is worth mentioning, not
215
- // worth blocking work over.
216
- async function outdated(force = false) {
217
- const cacheFile = join(tmpdir(), "sassy-skills-version-check.json");
218
- const TTL = 24 * 60 * 60 * 1000;
219
- let cached = null;
220
- try { cached = JSON.parse(readFileSync(cacheFile, "utf8")); } catch { /* first run */ }
221
-
222
- let latest = null;
223
- const fresh = cached && typeof cached.checkedAt === "number" && Date.now() - cached.checkedAt < TTL;
224
- if (fresh && !force) {
225
- latest = cached.latest ?? null;
226
- } else {
227
- try {
228
- const res = await fetch(`${REGISTRY}/${encodeURIComponent(NAME)}/latest`, {
229
- signal: AbortSignal.timeout(2000),
230
- headers: { accept: "application/json" },
231
- });
232
- const body = await res.json();
233
- latest = typeof body?.version === "string" ? body.version : null;
234
- } catch { latest = null; }
235
- try { writeFileSync(cacheFile, JSON.stringify({ checkedAt: Date.now(), latest })); } catch { /* read-only tmp */ }
236
- }
237
-
238
- if (!latest || !newerThan(latest, VERSION)) return 0;
239
- console.log(` ↑ ${NAME} ${VERSION} → ${latest} available — ${updateHint()}`);
240
- return 0;
241
- }
242
-
243
- switch (command) {
244
- case "outdated": process.exit(await outdated(args.includes("--force")));
245
- case "sync": process.exit(sync());
246
- case "check": process.exit(check());
247
- case "update": process.exit(update());
248
- default:
249
- die(`unknown command: ${command} (try --help)`);
250
- }