spine-rigc 1.0.1 → 1.1.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 +67 -57
- package/cli.ts +274 -3
- package/docs/AUTHORING.md +747 -1160
- package/docs/FACE.md +205 -371
- package/docs/INGEST.md +151 -306
- package/docs/MOTION.md +11 -27
- package/docs/PROMPTING.md +1 -1
- package/docs/RIGGING.md +10 -26
- package/docs/SPEC_COVERAGE.md +27 -871
- package/package.json +1 -1
- package/skills/rigc/SKILL.md +16 -9
- package/skills/{face → rigc-face}/SKILL.md +11 -7
- package/skills/{ingest → rigc-ingest}/SKILL.md +10 -6
- package/skills/{motion → rigc-motion}/SKILL.md +11 -7
- package/skills/{rigging → rigc-rigging}/SKILL.md +11 -7
- package/src/atlas.ts +9 -8
- package/src/compile.ts +1 -1
- package/src/generation.ts +2 -1
- package/src/ladder.ts +1 -1
- package/src/rig.ts +4 -4
- package/src/validate.ts +20 -14
package/README.md
CHANGED
|
@@ -146,8 +146,9 @@ to do with it, and it is the one instrument here that can see a wrong animation.
|
|
|
146
146
|
|
|
147
147
|
The guides under [Documentation](#documentation) also ship as
|
|
148
148
|
[Agent Skills](https://agentskills.io) — `skills/<name>/SKILL.md`, in this
|
|
149
|
-
repository and in the npm package —
|
|
150
|
-
|
|
149
|
+
repository and in the npm package — which a host reads once they are where it
|
|
150
|
+
looks: Claude Code through the plugin below, Codex, Gemini CLI and Antigravity
|
|
151
|
+
through `rigc skills install`. Each skill is a router and nothing more: when to load it, the
|
|
151
152
|
non-negotiables in a line apiece, and a link to the guide that owns every rule, so
|
|
152
153
|
a rule keeps living in exactly one place. The repository is also a Claude Code
|
|
153
154
|
plugin marketplace:
|
|
@@ -162,6 +163,35 @@ loads the same skills without a marketplace. The plugin carries no version of it
|
|
|
162
163
|
own — `/plugin update` follows `main` commit by commit, and the only version on
|
|
163
164
|
disk stays the one in `package.json`.
|
|
164
165
|
|
|
166
|
+
Codex, Gemini CLI and Antigravity read skills from one directory in the workspace,
|
|
167
|
+
`.agents/skills/` ([Codex](https://learn.chatgpt.com/docs/build-skills),
|
|
168
|
+
[Gemini CLI](https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/skills.md),
|
|
169
|
+
[Antigravity](https://antigravity.google/docs/skills/)), and none of them reads
|
|
170
|
+
`node_modules`. With the package installed, one command puts every skill there:
|
|
171
|
+
|
|
172
|
+
```shell
|
|
173
|
+
bun add -d spine-rigc
|
|
174
|
+
bun rigc skills install # relative links: .agents/skills/rigc -> ../../node_modules/spine-rigc/skills/rigc
|
|
175
|
+
bun rigc skills install --copy # the folders themselves, for a host that does not follow a link
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Run it through the project's own install, as above: `bunx spine-rigc skills install`
|
|
179
|
+
in a project that has the package was measured running the registry's copy instead
|
|
180
|
+
of the project's. A link reaches every upgrade of the package with no second run,
|
|
181
|
+
and a second run has nothing to do; an entry already there that this command did not
|
|
182
|
+
make is refused by name and nothing is written. Gemini CLI 0.41.1 was measured
|
|
183
|
+
listing a linked skill from both its workspace and its user directory — the
|
|
184
|
+
workspace one only in a folder it trusts. Codex's documentation says it follows a
|
|
185
|
+
symlinked skill folder, which is not measured here, and Antigravity CLI 1.1.9 has no
|
|
186
|
+
way to list skills without starting a session, so what it does with a link is not
|
|
187
|
+
measured either; `--copy` is the shape that asks nothing of a host. Gemini CLI can
|
|
188
|
+
also fetch a skill itself, one folder at a time:
|
|
189
|
+
`gemini skills install https://github.com/firejune/rigc.git --path skills/rigc --scope workspace`.
|
|
190
|
+
The routers are named `rigc-rigging`, `rigc-motion`, `rigc-face` and `rigc-ingest`
|
|
191
|
+
because that directory is flat — beside another tool's `motion`, a bare name is
|
|
192
|
+
whichever one the host picked — and under the Claude Code plugin they read
|
|
193
|
+
`rigc:rigc-motion` and so on.
|
|
194
|
+
|
|
165
195
|
## First rig in ten minutes
|
|
166
196
|
|
|
167
197
|
A whole rig, end to end, in a scratch directory: three tiny plates, two JSON
|
|
@@ -478,8 +508,8 @@ the shot: both silhouette edges move apart, which a flat slide cannot do, becaus
|
|
|
478
508
|
feature carries its own depth. The rig guarantees the seams — <code>idle</code> loops
|
|
479
509
|
while <code>gaze</code> and <code>turn</code> return to rest, so the hand-offs meet at 0
|
|
480
510
|
differing pixels — and the composing is the consumer's. Authorable on plain Spine 4.3, no
|
|
481
|
-
plugin, no runtime patch; the
|
|
482
|
-
|
|
511
|
+
plugin, no runtime patch; what the turn costs is authoring rather than runtime capability,
|
|
512
|
+
and that cost is one stated expression per key. Compiled and rendered entirely by the
|
|
483
513
|
published package.</em></p>
|
|
484
514
|
|
|
485
515
|
🎞️ **How the three films on this page were made** is kept with them, one directory per
|
|
@@ -511,6 +541,7 @@ commands take it and what its default is.
|
|
|
511
541
|
| `bonedist --candidate … --reference … --bones …` | per-frame, per-bone world-transform distance against another skeleton — the ladder's stage 3, run on its own. `--bones <correspondence.json \| identity>` is required rather than defaulted: a candidate is entitled to its own bone names, so the pairing is stated |
|
|
512
542
|
| `check --candidate <dir> --frames <dir>` | the candidate against reference pictures — the only instrument here that can see a *wrong animation* |
|
|
513
543
|
| `bench <rung> --candidate <dir>` | one rung of the benchmark ladder |
|
|
544
|
+
| `skills install [--dir …] [--copy]` | links every agent skill the package ships into `.agents/skills` (or `--dir`) as relative symlinks, or copies them with `--copy` — where Codex, Gemini CLI and Antigravity look. A second run has nothing to do; an entry already there that is not this command's is refused by name and nothing is written |
|
|
514
545
|
|
|
515
546
|
`diff`, `bonedist`, `check` and `bench` measure against something you were given; the
|
|
516
547
|
first three work on any reference you have, and `bench` is a repository workflow that needs a clone
|
|
@@ -553,20 +584,20 @@ holds no art — every rebuild then has to repeat `build --images parts/`.
|
|
|
553
584
|
|
|
554
585
|
**`build(ingest(x))` is `x`.** Over the eleven rigs this repository builds — the seven
|
|
555
586
|
gallery examples, the three generated probes and a coverage probe written for the
|
|
556
|
-
purpose — the rebuilt `skeleton.json` is byte for byte the file the decompiler read
|
|
557
|
-
|
|
587
|
+
purpose — the rebuilt `skeleton.json` is byte for byte the file the decompiler read.
|
|
588
|
+
The atlas is held to a weaker
|
|
558
589
|
claim on purpose, and the weakening is measured rather than assumed: it comes back
|
|
559
590
|
equal as a **multiset of region blocks**, because the order the pages are collected in
|
|
560
591
|
is in no field of the skeleton.
|
|
561
592
|
|
|
562
|
-
**And over twelve skeletons nobody here wrote.**
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
593
|
+
**And over twelve skeletons nobody here wrote.** Every editor export in the fetched
|
|
594
|
+
example corpus, ingested, rebuilt through the pack beside it and `diff`ed against the
|
|
595
|
+
source, comes back **12 of 12, no blockers, 1.000 on every measure the report
|
|
596
|
+
carries** — and each rebuild is its export's own text in canonical form, apart from
|
|
597
|
+
the header's `hash` and `spine`: the editor's project hash and the runtime version
|
|
598
|
+
rigc stamps, the two keys the rig spec has no field for by design (12 of 12).
|
|
599
|
+
[INGEST.md §2.3](docs/INGEST.md) states that pass line and why those two are the
|
|
600
|
+
exceptions.
|
|
570
601
|
|
|
571
602
|
**What it reads is skeleton JSON and nothing else** — no `.spine` project, no binary
|
|
572
603
|
`.skel`, no atlas, no art. So it never invents, and the things it cannot get out of
|
|
@@ -579,16 +610,13 @@ specs are still written — a spec plus a list of what is missing from it beats
|
|
|
579
610
|
|
|
580
611
|
- **The stage.** `skeleton.width`/`height`. A skeleton that declares none is carried as
|
|
581
612
|
declaring none — the rig spec states `"width": null, "height": null` and the rebuild
|
|
582
|
-
carries no box either, byte for byte
|
|
583
|
-
([#714](https://github.com/firejune/rigc/issues/714)) — and `--stage x,y,w,h` is how a
|
|
613
|
+
carries no box either, byte for byte — and `--stage x,y,w,h` is how a
|
|
584
614
|
caller *adds* one, recorded as a judgement. It is not derivable — posing the rig gives
|
|
585
615
|
the *animated* extent, which is a different number from the setup box. ⛔ **And `--stage` beside a box the file already states is refused
|
|
586
616
|
too**, for the opposite reason: two sources for one value, where the file is the record
|
|
587
|
-
of what was measured.
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
the flag. It is still the value that costs least to get wrong, because `diff` reports
|
|
591
|
-
the box and gates nothing on it.
|
|
617
|
+
of what was measured. **All twelve exports in the example corpus carry a stage** and
|
|
618
|
+
none of them needs the flag. It is the value that costs least to get wrong, because
|
|
619
|
+
`diff` reports the box and gates nothing on it.
|
|
592
620
|
- **An animation's duration.** The format has no such field. The largest key time is
|
|
593
621
|
the only derivable answer and it is what a runtime plays to; it is wrong for an
|
|
594
622
|
animation that holds its last pose past its last key, so it is recorded as a finding
|
|
@@ -620,17 +648,14 @@ render-and-check block per skin, with a per-skin roll-up under them, because a
|
|
|
620
648
|
rig's contested art lives in its named skins and a single un-skinned check draws
|
|
621
649
|
none of it; a skin only one side declares is a FAIL naming it as **lost** (or
|
|
622
650
|
**added**) **by the export**, with `diff`'s `attachments.skins` beside it, and is
|
|
623
|
-
rendered on neither side
|
|
651
|
+
rendered on neither side — and a
|
|
624
652
|
field-by-field list of what the editor rewrote. Every step quotes what its child
|
|
625
653
|
said when that child did not do what it was for, the renderers included; a skin
|
|
626
654
|
**neither** side can draw — a hit-box rig, say — is a **SKIP** naming that, not a
|
|
627
655
|
red, because `check` had nothing to compare and `diff` and `validate` have
|
|
628
656
|
already measured the rig. One side drawing where the other does not is the
|
|
629
|
-
divergence the trip exists to find and stays a failure.
|
|
630
|
-
|
|
631
|
-
[#369](https://github.com/firejune/rigc/issues/369),
|
|
632
|
-
[#370](https://github.com/firejune/rigc/issues/370) — and then showed that a
|
|
633
|
-
human edit made in the editor survives the trip back.
|
|
657
|
+
divergence the trip exists to find and stays a failure. A human edit made in the
|
|
658
|
+
editor survives the trip back.
|
|
634
659
|
|
|
635
660
|
🔒 **It requires a licensed Spine editor on the machine, by construction**, and
|
|
636
661
|
drives only the [documented command line](https://esotericsoftware.com/spine-command-line-interface)
|
|
@@ -643,14 +668,8 @@ and failing downstream. Both refusals point at `--exported <file>`, which
|
|
|
643
668
|
measures an export the editor already made and is the half of this tool that
|
|
644
669
|
needs no editor at all.
|
|
645
670
|
|
|
646
|
-
⛔ **
|
|
647
|
-
|
|
648
|
-
report SKIP for ever, which is how a gate comes to look kept while checking
|
|
649
|
-
nothing. Run the round trip by hand, on a machine that has the editor. Its
|
|
650
|
-
**refusals** are gated, because they are the half a machine with no editor can
|
|
651
|
-
answer for: the suite points the tool at stubs in a temp directory and reads what
|
|
652
|
-
comes back, including the case that must *not* be refused — an editor at an
|
|
653
|
-
unfamiliar path, which is who `--editor` exists for.
|
|
671
|
+
⛔ **Run the round trip by hand, on a machine that has the editor.** CI has no
|
|
672
|
+
editor, so no automated check runs it.
|
|
654
673
|
|
|
655
674
|
⚠️ Build with `--copy-images`. An ordinary build's atlas names its pages by a
|
|
656
675
|
relative path back to the art directory, and the round trip copies that atlas to
|
|
@@ -667,9 +686,10 @@ letting `A17` blame the editor for the harness's own doing.
|
|
|
667
686
|
| 🙂 **[docs/FACE.md](docs/FACE.md)** | **authoring a face.** A blink, a gaze and a 2.5D head turn on plain Spine data: the one line of yaw arithmetic every number in a turn comes from, depth as the parameter you are actually authoring, where to put a grid's columns and the angle at which any grid folds, what foreshortens and what does not, channel allocation before the first key, and the three cliffs with their angles. Also the deform audit gap, demonstrated — a folded mesh gates green — and the differential check that works today |
|
|
668
687
|
| 📥 **[docs/INGEST.md](docs/INGEST.md)** | **working with a skeleton you did not author.** What every command can and cannot do with a foreign `skeleton.json`, reading it with the toolchain, transcription as the route that makes it yours, what each validator complaint means on an export, and the re-pivot/rename/extend recipes. Ships in the package too |
|
|
669
688
|
| 🤖 **[docs/PROMPTING.md](docs/PROMPTING.md)** | **handing the authoring to an AI agent** — the prompt clauses a measured pilot run paid for, and what you can leave unsaid. Ships in the package too |
|
|
670
|
-
| 🔬 **[docs/SPEC_COVERAGE.md](docs/SPEC_COVERAGE.md)** | Spine 4.3
|
|
689
|
+
| 🔬 **[docs/SPEC_COVERAGE.md](docs/SPEC_COVERAGE.md)** | **the Spine 4.3 format, field by field.** Every key the 4.3 JSON parser and atlas reader take, what each defaults to, and the parser line it comes from — the rows rigc's refusals cite by part number. Ships in the package too |
|
|
671
690
|
| 🎓 **[the benchmark dossier](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md)** | **why you can trust the output.** The yardstick, `diff` and `check` and what neither can see, the eight-rung ladder and the spineboy graduation exam, the run viewer, the 49 named assertions with their profiles, and the selftest that has watched every one of them fire. Repository material — it is not in the npm package |
|
|
672
691
|
| 📋 [LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md) · [GATE.md](https://github.com/firejune/rigc/blob/main/docs/GATE.md) · [PILOT.md](https://github.com/firejune/rigc/blob/main/docs/PILOT.md) | the live rung ledger, the clause statements a candidate is graded against, and how to run an agent through the ladder and score what comes back |
|
|
692
|
+
| 🔬 [SURVEY_2026-08-22.md](https://github.com/firejune/rigc/blob/main/docs/SURVEY_2026-08-22.md) | the survey the ladder was climbed by: rigc and the official examples as measured on 2026-08-22, and the gap list ordered by rung. A dated record, not kept current. Repository material |
|
|
673
693
|
| 🧬 [GENERATIONS.md](https://github.com/firejune/rigc/blob/main/docs/GENERATIONS.md) | **Spine data from another generation.** Why a 3.8–4.2 file read as 4.3 fails in silence, the policy that follows (detect from `skeleton.spine`, never guess, play on the matching runtime), what `A16` and `ingest` do with such a file, and the editor as the migration path. Repository material |
|
|
674
694
|
| 🗺️ [ROADMAP.md](https://github.com/firejune/rigc/blob/main/ROADMAP.md) | where this is going, and where it has been. What 1.0 had to mean before the number was claimed, and what it was claimed on — conditions rather than a feature list, because direction here comes from what users hit |
|
|
675
695
|
| 📐 [CLAUDE.md](https://github.com/firejune/rigc/blob/main/CLAUDE.md) | **the doctrine** — why the validator's messages are the product, why nothing reaches disk before green, why no number is ever invented, and what a change has to keep. [CONTRIBUTING.md](https://github.com/firejune/rigc/blob/main/CONTRIBUTING.md) calls it worth ten minutes before a first patch. Repository material |
|
|
@@ -680,7 +700,7 @@ rigc is measured against **Spine's own official example projects** — the
|
|
|
680
700
|
`1-weight-and-mass` … `8-follow-through` series as a difficulty ladder, with spineboy
|
|
681
701
|
as the graduation exam.
|
|
682
702
|
|
|
683
|
-
🎓 **The ladder is complete
|
|
703
|
+
🎓 **The ladder is complete.** All eight numbered rungs and the
|
|
684
704
|
spineboy graduation exam are cleared and hold under the current gate, **v2.4**, every clause PASS or SKIP:
|
|
685
705
|
worst attributable slot drift **5.5550 px** against a 6.0 px bar — a **1.0801×**
|
|
686
706
|
margin, the thinnest of the ladder's **G2** figures, and **G5**'s 1.0376× is thinner
|
|
@@ -688,24 +708,15 @@ still — and **0 of 124** frame-change disagreements. Recompiling the same spec
|
|
|
688
708
|
different session reproduced every field of the measurement record **to the digit**.
|
|
689
709
|
The rungs stay in place as regression gates.
|
|
690
710
|
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
**Rungs 1–6 and 8 and the graduation exam were unaffected throughout**: each keeps its
|
|
701
|
-
pass on the clause, and recompiling a stored candidate reproduces its record **to the
|
|
702
|
-
digit within one gate**. Across an instrument change the digits do move, and the
|
|
703
|
-
record says where — the graduation **G2** figure went 5.5491 → 5.5544 → **5.5550 px**
|
|
704
|
-
over the #301 sampler repair and v2.4's adoption of the re-rendered reference basis,
|
|
705
|
-
while **0 of 124** held throughout. Both verdicts, and the sweep of every candidate under the gate of the day, are in
|
|
706
|
-
[docs/LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md)'s *PR #254 instrument re-inspection* and *gate-v2.3
|
|
707
|
-
re-inspection*; the standing figures quoted above are from its *gate-v2.4
|
|
708
|
-
re-inspection*, which is the current sweep.
|
|
711
|
+
**Rung 7 clears on a read-down.** One of its three slots draws in every set and is
|
|
712
|
+
attributable in none; a read-down names the framing of its evidence, and a slot whose
|
|
713
|
+
attributability is **measured** to be capped below the bar reads down when everything
|
|
714
|
+
observable about it is independently verified strict. Every other rung and the
|
|
715
|
+
graduation exam pass on the clause itself. Recompiling a stored candidate reproduces its
|
|
716
|
+
record **to the digit within one gate**; across an instrument change the digits move,
|
|
717
|
+
and [docs/LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md) records
|
|
718
|
+
where. The standing figures quoted above are from its *gate-v2.4 re-inspection*, which
|
|
719
|
+
is the current sweep.
|
|
709
720
|
|
|
710
721
|
⚠️ **What that certifies, stated exactly.** That **the tool, the guide and the
|
|
711
722
|
protocol reach the bar across a bounded series of honest attempts, each residual
|
|
@@ -717,8 +728,7 @@ ladder has not demonstrated that, and each row records which of the two it is.
|
|
|
717
728
|
🧪 **A separate series measures that harder question, and it has not been kind.**
|
|
718
729
|
From-zero attempts at spineboy — no inherited specs — have landed at **18.2, 18.8,
|
|
719
730
|
19.57, 7.86, 9.33 and 18.98 px** worst drift against the 6.0 px bar, a spread with no
|
|
720
|
-
monotone trend
|
|
721
|
-
2026-08-28 completion — are recorded 🔴 **FAIL**. They move no rung and reopen nothing —
|
|
731
|
+
monotone trend and every figure above the bar. They move no rung and reopen nothing —
|
|
722
732
|
a from-zero run is a tooling-progress measurement rather than a re-climb, which is
|
|
723
733
|
why the certification above is scoped to tool + guide + protocol. One of those
|
|
724
734
|
attempts states the residual in its own words: *"in motion it is not at editor
|
package/cli.ts
CHANGED
|
@@ -31,8 +31,22 @@
|
|
|
31
31
|
* Its paths resolve against the cuts.json file itself, so the table travels
|
|
32
32
|
* with the project that owns the art rather than with this repository.
|
|
33
33
|
*/
|
|
34
|
-
import {
|
|
35
|
-
|
|
34
|
+
import {
|
|
35
|
+
appendFileSync,
|
|
36
|
+
cpSync,
|
|
37
|
+
existsSync,
|
|
38
|
+
lstatSync,
|
|
39
|
+
mkdirSync,
|
|
40
|
+
readdirSync,
|
|
41
|
+
readFileSync,
|
|
42
|
+
readlinkSync,
|
|
43
|
+
realpathSync,
|
|
44
|
+
rmSync,
|
|
45
|
+
statSync,
|
|
46
|
+
symlinkSync,
|
|
47
|
+
writeFileSync,
|
|
48
|
+
} from 'node:fs';
|
|
49
|
+
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
36
50
|
import {
|
|
37
51
|
BallotError,
|
|
38
52
|
buildBallot,
|
|
@@ -229,7 +243,7 @@ function repositoryUrl(): string {
|
|
|
229
243
|
* needs a value` (issue #328). `CLI10`/`CLI11` in `selftest.ts` now hold the two
|
|
230
244
|
* halves together by reading `--help` rather than by naming a flag.
|
|
231
245
|
*/
|
|
232
|
-
const BOOLEAN_FLAGS = new Set(['all-frames', 'all-bones', 'help', 'copy-images', 'again', 'pack']);
|
|
246
|
+
const BOOLEAN_FLAGS = new Set(['all-frames', 'all-bones', 'help', 'copy-images', 'again', 'pack', 'copy']);
|
|
233
247
|
|
|
234
248
|
/**
|
|
235
249
|
* The flags a command is allowed to spell more than once.
|
|
@@ -3270,6 +3284,223 @@ function cmdIngest(flags: Record<string, string>, positional: string[]): void {
|
|
|
3270
3284
|
}
|
|
3271
3285
|
}
|
|
3272
3286
|
|
|
3287
|
+
// ---------------------------------------------------------------------------
|
|
3288
|
+
// skills install — put the shipped skills where an agent host looks (issue #831)
|
|
3289
|
+
// ---------------------------------------------------------------------------
|
|
3290
|
+
//
|
|
3291
|
+
// After `bun add -d spine-rigc` the skills sit at `node_modules/spine-rigc/skills/`,
|
|
3292
|
+
// which no host reads. Codex, Gemini CLI and Antigravity all read
|
|
3293
|
+
// `<workspace>/.agents/skills/<name>/`, so this links every `skills/<name>/` the
|
|
3294
|
+
// package ships into one directory — `.agents/skills` under the working
|
|
3295
|
+
// directory unless `--dir` says otherwise.
|
|
3296
|
+
//
|
|
3297
|
+
// ⭐ The skills are found from THIS FILE's location, never from the working
|
|
3298
|
+
// directory: the command installs the package it is, and a cwd that happens to
|
|
3299
|
+
// hold some other `skills/` is not a source. That is also why it lives here and
|
|
3300
|
+
// not in `src/`: its one input is where the CLI was installed, nothing else
|
|
3301
|
+
// calls it, and `src/` is about rigs.
|
|
3302
|
+
//
|
|
3303
|
+
// A RELATIVE symlink by default, so the directory survives the project being
|
|
3304
|
+
// moved or cloned elsewhere and an upgrade of the package is seen with no second
|
|
3305
|
+
// run. `--copy` writes the folder instead, for a host that does not follow a
|
|
3306
|
+
// linked skill folder.
|
|
3307
|
+
//
|
|
3308
|
+
// 🔒 **An entry that is already there and is not what this command would write is
|
|
3309
|
+
// refused by name, and then nothing at all is written.** The check runs over
|
|
3310
|
+
// every skill before the first write, so a refusal never leaves half an install
|
|
3311
|
+
// behind. The one entry that is NOT refused is the one this command would have
|
|
3312
|
+
// made — a link that already resolves to the same skill folder, however it is
|
|
3313
|
+
// spelled, or with `--copy` a folder whose files are byte for byte the package's
|
|
3314
|
+
// — and a run over only those says it had nothing to do. No lifecycle script
|
|
3315
|
+
// does this on install: a postinstall writing into a consumer's project root is
|
|
3316
|
+
// refused as design, and Bun does not run a dependency's lifecycle scripts
|
|
3317
|
+
// outside `trustedDependencies`, so half the installs would silently skip it.
|
|
3318
|
+
// ---------------------------------------------------------------------------
|
|
3319
|
+
|
|
3320
|
+
/** The default `--dir`, resolved against the caller's working directory. */
|
|
3321
|
+
const DEFAULT_SKILLS_DIR = '.agents/skills';
|
|
3322
|
+
|
|
3323
|
+
/** What `rigc skills` offers. One today; the list is what the refusal of any other word prints. */
|
|
3324
|
+
const SKILLS_SUBCOMMANDS = ['install'];
|
|
3325
|
+
|
|
3326
|
+
/** An install refused before its first write — nothing to install, or an entry in the way. Exit 1. */
|
|
3327
|
+
class SkillsInstallError extends Error {}
|
|
3328
|
+
|
|
3329
|
+
type SkillsInstallAction = 'linked' | 'copied' | 'already linked' | 'already copied';
|
|
3330
|
+
|
|
3331
|
+
interface SkillsInstallEntry {
|
|
3332
|
+
/** `<dir>/<name>`. */
|
|
3333
|
+
target: string;
|
|
3334
|
+
/** `<package>/skills/<name>`. */
|
|
3335
|
+
source: string;
|
|
3336
|
+
action: SkillsInstallAction;
|
|
3337
|
+
/** The link text, relative to the directory it sits in, when the entry is a link. */
|
|
3338
|
+
link: string;
|
|
3339
|
+
}
|
|
3340
|
+
|
|
3341
|
+
/** Every `<name>/` under `source` that holds a `SKILL.md`, in name order, so two runs print the same lines. */
|
|
3342
|
+
function shippedSkills(source: string): string[] {
|
|
3343
|
+
if (!existsSync(source) || !statSync(source).isDirectory()) return [];
|
|
3344
|
+
return readdirSync(source)
|
|
3345
|
+
.filter((name) => statSync(join(source, name)).isDirectory() && existsSync(join(source, name, 'SKILL.md')))
|
|
3346
|
+
.sort();
|
|
3347
|
+
}
|
|
3348
|
+
|
|
3349
|
+
/**
|
|
3350
|
+
* The real path of `path` whether or not it exists yet: the real path of its
|
|
3351
|
+
* nearest existing ancestor with the rest appended. A relative link has to be
|
|
3352
|
+
* computed between two paths spelled the same way, and on macOS the temp
|
|
3353
|
+
* directory alone is reached as `/var/…` and is really `/private/var/…`.
|
|
3354
|
+
*/
|
|
3355
|
+
function realpathAhead(path: string): string {
|
|
3356
|
+
const rest: string[] = [];
|
|
3357
|
+
let at = resolve(path);
|
|
3358
|
+
while (!existsSync(at)) {
|
|
3359
|
+
const up = dirname(at);
|
|
3360
|
+
if (up === at) break;
|
|
3361
|
+
rest.unshift(basename(at));
|
|
3362
|
+
at = up;
|
|
3363
|
+
}
|
|
3364
|
+
return join(realpathSync(at), ...rest);
|
|
3365
|
+
}
|
|
3366
|
+
|
|
3367
|
+
/** Every file under `root`, relative and sorted, so two trees compare in one order. */
|
|
3368
|
+
function filesUnder(root: string, prefix = ''): string[] {
|
|
3369
|
+
const out: string[] = [];
|
|
3370
|
+
for (const name of readdirSync(join(root, prefix)).sort()) {
|
|
3371
|
+
const rel = prefix === '' ? name : `${prefix}/${name}`;
|
|
3372
|
+
if (lstatSync(join(root, rel)).isDirectory()) out.push(...filesUnder(root, rel));
|
|
3373
|
+
else out.push(rel);
|
|
3374
|
+
}
|
|
3375
|
+
return out;
|
|
3376
|
+
}
|
|
3377
|
+
|
|
3378
|
+
/** The first way `copy` differs from `original`, or null when every file is the same bytes. */
|
|
3379
|
+
function firstDifference(copy: string, original: string): string | null {
|
|
3380
|
+
const theirs = filesUnder(copy);
|
|
3381
|
+
const ours = filesUnder(original);
|
|
3382
|
+
for (const rel of ours) {
|
|
3383
|
+
if (!theirs.includes(rel)) return `${rel} is missing from it`;
|
|
3384
|
+
if (!readFileSync(join(copy, rel)).equals(readFileSync(join(original, rel)))) return `${rel} differs`;
|
|
3385
|
+
}
|
|
3386
|
+
for (const rel of theirs) if (!ours.includes(rel)) return `${rel} is in it and not in the package`;
|
|
3387
|
+
return null;
|
|
3388
|
+
}
|
|
3389
|
+
|
|
3390
|
+
/**
|
|
3391
|
+
* Install every shipped skill into `dir`, or refuse and write nothing.
|
|
3392
|
+
*
|
|
3393
|
+
* The plan is made in full before the first write: every entry is classified as
|
|
3394
|
+
* absent, already this command's, or in the way, and one entry in the way
|
|
3395
|
+
* refuses the whole call with every such entry named.
|
|
3396
|
+
*/
|
|
3397
|
+
function installSkills(source: string, dir: string, copy: boolean): SkillsInstallEntry[] {
|
|
3398
|
+
const names = shippedSkills(source);
|
|
3399
|
+
if (names.length === 0) {
|
|
3400
|
+
throw new SkillsInstallError(
|
|
3401
|
+
`no skill to install: ${source} ${existsSync(source) ? 'holds no <name>/SKILL.md' : 'is not there'}, and it is ` +
|
|
3402
|
+
'the skills/ directory of the package this command ran from; nothing was written',
|
|
3403
|
+
);
|
|
3404
|
+
}
|
|
3405
|
+
if (existsSync(dir) && !statSync(dir).isDirectory()) {
|
|
3406
|
+
throw new SkillsInstallError(`${dir} exists and is not a directory, so no skill can be installed into it; nothing was written`);
|
|
3407
|
+
}
|
|
3408
|
+
const realDir = realpathAhead(dir);
|
|
3409
|
+
const planned: SkillsInstallEntry[] = [];
|
|
3410
|
+
const refused: string[] = [];
|
|
3411
|
+
for (const name of names) {
|
|
3412
|
+
const target = join(dir, name);
|
|
3413
|
+
const from = join(source, name);
|
|
3414
|
+
const realFrom = realpathSync(from);
|
|
3415
|
+
const link = relative(realDir, realFrom);
|
|
3416
|
+
let action: SkillsInstallAction = copy ? 'copied' : 'linked';
|
|
3417
|
+
let found: string | null = null;
|
|
3418
|
+
const stat = existsSync(target) || isLink(target) ? lstatSync(target) : null;
|
|
3419
|
+
if (stat === null) {
|
|
3420
|
+
// absent: this command writes it
|
|
3421
|
+
} else if (stat.isSymbolicLink()) {
|
|
3422
|
+
const text = readlinkSync(target);
|
|
3423
|
+
const pointsAt = resolve(realDir, text);
|
|
3424
|
+
const lands = existsSync(pointsAt) ? realpathSync(pointsAt) : null;
|
|
3425
|
+
if (lands === realFrom && !copy) action = 'already linked';
|
|
3426
|
+
else if (lands === realFrom) found = `a symlink to ${text}, the package's own folder, and --copy asks for a directory in its place`;
|
|
3427
|
+
else found = `a symlink to ${text}, ${lands === null ? 'which resolves to nothing' : `which resolves to ${lands}`}`;
|
|
3428
|
+
} else if (stat.isDirectory()) {
|
|
3429
|
+
const difference = copy ? firstDifference(target, from) : null;
|
|
3430
|
+
if (!copy) found = 'a directory';
|
|
3431
|
+
else if (difference === null) action = 'already copied';
|
|
3432
|
+
else found = `a directory that is not the package's copy (${difference})`;
|
|
3433
|
+
} else {
|
|
3434
|
+
found = 'a plain file';
|
|
3435
|
+
}
|
|
3436
|
+
if (found !== null) refused.push(`${target} is ${found}; ${copy ? `a copy of ${from}` : `a symlink to ${link}`} was required`);
|
|
3437
|
+
else planned.push({ target, source: from, action, link });
|
|
3438
|
+
}
|
|
3439
|
+
if (refused.length > 0) {
|
|
3440
|
+
throw new SkillsInstallError(
|
|
3441
|
+
`${refused.length} of the ${names.length} skill(s) cannot be installed into ${dir}, and nothing was written:\n` +
|
|
3442
|
+
refused.map((line) => ` ${line}`).join('\n') +
|
|
3443
|
+
'\nRemove the entries named above, or pass --dir to install somewhere else.',
|
|
3444
|
+
);
|
|
3445
|
+
}
|
|
3446
|
+
mkdirSync(dir, { recursive: true });
|
|
3447
|
+
for (const entry of planned) {
|
|
3448
|
+
if (entry.action === 'linked') symlinkSync(entry.link, entry.target, 'dir');
|
|
3449
|
+
else if (entry.action === 'copied') cpSync(entry.source, entry.target, { recursive: true, errorOnExist: true, force: false });
|
|
3450
|
+
}
|
|
3451
|
+
return planned;
|
|
3452
|
+
}
|
|
3453
|
+
|
|
3454
|
+
/** A dangling link is not `existsSync`, and is still an entry in the way. */
|
|
3455
|
+
function isLink(path: string): boolean {
|
|
3456
|
+
try {
|
|
3457
|
+
return lstatSync(path).isSymbolicLink();
|
|
3458
|
+
} catch {
|
|
3459
|
+
return false;
|
|
3460
|
+
}
|
|
3461
|
+
}
|
|
3462
|
+
|
|
3463
|
+
function cmdSkills(flags: Record<string, string>, positional: string[]): void {
|
|
3464
|
+
const [sub, ...extra] = positional;
|
|
3465
|
+
if (sub === undefined) {
|
|
3466
|
+
throw new UsageError(
|
|
3467
|
+
`skills takes a subcommand: ${SKILLS_SUBCOMMANDS.join(', ')} — \`rigc skills install\` links every skill this ` +
|
|
3468
|
+
`package ships into ${DEFAULT_SKILLS_DIR}`,
|
|
3469
|
+
);
|
|
3470
|
+
}
|
|
3471
|
+
if (!SKILLS_SUBCOMMANDS.includes(sub)) {
|
|
3472
|
+
throw new UsageError(`unknown skills subcommand: ${sub} (rigc skills offers ${SKILLS_SUBCOMMANDS.join(', ')})`);
|
|
3473
|
+
}
|
|
3474
|
+
if (extra.length > 0) {
|
|
3475
|
+
throw new UsageError(
|
|
3476
|
+
`skills install takes no positional argument, and ${JSON.stringify(extra[0])} was given — the directory is --dir <path>`,
|
|
3477
|
+
);
|
|
3478
|
+
}
|
|
3479
|
+
const takes = COMMANDS.find((c) => c.name === 'skills')?.flags ?? [];
|
|
3480
|
+
const foreign = Object.keys(flags).filter((flag) => !takes.includes(flag));
|
|
3481
|
+
if (foreign.length > 0) {
|
|
3482
|
+
throw new UsageError(
|
|
3483
|
+
`skills install takes ${takes.map((flag) => `--${flag}`).join(' and ')}; ` +
|
|
3484
|
+
`${foreign.map((flag) => `--${flag}`).join(', ')} is not one of them`,
|
|
3485
|
+
);
|
|
3486
|
+
}
|
|
3487
|
+
const copy = flags.copy !== undefined;
|
|
3488
|
+
const dir = resolve(process.cwd(), flags.dir ?? DEFAULT_SKILLS_DIR);
|
|
3489
|
+
const entries = installSkills(join(import.meta.dir, 'skills'), dir, copy);
|
|
3490
|
+
for (const entry of entries) {
|
|
3491
|
+
const ends = entry.action.endsWith('linked') ? `${entry.target} -> ${entry.link} (${entry.source})` : `${entry.target} <- ${entry.source}`;
|
|
3492
|
+
console.log(` ${entry.action.padEnd(14)} ${ends}`);
|
|
3493
|
+
}
|
|
3494
|
+
const wrote = entries.filter((entry) => entry.action === 'linked' || entry.action === 'copied').length;
|
|
3495
|
+
const verb = copy ? 'copied' : 'linked';
|
|
3496
|
+
console.log(
|
|
3497
|
+
wrote === 0
|
|
3498
|
+
? `rigc skills install: nothing to do — all ${entries.length} skill(s) are already ${verb} into ${dir}`
|
|
3499
|
+
: `rigc skills install: ${wrote} of ${entries.length} skill(s) ${verb} into ${dir}` +
|
|
3500
|
+
(wrote < entries.length ? `, ${entries.length - wrote} already there` : ''),
|
|
3501
|
+
);
|
|
3502
|
+
}
|
|
3503
|
+
|
|
3273
3504
|
// ---------------------------------------------------------------------------
|
|
3274
3505
|
// usage / per-command help
|
|
3275
3506
|
// ---------------------------------------------------------------------------
|
|
@@ -3368,6 +3599,14 @@ const FLAG_MEANINGS: Record<string, string> = {
|
|
|
3368
3599
|
ballot: `the ballot the --record'd vote answers (default \`${DEFAULT_BALLOT}\`); its embedded manifest is what the vote is checked against`,
|
|
3369
3600
|
ledger: `the append-only JSONL the vote lands in (default \`${DEFAULT_LEDGER}\`)`,
|
|
3370
3601
|
again: 'record a second vote on a ballot the ledger already has; without it, a repeat is refused rather than doubled',
|
|
3602
|
+
dir:
|
|
3603
|
+
`the directory to install into, resolved against your working directory (default \`${DEFAULT_SKILLS_DIR}\`, the ` +
|
|
3604
|
+
'workspace directory Codex, Gemini CLI and Antigravity read skills from)',
|
|
3605
|
+
copy:
|
|
3606
|
+
'copy each skill folder instead of linking it, for a host that does not follow a linked skill folder. A copy ' +
|
|
3607
|
+
'is not reached by an upgrade of the package, and one that is no longer the package\'s bytes is refused by name ' +
|
|
3608
|
+
'on the next run — remove it and run again (default: a relative symlink, which an upgrade reaches with no ' +
|
|
3609
|
+
'second run)',
|
|
3371
3610
|
name: "the rig spec's own name, which the motion spec's archetype must match (default: the skeleton file's basename)",
|
|
3372
3611
|
art: 'how the written spec reaches the art, which a skeleton does not encode: `loose` names an image per ' +
|
|
3373
3612
|
"attachment, measured out of the rig spec's own images directory (--images writes it; without it, `build " +
|
|
@@ -3430,6 +3669,7 @@ const FLAG_VALUES: Record<string, string> = {
|
|
|
3430
3669
|
name: '<n>',
|
|
3431
3670
|
art: 'loose|none',
|
|
3432
3671
|
stage: '<x,y,w,h>',
|
|
3672
|
+
dir: '<path>',
|
|
3433
3673
|
};
|
|
3434
3674
|
|
|
3435
3675
|
interface CommandDoc {
|
|
@@ -3723,6 +3963,21 @@ const COMMANDS: CommandDoc[] = [
|
|
|
3723
3963
|
},
|
|
3724
3964
|
},
|
|
3725
3965
|
},
|
|
3966
|
+
{
|
|
3967
|
+
name: 'skills',
|
|
3968
|
+
usage: [`rigc skills install [--dir ${DEFAULT_SKILLS_DIR}] [--copy] (every skill this package ships, where an agent host looks)`],
|
|
3969
|
+
flags: ['dir', 'copy'],
|
|
3970
|
+
notes: [
|
|
3971
|
+
'the skills installed are the skills/ directory of the package this command runs from,',
|
|
3972
|
+
'never whatever the working directory holds. Each becomes <dir>/<name>: a relative',
|
|
3973
|
+
'symlink into that folder, or with --copy a copy of it. An entry already there that',
|
|
3974
|
+
'is not a link to the same folder — or, with --copy, not the same bytes — is refused',
|
|
3975
|
+
'by name, exit 1, and nothing is written; a run over only what this command made has',
|
|
3976
|
+
'nothing to do and exits 0. Codex, Gemini CLI and Antigravity read',
|
|
3977
|
+
`<workspace>/${DEFAULT_SKILLS_DIR}; Claude Code installs the plugin instead (README,`,
|
|
3978
|
+
'"Install it into your agent").',
|
|
3979
|
+
],
|
|
3980
|
+
},
|
|
3726
3981
|
];
|
|
3727
3982
|
|
|
3728
3983
|
const KNOWN_COMMANDS = COMMANDS.map((c) => c.name);
|
|
@@ -3829,6 +4084,13 @@ const USAGE = [
|
|
|
3829
4084
|
'answer rather than a missing one, and a result whose hashes are not the ballot\'s is',
|
|
3830
4085
|
'refused by name instead of appended.',
|
|
3831
4086
|
'',
|
|
4087
|
+
'skills install puts the agent skills this package ships where an agent host looks',
|
|
4088
|
+
'for them, since none of them reads node_modules:',
|
|
4089
|
+
` rigc skills install relative links in ${DEFAULT_SKILLS_DIR}, which Codex, Gemini CLI`,
|
|
4090
|
+
' and Antigravity read; --copy writes the folders instead',
|
|
4091
|
+
'An entry already there that this command did not make is refused by name and',
|
|
4092
|
+
'nothing is written; a second run has nothing to do. See `rigc skills --help`.',
|
|
4093
|
+
'',
|
|
3832
4094
|
'a cuts.json is { "<name>": { "rig": "...", "motion": "...", "out": "...",',
|
|
3833
4095
|
' "manifest": "..." (optional) } }, with every path',
|
|
3834
4096
|
'resolved relative to the cuts.json file itself.',
|
|
@@ -3870,6 +4132,7 @@ try {
|
|
|
3870
4132
|
else if (command === 'pose') cmdPose(flags);
|
|
3871
4133
|
else if (command === 'chainfit') cmdChainFit(flags);
|
|
3872
4134
|
else if (command === 'vote') cmdVote(flags, lists.candidate ?? []);
|
|
4135
|
+
else if (command === 'skills') cmdSkills(flags, positional);
|
|
3873
4136
|
} catch (err) {
|
|
3874
4137
|
if (err instanceof UsageError) {
|
|
3875
4138
|
console.error(`rigc: ${err.message}\n\n${USAGE}`);
|
|
@@ -3940,5 +4203,13 @@ try {
|
|
|
3940
4203
|
console.error(`rigc: ${err.message}`);
|
|
3941
4204
|
process.exit(1);
|
|
3942
4205
|
}
|
|
4206
|
+
// An install refused before its first write (issue #831): the invocation was
|
|
4207
|
+
// fine and an entry on disk was not what this command would write, so exit 1
|
|
4208
|
+
// like a file that is not a PNG. The message names every such entry, what is
|
|
4209
|
+
// there and what was required; the usage under it would bury that.
|
|
4210
|
+
if (err instanceof SkillsInstallError) {
|
|
4211
|
+
console.error(`rigc skills install: ${err.message}`);
|
|
4212
|
+
process.exit(1);
|
|
4213
|
+
}
|
|
3943
4214
|
throw err;
|
|
3944
4215
|
}
|