spine-rigc 1.0.2 → 1.2.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 +37 -5
- package/cli.ts +562 -48
- package/docs/AUTHORING.md +156 -7
- package/docs/MOTION.md +1 -0
- package/package.json +1 -1
- package/skills/rigc/SKILL.md +42 -12
- 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/ballot.ts +9 -12
- package/src/check.ts +181 -5
- package/src/checkpics.ts +293 -0
- package/src/preview.ts +223 -32
- package/src/render.ts +140 -2
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, isAbsolute, join, relative, resolve } from 'node:path';
|
|
36
50
|
import {
|
|
37
51
|
BallotError,
|
|
38
52
|
buildBallot,
|
|
@@ -56,7 +70,15 @@ import {
|
|
|
56
70
|
IDENTITY_CORRESPONDENCE,
|
|
57
71
|
type BoneDistReport,
|
|
58
72
|
} from './src/bonedist.ts';
|
|
59
|
-
import {
|
|
73
|
+
import {
|
|
74
|
+
checkAgainstFrames,
|
|
75
|
+
checkLines,
|
|
76
|
+
CheckError,
|
|
77
|
+
CheckPlates,
|
|
78
|
+
type CheckOptions,
|
|
79
|
+
type CheckReport,
|
|
80
|
+
} from './src/check.ts';
|
|
81
|
+
import { writeCheckPictures } from './src/checkpics.ts';
|
|
60
82
|
import { compile, CompileError, droppedStateReason, relativeImagesPath, type CompileOptions } from './src/compile.ts';
|
|
61
83
|
import {
|
|
62
84
|
skeletonDataFromText,
|
|
@@ -111,7 +133,7 @@ import {
|
|
|
111
133
|
estimateChainFit,
|
|
112
134
|
type ChainFitOptions,
|
|
113
135
|
} from './src/chainfit.ts';
|
|
114
|
-
import { buildPreview, PLAYER_LINE, type PreviewPage } from './src/preview.ts';
|
|
136
|
+
import { buildPreview, buildPreviewPanes, PLAYER_LINE, type PreviewGate, type PreviewInput, type PreviewPage } from './src/preview.ts';
|
|
115
137
|
import {
|
|
116
138
|
atlasPageNames,
|
|
117
139
|
BACKGROUND,
|
|
@@ -127,9 +149,13 @@ import {
|
|
|
127
149
|
SETUP_POSE_DIR,
|
|
128
150
|
SHEET_FILE,
|
|
129
151
|
SHEET_TILE,
|
|
152
|
+
SlotSubsetError,
|
|
153
|
+
slotSubsetOf,
|
|
130
154
|
type Frame,
|
|
131
155
|
type FramesSidecar,
|
|
132
156
|
type FrameSet,
|
|
157
|
+
type Posable,
|
|
158
|
+
type SlotSubset,
|
|
133
159
|
} from './src/render.ts';
|
|
134
160
|
import {
|
|
135
161
|
assertionCountForProfile,
|
|
@@ -229,14 +255,15 @@ function repositoryUrl(): string {
|
|
|
229
255
|
* needs a value` (issue #328). `CLI10`/`CLI11` in `selftest.ts` now hold the two
|
|
230
256
|
* halves together by reading `--help` rather than by naming a flag.
|
|
231
257
|
*/
|
|
232
|
-
const BOOLEAN_FLAGS = new Set(['all-frames', 'all-bones', 'help', 'copy-images', 'again', 'pack']);
|
|
258
|
+
const BOOLEAN_FLAGS = new Set(['all-frames', 'all-bones', 'help', 'copy-images', 'again', 'pack', 'copy']);
|
|
233
259
|
|
|
234
260
|
/**
|
|
235
261
|
* The flags a command is allowed to spell more than once.
|
|
236
262
|
*
|
|
237
263
|
* `vote --candidate` is, because a ballot is *by definition* several
|
|
238
|
-
* candidates
|
|
239
|
-
*
|
|
264
|
+
* candidates; `preview --candidate` is, because a pane per candidate on one
|
|
265
|
+
* page is what an agent otherwise builds by hand (issue #837); and `diff --as`
|
|
266
|
+
* is, because a skeleton has as many shots as it has and one pairing per flag is the only spelling that keeps each pair a pair
|
|
240
267
|
* (issue #720). Everywhere else a repeat is a mistake and is refused: `check
|
|
241
268
|
* --candidate a --candidate b` used to take `b` silently, which is a report
|
|
242
269
|
* about a rig the caller did not think they were asking about.
|
|
@@ -244,6 +271,7 @@ const BOOLEAN_FLAGS = new Set(['all-frames', 'all-bones', 'help', 'copy-images',
|
|
|
244
271
|
const REPEATABLE_FLAGS: Record<string, ReadonlySet<string>> = {
|
|
245
272
|
vote: new Set(['candidate']),
|
|
246
273
|
diff: new Set(['as']),
|
|
274
|
+
preview: new Set(['candidate']),
|
|
247
275
|
};
|
|
248
276
|
|
|
249
277
|
/**
|
|
@@ -1470,6 +1498,12 @@ function cmdBuild(flags: Record<string, string>): void {
|
|
|
1470
1498
|
writeFileSync(join(opts.outDir, 'skeleton.atlas'), atlasText);
|
|
1471
1499
|
console.log(`rigc: wrote ${join(opts.outDir, 'skeleton.json')}`);
|
|
1472
1500
|
console.log(`rigc: wrote ${join(opts.outDir, 'skeleton.atlas')}`);
|
|
1501
|
+
// The next command is part of the message (issue #837). A green build is the
|
|
1502
|
+
// moment somebody wants to see what came out, and the one page rigc writes
|
|
1503
|
+
// for that is `preview` of exactly this directory — so the line names it,
|
|
1504
|
+
// with the path already resolved. Printed only here: a red build wrote
|
|
1505
|
+
// nothing, so there is nothing to look at and no line.
|
|
1506
|
+
console.log(`rigc: look at it: rigc preview --candidate ${opts.outDir}`);
|
|
1473
1507
|
}
|
|
1474
1508
|
|
|
1475
1509
|
/**
|
|
@@ -1686,7 +1720,13 @@ function readCheckFlags(
|
|
|
1686
1720
|
return out;
|
|
1687
1721
|
}
|
|
1688
1722
|
|
|
1689
|
-
function runCheck(
|
|
1723
|
+
function runCheck(
|
|
1724
|
+
candidate: string,
|
|
1725
|
+
atlasFlag: string | undefined,
|
|
1726
|
+
framesDir: string,
|
|
1727
|
+
flags: Record<string, string>,
|
|
1728
|
+
plates?: CheckPlates,
|
|
1729
|
+
): CheckReport {
|
|
1690
1730
|
const { skeletonPath, atlasPath } = resolveArtifacts(candidate, atlasFlag);
|
|
1691
1731
|
return checkAgainstFrames({
|
|
1692
1732
|
skeletonText: readFileSync(skeletonPath, 'utf8'),
|
|
@@ -1695,16 +1735,59 @@ function runCheck(candidate: string, atlasFlag: string | undefined, framesDir: s
|
|
|
1695
1735
|
framesDir,
|
|
1696
1736
|
labels: { skeleton: skeletonPath, atlas: atlasPath },
|
|
1697
1737
|
...readCheckFlags(flags),
|
|
1738
|
+
...(plates === undefined ? {} : { plates }),
|
|
1698
1739
|
});
|
|
1699
1740
|
}
|
|
1700
1741
|
|
|
1742
|
+
/**
|
|
1743
|
+
* `check --out <dir>`, refused before anything is compared when it cannot be
|
|
1744
|
+
* written — see `src/checkpics.ts` for what goes there.
|
|
1745
|
+
*
|
|
1746
|
+
* Two refusals and no others. A **file** at the path is not a directory of
|
|
1747
|
+
* pictures, and saying so beats the `ENOTDIR` a write would throw after the
|
|
1748
|
+
* whole comparison had run. And a directory that **is `--frames` or holds it**
|
|
1749
|
+
* is refused because `<out>/<set>/` is cleared and `<out>/frames.json` written,
|
|
1750
|
+
* as `render` does to its own: there, that is the reference set being deleted by
|
|
1751
|
+
* the command that reads it — `check --frames render --out render` would clear
|
|
1752
|
+
* `render/heavy` and put a picture sidecar where the frames' own was. A fresh
|
|
1753
|
+
* path, and an existing directory anywhere else, are written to.
|
|
1754
|
+
*/
|
|
1755
|
+
function readCheckOut(outFlag: string, framesFlag: string): string {
|
|
1756
|
+
const out = resolve(outFlag);
|
|
1757
|
+
if (existsSync(out) && !statSync(out).isDirectory()) {
|
|
1758
|
+
throw new UsageError(`check: --out ${out} is a file; it names a directory`);
|
|
1759
|
+
}
|
|
1760
|
+
const frames = resolve(framesFlag);
|
|
1761
|
+
const within = relative(out, frames);
|
|
1762
|
+
if (within === '' || (!within.startsWith('..') && !isAbsolute(within))) {
|
|
1763
|
+
throw new UsageError(
|
|
1764
|
+
`check: --out ${out} ${within === '' ? 'is' : 'holds'} --frames ${frames}; the pictures would be written ` +
|
|
1765
|
+
'over the frames they are pictures of (each set directory under --out is cleared first) — name a directory ' +
|
|
1766
|
+
'outside it',
|
|
1767
|
+
);
|
|
1768
|
+
}
|
|
1769
|
+
return out;
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1701
1772
|
function cmdCheck(flags: Record<string, string>): void {
|
|
1702
1773
|
if (flags.candidate === undefined) throw new UsageError('check needs --candidate <dir | skeleton.json>');
|
|
1703
1774
|
if (flags.frames === undefined) throw new UsageError('check needs --frames <dir> — a rendered reference frame set');
|
|
1704
|
-
const
|
|
1775
|
+
const out = flags.out === undefined ? null : readCheckOut(flags.out, flags.frames);
|
|
1776
|
+
const allFrames = flags['all-frames'] !== undefined;
|
|
1777
|
+
// Only asked for when there is somewhere to put the pictures: without --out
|
|
1778
|
+
// nothing is kept, and the run is the run it was before --out existed.
|
|
1779
|
+
const plates = out === null ? undefined : new CheckPlates({ allFrames });
|
|
1780
|
+
const report = runCheck(flags.candidate, flags.atlas, flags.frames, flags, plates);
|
|
1705
1781
|
console.log('rigc check');
|
|
1706
|
-
for (const line of checkLines(report, { allFrames
|
|
1782
|
+
for (const line of checkLines(report, { allFrames })) console.log(line);
|
|
1707
1783
|
if (flags.json !== undefined) writeJson(flags.json, report);
|
|
1784
|
+
if (out !== null && plates !== undefined) {
|
|
1785
|
+
for (const set of writeCheckPictures(out, report, plates, { allFrames })) {
|
|
1786
|
+
const which = set.frames.length === 0 ? 'nothing compared' : set.every ? 'every compared frame' : 'the frames worth reading';
|
|
1787
|
+
console.log(` .. ${set.dir.padEnd(16)} ${set.frames.length} picture(s), ${which} -> ${set.path}`);
|
|
1788
|
+
}
|
|
1789
|
+
console.log(`rigc: wrote ${join(out, FRAMES_SIDECAR)}`);
|
|
1790
|
+
}
|
|
1708
1791
|
}
|
|
1709
1792
|
|
|
1710
1793
|
function writeJson(target: string, body: unknown): void {
|
|
@@ -1787,6 +1870,35 @@ function readSkinFlag(flags: Record<string, string>, declared: string[]): string
|
|
|
1787
1870
|
return name;
|
|
1788
1871
|
}
|
|
1789
1872
|
|
|
1873
|
+
/**
|
|
1874
|
+
* `--slot` / `--hide`, resolved against the skeleton under the skin this run
|
|
1875
|
+
* poses it in — or refused as a usage error, nothing written (issue #835).
|
|
1876
|
+
*
|
|
1877
|
+
* The rule itself is `slotSubsetOf`'s in `src/render.ts`, which `piecesOf`
|
|
1878
|
+
* applies too: it is read here only so a miss exits 2 with the usage beside
|
|
1879
|
+
* it before a directory is created, rather than surfacing from the sampler.
|
|
1880
|
+
*/
|
|
1881
|
+
function readSlotSubsetFlags(
|
|
1882
|
+
flags: Record<string, string>,
|
|
1883
|
+
data: Posable['data'],
|
|
1884
|
+
skin: string | undefined,
|
|
1885
|
+
): SlotSubset | undefined {
|
|
1886
|
+
const list = (raw: string | undefined): string[] | undefined =>
|
|
1887
|
+
raw === undefined ? undefined : raw.split(',').map((name) => name.trim()).filter((name) => name !== '');
|
|
1888
|
+
try {
|
|
1889
|
+
return slotSubsetOf(data, { slots: list(flags.slot), hidden: list(flags.hide) }, skin);
|
|
1890
|
+
} catch (err) {
|
|
1891
|
+
if (err instanceof SlotSubsetError) throw new UsageError(err.message);
|
|
1892
|
+
throw err;
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
/** A resolved subset as the one field it is spelled as, in `PoseOptions` and in `frames.json` alike. */
|
|
1897
|
+
function subsetFields(subset: SlotSubset | undefined): { slots?: string[]; hidden?: string[] } {
|
|
1898
|
+
if (subset === undefined) return {};
|
|
1899
|
+
return subset.mode === 'slots' ? { slots: subset.names } : { hidden: subset.names };
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1790
1902
|
function readPositiveNumber(flags: Record<string, string>, key: string, fallback: number, least: number): number {
|
|
1791
1903
|
const raw = flags[key];
|
|
1792
1904
|
if (raw === undefined) return fallback;
|
|
@@ -1855,12 +1967,19 @@ function cmdRender(flags: Record<string, string>): void {
|
|
|
1855
1967
|
const { data, pages } = loadPosable(skeletonPath, atlasPath, atlasDir);
|
|
1856
1968
|
const only = readAnimationFlag(flags, data.animations.map((a) => a.name));
|
|
1857
1969
|
const skin = readSkinFlag(flags, data.skins.map((s) => s.name));
|
|
1970
|
+
const subset = readSlotSubsetFlags(flags, data, skin);
|
|
1858
1971
|
// One object, so the framing and the frames cannot be posed under two
|
|
1859
1972
|
// different skins — which would frame one shot with another shot's box.
|
|
1860
1973
|
// Not annotated `PoseOptions`: that name is `src/pose.ts`'s in this file, and
|
|
1861
1974
|
// `src/render.ts` has one of its own. The inferred shape is the render one.
|
|
1862
|
-
|
|
1975
|
+
// The subset rides on the same object and `framingViewport` takes it off, so
|
|
1976
|
+
// the frames draw the subset and the box is still the whole rig's.
|
|
1977
|
+
const pose =
|
|
1978
|
+
skin === undefined && subset === undefined
|
|
1979
|
+
? undefined
|
|
1980
|
+
: { ...(skin === undefined ? {} : { skin }), ...subsetFields(subset) };
|
|
1863
1981
|
if (skin !== undefined) console.log(` .. skin ${skin}`);
|
|
1982
|
+
if (subset !== undefined) console.log(` .. ${subset.mode.padEnd(8)} ${subset.names.join(', ')}`);
|
|
1864
1983
|
|
|
1865
1984
|
const viewport = framingViewport(data, maxSide, pose);
|
|
1866
1985
|
if (!viewport) {
|
|
@@ -1922,6 +2041,9 @@ function cmdRender(flags: Record<string, string>): void {
|
|
|
1922
2041
|
// which is both what this run did and what every frame set written before
|
|
1923
2042
|
// #571 did. See `FramesSidecar.skin`.
|
|
1924
2043
|
...(skin === undefined ? {} : { skin }),
|
|
2044
|
+
// Written only when a subset was asked for, for the same reason: a render of
|
|
2045
|
+
// every slot says nothing and stays the bytes it always was (issue #835).
|
|
2046
|
+
...subsetFields(subset),
|
|
1925
2047
|
background: BACKGROUND,
|
|
1926
2048
|
viewport: {
|
|
1927
2049
|
x: viewport.minX,
|
|
@@ -1955,6 +2077,29 @@ function skeletonAnimationNames(skeletonText: string, path: string): string[] {
|
|
|
1955
2077
|
return Object.keys(animations);
|
|
1956
2078
|
}
|
|
1957
2079
|
|
|
2080
|
+
/**
|
|
2081
|
+
* The gate's reading of one candidate, taken the way `rigc validate <dir>`
|
|
2082
|
+
* takes it (issue #837).
|
|
2083
|
+
*
|
|
2084
|
+
* ⭐ The same call `cmdValidate` makes on a bare directory: the two texts, the
|
|
2085
|
+
* atlas's own directory, the default profile, and nothing a directory cannot
|
|
2086
|
+
* supply — no rig spec, no declared durations, no second compile. So `A09` and
|
|
2087
|
+
* `A18` report SKIP here exactly as they do there, and the line is the line
|
|
2088
|
+
* that command prints for these files, not the one `build` printed for the
|
|
2089
|
+
* compile that wrote them. Measured on every run, because the page must not
|
|
2090
|
+
* carry a figure this run did not measure.
|
|
2091
|
+
*/
|
|
2092
|
+
function previewGate(skeletonText: string, atlasText: string, atlasDir: string): PreviewGate {
|
|
2093
|
+
const lines = reportLines(validate({ skeletonText, atlasText, atlasDir, profile: CLI_DEFAULT_PROFILE }));
|
|
2094
|
+
const refusal = lines.find((line) => line.startsWith(' FAIL '));
|
|
2095
|
+
return {
|
|
2096
|
+
// `reportLines` ends on the summary by construction; the gutter is the
|
|
2097
|
+
// report's layout, not part of what the gate said.
|
|
2098
|
+
summary: lines[lines.length - 1].replace(/^ {2}\.\. {4}/, ''),
|
|
2099
|
+
refusal: refusal === undefined ? null : refusal.trimStart(),
|
|
2100
|
+
};
|
|
2101
|
+
}
|
|
2102
|
+
|
|
1958
2103
|
/**
|
|
1959
2104
|
* preview — the artifact playing in Esoteric's own web player, as one file.
|
|
1960
2105
|
*
|
|
@@ -1963,52 +2108,130 @@ function skeletonAnimationNames(skeletonText: string, path: string): string[] {
|
|
|
1963
2108
|
* can draw rather than for the ones our own decoder reads — which is the right
|
|
1964
2109
|
* direction for the command whose whole job is "just show me".
|
|
1965
2110
|
*/
|
|
1966
|
-
function cmdPreview(flags: Record<string, string
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
const
|
|
1970
|
-
|
|
1971
|
-
|
|
2111
|
+
function cmdPreview(flags: Record<string, string>, candidates: string[]): void {
|
|
2112
|
+
// Refused rather than ignored: a preview asked to hide `head` that plays the
|
|
2113
|
+
// whole rig is a picture that answers a question it was not asked (issue #835).
|
|
2114
|
+
for (const flag of ['slot', 'hide'] as const) {
|
|
2115
|
+
if (flags[flag] !== undefined) {
|
|
2116
|
+
throw new UsageError(
|
|
2117
|
+
`preview takes no --${flag}: the Spine Web Player draws what the skeleton draws. A subset of the slots is ` +
|
|
2118
|
+
`\`rigc render --${flag} ${flags[flag]}\`, on the whole rig's grid`,
|
|
2119
|
+
);
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
const several = candidates.length > 1;
|
|
2123
|
+
// `--atlas` names ONE atlas, and with several skeletons there is no
|
|
2124
|
+
// unambiguous thing it could mean — the refusal `vote` makes, for the reason
|
|
2125
|
+
// it makes it.
|
|
2126
|
+
if (several && flags.atlas !== undefined) {
|
|
2127
|
+
throw new UsageError(
|
|
2128
|
+
`--atlas names one atlas and ${candidates.length} --candidate were given; each candidate's atlas has to sit ` +
|
|
2129
|
+
'beside its skeleton, which is what `build --out` leaves behind',
|
|
2130
|
+
);
|
|
2131
|
+
}
|
|
2132
|
+
const found = several
|
|
2133
|
+
? candidates.map((target) => {
|
|
2134
|
+
const { skeletonPath, atlasPath } = resolveArtifacts(target, undefined);
|
|
2135
|
+
for (const path of [skeletonPath, atlasPath]) {
|
|
2136
|
+
if (!existsSync(path)) throw new UsageError(`nothing at ${path}`);
|
|
2137
|
+
}
|
|
2138
|
+
return { target, skeletonPath, atlasPath, atlasDir: dirname(atlasPath) };
|
|
2139
|
+
})
|
|
2140
|
+
: [{ target: flags.candidate, ...resolveViewable(flags) }];
|
|
2141
|
+
// ⚠️ By the FILE each one resolves to, not by the text typed: `build/` and
|
|
2142
|
+
// `build/skeleton.json` are two spellings of one candidate, and so are
|
|
2143
|
+
// `/tmp/x` and `/private/tmp/x` on a machine where one is a link to the other
|
|
2144
|
+
// — `resolve` alone left that pair unrefused, measured on macOS. A page
|
|
2145
|
+
// showing one skeleton twice is two panes that look like a comparison of
|
|
2146
|
+
// nothing. (`vote` accepts a repeat today, exit 0; that is its own card.)
|
|
2147
|
+
const identities = found.map((f) => realpathSync(f.skeletonPath));
|
|
2148
|
+
for (let i = 1; i < found.length; i++) {
|
|
2149
|
+
const first = identities.indexOf(identities[i]);
|
|
2150
|
+
if (first < i) {
|
|
2151
|
+
throw new UsageError(
|
|
2152
|
+
`--candidate ${JSON.stringify(found[i].target)} is ${identities[i]}, which --candidate ` +
|
|
2153
|
+
`${JSON.stringify(found[first].target)} already names (candidates ${first + 1} and ${i + 1}); a pane per ` +
|
|
2154
|
+
'candidate would show the same skeleton twice',
|
|
2155
|
+
);
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
// Every candidate's texts and animation are read before a line is printed,
|
|
2160
|
+
// so a refusal about any of them comes before the report, as it always has.
|
|
2161
|
+
const loaded = found.map((f, i) => {
|
|
2162
|
+
const skeletonText = readFileSync(f.skeletonPath, 'utf8');
|
|
2163
|
+
const atlasText = readFileSync(f.atlasPath, 'utf8');
|
|
2164
|
+
const animations = skeletonAnimationNames(skeletonText, f.skeletonPath);
|
|
2165
|
+
let chosen: string | undefined;
|
|
2166
|
+
try {
|
|
2167
|
+
chosen = readAnimationFlag(flags, animations);
|
|
2168
|
+
} catch (err) {
|
|
2169
|
+
// With several candidates the refusal has to say WHICH one lacks it.
|
|
2170
|
+
if (several && err instanceof UsageError) {
|
|
2171
|
+
throw new UsageError(`candidate ${i + 1} (${f.skeletonPath}): ${err.message}`);
|
|
2172
|
+
}
|
|
2173
|
+
throw err;
|
|
2174
|
+
}
|
|
2175
|
+
return { ...f, skeletonText, atlasText, animations, chosen };
|
|
2176
|
+
});
|
|
1972
2177
|
|
|
1973
2178
|
// A directory for --out is taken as "put the default name in here", because
|
|
1974
2179
|
// `--out render/` is what the sibling command means by the same flag and a
|
|
1975
2180
|
// preview written OVER a directory is not a recoverable mistake.
|
|
1976
2181
|
const target = resolve(flags.out ?? 'preview.html');
|
|
1977
2182
|
const out = existsSync(target) && statSync(target).isDirectory() ? join(target, 'preview.html') : target;
|
|
2183
|
+
const version = readVersion();
|
|
1978
2184
|
|
|
1979
2185
|
console.log('rigc preview');
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
2186
|
+
const inputs: PreviewInput[] = loaded.map((candidate, i) => {
|
|
2187
|
+
const { skeletonPath, atlasPath, atlasDir, skeletonText, atlasText, animations, chosen } = candidate;
|
|
2188
|
+
if (several) console.log(` .. pane ${i + 1} of ${loaded.length}`);
|
|
2189
|
+
console.log(` .. skeleton ${skeletonPath}`);
|
|
2190
|
+
console.log(` .. atlas ${atlasPath}`);
|
|
2191
|
+
const pages: PreviewPage[] = atlasPageNames(atlasText).map((name) => {
|
|
2192
|
+
const path = join(atlasDir, name);
|
|
2193
|
+
if (!existsSync(path)) {
|
|
2194
|
+
throw new UsageError(
|
|
2195
|
+
`the atlas declares page "${name}", which resolves to ${path} and is not there — ` +
|
|
2196
|
+
'a page a preview cannot embed is a page the player could not have loaded either',
|
|
2197
|
+
);
|
|
2198
|
+
}
|
|
2199
|
+
return { name, bytes: readFileSync(path) };
|
|
2200
|
+
});
|
|
2201
|
+
for (const page of pages) {
|
|
2202
|
+
console.log(` .. page ${page.name.padEnd(28)} ${(page.bytes.length / 1024).toFixed(1)} KiB`);
|
|
2203
|
+
}
|
|
2204
|
+
if (!declaresSetupStage(skeletonHeaderOf(skeletonText))) console.log(` .. ${STAGELESS_FRAMING.preview}`);
|
|
2205
|
+
const gate = previewGate(skeletonText, atlasText, atlasDir);
|
|
2206
|
+
console.log(` .. gate ${gate.summary}`);
|
|
2207
|
+
if (gate.refusal !== null) {
|
|
2208
|
+
console.log(
|
|
2209
|
+
` .. gate refused — ${gate.refusal}. Previewed anyway: looking at a red build is what preview is ` +
|
|
2210
|
+
'for, and the page header says the same',
|
|
1989
2211
|
);
|
|
1990
2212
|
}
|
|
1991
|
-
return {
|
|
2213
|
+
return {
|
|
2214
|
+
skeletonText,
|
|
2215
|
+
atlasText,
|
|
2216
|
+
pages,
|
|
2217
|
+
animation: chosen ?? animations[0] ?? null,
|
|
2218
|
+
animations,
|
|
2219
|
+
label: skeletonPath,
|
|
2220
|
+
version,
|
|
2221
|
+
gate,
|
|
2222
|
+
};
|
|
1992
2223
|
});
|
|
1993
|
-
for (const page of pages) {
|
|
1994
|
-
console.log(` .. page ${page.name.padEnd(28)} ${(page.bytes.length / 1024).toFixed(1)} KiB`);
|
|
1995
|
-
}
|
|
1996
|
-
if (!declaresSetupStage(skeletonHeaderOf(skeletonText))) console.log(` .. ${STAGELESS_FRAMING.preview}`);
|
|
1997
2224
|
|
|
1998
|
-
const html = buildPreview(
|
|
1999
|
-
skeletonText,
|
|
2000
|
-
atlasText,
|
|
2001
|
-
pages,
|
|
2002
|
-
animation: chosen ?? animations[0] ?? null,
|
|
2003
|
-
animations,
|
|
2004
|
-
label: skeletonPath,
|
|
2005
|
-
version: readVersion(),
|
|
2006
|
-
});
|
|
2225
|
+
const html = several ? buildPreviewPanes(inputs, version) : buildPreview(inputs[0]);
|
|
2007
2226
|
mkdirSync(dirname(out), { recursive: true });
|
|
2008
2227
|
writeFileSync(out, html);
|
|
2228
|
+
const pageCount = inputs.reduce((n, input) => n + input.pages.length, 0);
|
|
2009
2229
|
console.log(
|
|
2010
|
-
|
|
2011
|
-
`
|
|
2230
|
+
several
|
|
2231
|
+
? ` .. embedded ${pageCount} page(s) + ${inputs.length} skeletons and atlases as data URIs, one pane each; ` +
|
|
2232
|
+
`the player itself loads from unpkg (@${PLAYER_LINE}), so the first open needs a network`
|
|
2233
|
+
: ` .. embedded ${pageCount} page(s) + the skeleton and atlas as data URIs; ` +
|
|
2234
|
+
`the player itself loads from unpkg (@${PLAYER_LINE}), so the first open needs a network`,
|
|
2012
2235
|
);
|
|
2013
2236
|
console.log(`rigc: wrote ${out} (${(html.length / 1024).toFixed(1)} KiB — open it in a browser)`);
|
|
2014
2237
|
}
|
|
@@ -2181,8 +2404,8 @@ function cmdChainFit(flags: Record<string, string>): void {
|
|
|
2181
2404
|
// choosing between results — vote
|
|
2182
2405
|
// ---------------------------------------------------------------------------
|
|
2183
2406
|
//
|
|
2184
|
-
// ⭐ `preview` shows
|
|
2185
|
-
// and takes an answer back. The rest of this toolchain is instruments, and it
|
|
2407
|
+
// ⭐ `preview` shows candidates and asks nothing; this shows two to four of them
|
|
2408
|
+
// side by side, hides where each came from, and takes an answer back. The rest of this toolchain is instruments, and it
|
|
2186
2409
|
// should be — the vote opens only where the instruments have already run out.
|
|
2187
2410
|
// See `src/ballot.ts` for why the ballot is ordered compile-first-vote-last,
|
|
2188
2411
|
// why the labels are A and B, and why the record is hashes.
|
|
@@ -3270,6 +3493,223 @@ function cmdIngest(flags: Record<string, string>, positional: string[]): void {
|
|
|
3270
3493
|
}
|
|
3271
3494
|
}
|
|
3272
3495
|
|
|
3496
|
+
// ---------------------------------------------------------------------------
|
|
3497
|
+
// skills install — put the shipped skills where an agent host looks (issue #831)
|
|
3498
|
+
// ---------------------------------------------------------------------------
|
|
3499
|
+
//
|
|
3500
|
+
// After `bun add -d spine-rigc` the skills sit at `node_modules/spine-rigc/skills/`,
|
|
3501
|
+
// which no host reads. Codex, Gemini CLI and Antigravity all read
|
|
3502
|
+
// `<workspace>/.agents/skills/<name>/`, so this links every `skills/<name>/` the
|
|
3503
|
+
// package ships into one directory — `.agents/skills` under the working
|
|
3504
|
+
// directory unless `--dir` says otherwise.
|
|
3505
|
+
//
|
|
3506
|
+
// ⭐ The skills are found from THIS FILE's location, never from the working
|
|
3507
|
+
// directory: the command installs the package it is, and a cwd that happens to
|
|
3508
|
+
// hold some other `skills/` is not a source. That is also why it lives here and
|
|
3509
|
+
// not in `src/`: its one input is where the CLI was installed, nothing else
|
|
3510
|
+
// calls it, and `src/` is about rigs.
|
|
3511
|
+
//
|
|
3512
|
+
// A RELATIVE symlink by default, so the directory survives the project being
|
|
3513
|
+
// moved or cloned elsewhere and an upgrade of the package is seen with no second
|
|
3514
|
+
// run. `--copy` writes the folder instead, for a host that does not follow a
|
|
3515
|
+
// linked skill folder.
|
|
3516
|
+
//
|
|
3517
|
+
// 🔒 **An entry that is already there and is not what this command would write is
|
|
3518
|
+
// refused by name, and then nothing at all is written.** The check runs over
|
|
3519
|
+
// every skill before the first write, so a refusal never leaves half an install
|
|
3520
|
+
// behind. The one entry that is NOT refused is the one this command would have
|
|
3521
|
+
// made — a link that already resolves to the same skill folder, however it is
|
|
3522
|
+
// spelled, or with `--copy` a folder whose files are byte for byte the package's
|
|
3523
|
+
// — and a run over only those says it had nothing to do. No lifecycle script
|
|
3524
|
+
// does this on install: a postinstall writing into a consumer's project root is
|
|
3525
|
+
// refused as design, and Bun does not run a dependency's lifecycle scripts
|
|
3526
|
+
// outside `trustedDependencies`, so half the installs would silently skip it.
|
|
3527
|
+
// ---------------------------------------------------------------------------
|
|
3528
|
+
|
|
3529
|
+
/** The default `--dir`, resolved against the caller's working directory. */
|
|
3530
|
+
const DEFAULT_SKILLS_DIR = '.agents/skills';
|
|
3531
|
+
|
|
3532
|
+
/** What `rigc skills` offers. One today; the list is what the refusal of any other word prints. */
|
|
3533
|
+
const SKILLS_SUBCOMMANDS = ['install'];
|
|
3534
|
+
|
|
3535
|
+
/** An install refused before its first write — nothing to install, or an entry in the way. Exit 1. */
|
|
3536
|
+
class SkillsInstallError extends Error {}
|
|
3537
|
+
|
|
3538
|
+
type SkillsInstallAction = 'linked' | 'copied' | 'already linked' | 'already copied';
|
|
3539
|
+
|
|
3540
|
+
interface SkillsInstallEntry {
|
|
3541
|
+
/** `<dir>/<name>`. */
|
|
3542
|
+
target: string;
|
|
3543
|
+
/** `<package>/skills/<name>`. */
|
|
3544
|
+
source: string;
|
|
3545
|
+
action: SkillsInstallAction;
|
|
3546
|
+
/** The link text, relative to the directory it sits in, when the entry is a link. */
|
|
3547
|
+
link: string;
|
|
3548
|
+
}
|
|
3549
|
+
|
|
3550
|
+
/** Every `<name>/` under `source` that holds a `SKILL.md`, in name order, so two runs print the same lines. */
|
|
3551
|
+
function shippedSkills(source: string): string[] {
|
|
3552
|
+
if (!existsSync(source) || !statSync(source).isDirectory()) return [];
|
|
3553
|
+
return readdirSync(source)
|
|
3554
|
+
.filter((name) => statSync(join(source, name)).isDirectory() && existsSync(join(source, name, 'SKILL.md')))
|
|
3555
|
+
.sort();
|
|
3556
|
+
}
|
|
3557
|
+
|
|
3558
|
+
/**
|
|
3559
|
+
* The real path of `path` whether or not it exists yet: the real path of its
|
|
3560
|
+
* nearest existing ancestor with the rest appended. A relative link has to be
|
|
3561
|
+
* computed between two paths spelled the same way, and on macOS the temp
|
|
3562
|
+
* directory alone is reached as `/var/…` and is really `/private/var/…`.
|
|
3563
|
+
*/
|
|
3564
|
+
function realpathAhead(path: string): string {
|
|
3565
|
+
const rest: string[] = [];
|
|
3566
|
+
let at = resolve(path);
|
|
3567
|
+
while (!existsSync(at)) {
|
|
3568
|
+
const up = dirname(at);
|
|
3569
|
+
if (up === at) break;
|
|
3570
|
+
rest.unshift(basename(at));
|
|
3571
|
+
at = up;
|
|
3572
|
+
}
|
|
3573
|
+
return join(realpathSync(at), ...rest);
|
|
3574
|
+
}
|
|
3575
|
+
|
|
3576
|
+
/** Every file under `root`, relative and sorted, so two trees compare in one order. */
|
|
3577
|
+
function filesUnder(root: string, prefix = ''): string[] {
|
|
3578
|
+
const out: string[] = [];
|
|
3579
|
+
for (const name of readdirSync(join(root, prefix)).sort()) {
|
|
3580
|
+
const rel = prefix === '' ? name : `${prefix}/${name}`;
|
|
3581
|
+
if (lstatSync(join(root, rel)).isDirectory()) out.push(...filesUnder(root, rel));
|
|
3582
|
+
else out.push(rel);
|
|
3583
|
+
}
|
|
3584
|
+
return out;
|
|
3585
|
+
}
|
|
3586
|
+
|
|
3587
|
+
/** The first way `copy` differs from `original`, or null when every file is the same bytes. */
|
|
3588
|
+
function firstDifference(copy: string, original: string): string | null {
|
|
3589
|
+
const theirs = filesUnder(copy);
|
|
3590
|
+
const ours = filesUnder(original);
|
|
3591
|
+
for (const rel of ours) {
|
|
3592
|
+
if (!theirs.includes(rel)) return `${rel} is missing from it`;
|
|
3593
|
+
if (!readFileSync(join(copy, rel)).equals(readFileSync(join(original, rel)))) return `${rel} differs`;
|
|
3594
|
+
}
|
|
3595
|
+
for (const rel of theirs) if (!ours.includes(rel)) return `${rel} is in it and not in the package`;
|
|
3596
|
+
return null;
|
|
3597
|
+
}
|
|
3598
|
+
|
|
3599
|
+
/**
|
|
3600
|
+
* Install every shipped skill into `dir`, or refuse and write nothing.
|
|
3601
|
+
*
|
|
3602
|
+
* The plan is made in full before the first write: every entry is classified as
|
|
3603
|
+
* absent, already this command's, or in the way, and one entry in the way
|
|
3604
|
+
* refuses the whole call with every such entry named.
|
|
3605
|
+
*/
|
|
3606
|
+
function installSkills(source: string, dir: string, copy: boolean): SkillsInstallEntry[] {
|
|
3607
|
+
const names = shippedSkills(source);
|
|
3608
|
+
if (names.length === 0) {
|
|
3609
|
+
throw new SkillsInstallError(
|
|
3610
|
+
`no skill to install: ${source} ${existsSync(source) ? 'holds no <name>/SKILL.md' : 'is not there'}, and it is ` +
|
|
3611
|
+
'the skills/ directory of the package this command ran from; nothing was written',
|
|
3612
|
+
);
|
|
3613
|
+
}
|
|
3614
|
+
if (existsSync(dir) && !statSync(dir).isDirectory()) {
|
|
3615
|
+
throw new SkillsInstallError(`${dir} exists and is not a directory, so no skill can be installed into it; nothing was written`);
|
|
3616
|
+
}
|
|
3617
|
+
const realDir = realpathAhead(dir);
|
|
3618
|
+
const planned: SkillsInstallEntry[] = [];
|
|
3619
|
+
const refused: string[] = [];
|
|
3620
|
+
for (const name of names) {
|
|
3621
|
+
const target = join(dir, name);
|
|
3622
|
+
const from = join(source, name);
|
|
3623
|
+
const realFrom = realpathSync(from);
|
|
3624
|
+
const link = relative(realDir, realFrom);
|
|
3625
|
+
let action: SkillsInstallAction = copy ? 'copied' : 'linked';
|
|
3626
|
+
let found: string | null = null;
|
|
3627
|
+
const stat = existsSync(target) || isLink(target) ? lstatSync(target) : null;
|
|
3628
|
+
if (stat === null) {
|
|
3629
|
+
// absent: this command writes it
|
|
3630
|
+
} else if (stat.isSymbolicLink()) {
|
|
3631
|
+
const text = readlinkSync(target);
|
|
3632
|
+
const pointsAt = resolve(realDir, text);
|
|
3633
|
+
const lands = existsSync(pointsAt) ? realpathSync(pointsAt) : null;
|
|
3634
|
+
if (lands === realFrom && !copy) action = 'already linked';
|
|
3635
|
+
else if (lands === realFrom) found = `a symlink to ${text}, the package's own folder, and --copy asks for a directory in its place`;
|
|
3636
|
+
else found = `a symlink to ${text}, ${lands === null ? 'which resolves to nothing' : `which resolves to ${lands}`}`;
|
|
3637
|
+
} else if (stat.isDirectory()) {
|
|
3638
|
+
const difference = copy ? firstDifference(target, from) : null;
|
|
3639
|
+
if (!copy) found = 'a directory';
|
|
3640
|
+
else if (difference === null) action = 'already copied';
|
|
3641
|
+
else found = `a directory that is not the package's copy (${difference})`;
|
|
3642
|
+
} else {
|
|
3643
|
+
found = 'a plain file';
|
|
3644
|
+
}
|
|
3645
|
+
if (found !== null) refused.push(`${target} is ${found}; ${copy ? `a copy of ${from}` : `a symlink to ${link}`} was required`);
|
|
3646
|
+
else planned.push({ target, source: from, action, link });
|
|
3647
|
+
}
|
|
3648
|
+
if (refused.length > 0) {
|
|
3649
|
+
throw new SkillsInstallError(
|
|
3650
|
+
`${refused.length} of the ${names.length} skill(s) cannot be installed into ${dir}, and nothing was written:\n` +
|
|
3651
|
+
refused.map((line) => ` ${line}`).join('\n') +
|
|
3652
|
+
'\nRemove the entries named above, or pass --dir to install somewhere else.',
|
|
3653
|
+
);
|
|
3654
|
+
}
|
|
3655
|
+
mkdirSync(dir, { recursive: true });
|
|
3656
|
+
for (const entry of planned) {
|
|
3657
|
+
if (entry.action === 'linked') symlinkSync(entry.link, entry.target, 'dir');
|
|
3658
|
+
else if (entry.action === 'copied') cpSync(entry.source, entry.target, { recursive: true, errorOnExist: true, force: false });
|
|
3659
|
+
}
|
|
3660
|
+
return planned;
|
|
3661
|
+
}
|
|
3662
|
+
|
|
3663
|
+
/** A dangling link is not `existsSync`, and is still an entry in the way. */
|
|
3664
|
+
function isLink(path: string): boolean {
|
|
3665
|
+
try {
|
|
3666
|
+
return lstatSync(path).isSymbolicLink();
|
|
3667
|
+
} catch {
|
|
3668
|
+
return false;
|
|
3669
|
+
}
|
|
3670
|
+
}
|
|
3671
|
+
|
|
3672
|
+
function cmdSkills(flags: Record<string, string>, positional: string[]): void {
|
|
3673
|
+
const [sub, ...extra] = positional;
|
|
3674
|
+
if (sub === undefined) {
|
|
3675
|
+
throw new UsageError(
|
|
3676
|
+
`skills takes a subcommand: ${SKILLS_SUBCOMMANDS.join(', ')} — \`rigc skills install\` links every skill this ` +
|
|
3677
|
+
`package ships into ${DEFAULT_SKILLS_DIR}`,
|
|
3678
|
+
);
|
|
3679
|
+
}
|
|
3680
|
+
if (!SKILLS_SUBCOMMANDS.includes(sub)) {
|
|
3681
|
+
throw new UsageError(`unknown skills subcommand: ${sub} (rigc skills offers ${SKILLS_SUBCOMMANDS.join(', ')})`);
|
|
3682
|
+
}
|
|
3683
|
+
if (extra.length > 0) {
|
|
3684
|
+
throw new UsageError(
|
|
3685
|
+
`skills install takes no positional argument, and ${JSON.stringify(extra[0])} was given — the directory is --dir <path>`,
|
|
3686
|
+
);
|
|
3687
|
+
}
|
|
3688
|
+
const takes = COMMANDS.find((c) => c.name === 'skills')?.flags ?? [];
|
|
3689
|
+
const foreign = Object.keys(flags).filter((flag) => !takes.includes(flag));
|
|
3690
|
+
if (foreign.length > 0) {
|
|
3691
|
+
throw new UsageError(
|
|
3692
|
+
`skills install takes ${takes.map((flag) => `--${flag}`).join(' and ')}; ` +
|
|
3693
|
+
`${foreign.map((flag) => `--${flag}`).join(', ')} is not one of them`,
|
|
3694
|
+
);
|
|
3695
|
+
}
|
|
3696
|
+
const copy = flags.copy !== undefined;
|
|
3697
|
+
const dir = resolve(process.cwd(), flags.dir ?? DEFAULT_SKILLS_DIR);
|
|
3698
|
+
const entries = installSkills(join(import.meta.dir, 'skills'), dir, copy);
|
|
3699
|
+
for (const entry of entries) {
|
|
3700
|
+
const ends = entry.action.endsWith('linked') ? `${entry.target} -> ${entry.link} (${entry.source})` : `${entry.target} <- ${entry.source}`;
|
|
3701
|
+
console.log(` ${entry.action.padEnd(14)} ${ends}`);
|
|
3702
|
+
}
|
|
3703
|
+
const wrote = entries.filter((entry) => entry.action === 'linked' || entry.action === 'copied').length;
|
|
3704
|
+
const verb = copy ? 'copied' : 'linked';
|
|
3705
|
+
console.log(
|
|
3706
|
+
wrote === 0
|
|
3707
|
+
? `rigc skills install: nothing to do — all ${entries.length} skill(s) are already ${verb} into ${dir}`
|
|
3708
|
+
: `rigc skills install: ${wrote} of ${entries.length} skill(s) ${verb} into ${dir}` +
|
|
3709
|
+
(wrote < entries.length ? `, ${entries.length - wrote} already there` : ''),
|
|
3710
|
+
);
|
|
3711
|
+
}
|
|
3712
|
+
|
|
3273
3713
|
// ---------------------------------------------------------------------------
|
|
3274
3714
|
// usage / per-command help
|
|
3275
3715
|
// ---------------------------------------------------------------------------
|
|
@@ -3363,11 +3803,27 @@ const FLAG_MEANINGS: Record<string, string> = {
|
|
|
3363
3803
|
'through the default skin alone, so a slot whose art lives only in a named skin draws nothing. A name the ' +
|
|
3364
3804
|
'skeleton does not declare is refused with the ones it does. `render` records the skin in frames.json and ' +
|
|
3365
3805
|
'`check` reads it back, so a skin-A candidate is not scored against skin-B frames in silence',
|
|
3806
|
+
slot:
|
|
3807
|
+
'draw only these slots, comma-separated, in the skeleton\'s draw order, on the SAME grid as the whole rig: the ' +
|
|
3808
|
+
'viewport is still fitted to every slot, so this frame overlays the full one pixel for pixel. A name the ' +
|
|
3809
|
+
'skeleton does not declare is refused with every one it does; a slot whose art lives only under another skin ' +
|
|
3810
|
+
'is refused naming that skin. frames.json records the subset, and `check` refuses such a set as a reference',
|
|
3811
|
+
hide:
|
|
3812
|
+
'draw every slot but these, comma-separated — `--slot` the other way round, on the same grid, recorded and ' +
|
|
3813
|
+
'refused the same way. Not with `--slot`: the two are one statement',
|
|
3366
3814
|
max: 'longest side of a rendered frame, in pixels (default 256)',
|
|
3367
3815
|
record: 'a saved vote to check against its ballot and append to the ledger, instead of writing a ballot',
|
|
3368
3816
|
ballot: `the ballot the --record'd vote answers (default \`${DEFAULT_BALLOT}\`); its embedded manifest is what the vote is checked against`,
|
|
3369
3817
|
ledger: `the append-only JSONL the vote lands in (default \`${DEFAULT_LEDGER}\`)`,
|
|
3370
3818
|
again: 'record a second vote on a ballot the ledger already has; without it, a repeat is refused rather than doubled',
|
|
3819
|
+
dir:
|
|
3820
|
+
`the directory to install into, resolved against your working directory (default \`${DEFAULT_SKILLS_DIR}\`, the ` +
|
|
3821
|
+
'workspace directory Codex, Gemini CLI and Antigravity read skills from)',
|
|
3822
|
+
copy:
|
|
3823
|
+
'copy each skill folder instead of linking it, for a host that does not follow a linked skill folder. A copy ' +
|
|
3824
|
+
'is not reached by an upgrade of the package, and one that is no longer the package\'s bytes is refused by name ' +
|
|
3825
|
+
'on the next run — remove it and run again (default: a relative symlink, which an upgrade reaches with no ' +
|
|
3826
|
+
'second run)',
|
|
3371
3827
|
name: "the rig spec's own name, which the motion spec's archetype must match (default: the skeleton file's basename)",
|
|
3372
3828
|
art: 'how the written spec reaches the art, which a skeleton does not encode: `loose` names an image per ' +
|
|
3373
3829
|
"attachment, measured out of the rig spec's own images directory (--images writes it; without it, `build " +
|
|
@@ -3423,6 +3879,8 @@ const FLAG_VALUES: Record<string, string> = {
|
|
|
3423
3879
|
'inward-lever': '<px>',
|
|
3424
3880
|
animation: '<name>',
|
|
3425
3881
|
skin: '<name>',
|
|
3882
|
+
slot: '<name[,name…]>',
|
|
3883
|
+
hide: '<name[,name…]>',
|
|
3426
3884
|
max: '<px>',
|
|
3427
3885
|
record: '<result.json>',
|
|
3428
3886
|
ballot: '<ballot.html>',
|
|
@@ -3430,6 +3888,7 @@ const FLAG_VALUES: Record<string, string> = {
|
|
|
3430
3888
|
name: '<n>',
|
|
3431
3889
|
art: 'loose|none',
|
|
3432
3890
|
stage: '<x,y,w,h>',
|
|
3891
|
+
dir: '<path>',
|
|
3433
3892
|
};
|
|
3434
3893
|
|
|
3435
3894
|
interface CommandDoc {
|
|
@@ -3572,7 +4031,7 @@ const COMMANDS: CommandDoc[] = [
|
|
|
3572
4031
|
{
|
|
3573
4032
|
name: 'check',
|
|
3574
4033
|
usage: ['rigc check --candidate <dir | skeleton.json> --frames <dir> [flags]'],
|
|
3575
|
-
flags: ['candidate', 'frames', 'atlas', 'texture-from', 'fps', 'viewport', 'framing', 'as', 'skin', 'all-frames', 'json'],
|
|
4034
|
+
flags: ['candidate', 'frames', 'atlas', 'texture-from', 'fps', 'viewport', 'framing', 'as', 'skin', 'all-frames', 'json', 'out'],
|
|
3576
4035
|
overrides: {
|
|
3577
4036
|
skin: {
|
|
3578
4037
|
meaning:
|
|
@@ -3581,6 +4040,16 @@ const COMMANDS: CommandDoc[] = [
|
|
|
3581
4040
|
'contested art. The frames are checked back: a set whose frames.json records a different skin is ' +
|
|
3582
4041
|
'REFUSED by name, and one that records none says so in the report rather than pretending to agree',
|
|
3583
4042
|
},
|
|
4043
|
+
out: {
|
|
4044
|
+
value: '<dir>',
|
|
4045
|
+
meaning:
|
|
4046
|
+
'also write the PICTURE each listed frame\'s figures came from, as <dir>/<set>/f####.png: reference, ' +
|
|
4047
|
+
'candidate, difference and overlay side by side at the comparison grid\'s native size, with the table\'s ' +
|
|
4048
|
+
'figures burned in and one row per slot under them, beside a frames.json that says what they are pictures ' +
|
|
4049
|
+
'of. The frames are the ones the table lists, so --all-frames writes every compared one. Each <dir>/<set>/ ' +
|
|
4050
|
+
'is cleared first; a file at <dir>, or a directory that is or holds --frames, is refused. See ' +
|
|
4051
|
+
'docs/AUTHORING.md §9.2.1',
|
|
4052
|
+
},
|
|
3584
4053
|
},
|
|
3585
4054
|
notes: [
|
|
3586
4055
|
'every figure here is a reporting threshold, not a pass bar. Nothing in this report',
|
|
@@ -3623,8 +4092,9 @@ const COMMANDS: CommandDoc[] = [
|
|
|
3623
4092
|
name: 'render',
|
|
3624
4093
|
usage: [
|
|
3625
4094
|
'rigc render --candidate <dir | skeleton.json> [--animation <name>] [--skin <name>] [--fps 12] [--max 256] [--out render/]',
|
|
4095
|
+
'rigc render … --slot <name[,name…]> | --hide <name[,name…]> (a subset of the slots, on the whole rig\'s grid)',
|
|
3626
4096
|
],
|
|
3627
|
-
flags: ['candidate', 'atlas', 'animation', 'skin', 'fps', 'max', 'out'],
|
|
4097
|
+
flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'out'],
|
|
3628
4098
|
overrides: {
|
|
3629
4099
|
out: { value: '<dir>', meaning: 'directory to write the frame series into (default `render/`)' },
|
|
3630
4100
|
fps: { meaning: `frames per second to sample the animation at (default ${PROTOCOL_FPS})` },
|
|
@@ -3632,9 +4102,22 @@ const COMMANDS: CommandDoc[] = [
|
|
|
3632
4102
|
},
|
|
3633
4103
|
{
|
|
3634
4104
|
name: 'preview',
|
|
3635
|
-
usage: ['rigc preview --candidate <dir | skeleton.json> [--animation <name>] [--out preview.html]'],
|
|
4105
|
+
usage: ['rigc preview --candidate <dir | skeleton.json> [--candidate <another> …] [--animation <name>] [--out preview.html]'],
|
|
3636
4106
|
flags: ['candidate', 'atlas', 'animation', 'out'],
|
|
3637
4107
|
overrides: {
|
|
4108
|
+
candidate: {
|
|
4109
|
+
value: '<dir|skeleton.json>',
|
|
4110
|
+
meaning:
|
|
4111
|
+
'a compiled skeleton: a directory holding skeleton.json + skeleton.atlas, or a skeleton.json path. Repeat it ' +
|
|
4112
|
+
'for one page with a pane per candidate, in the order given; the same skeleton twice is refused. Each ' +
|
|
4113
|
+
'header carries the line `rigc validate <dir>` prints for that candidate, measured when the page is written',
|
|
4114
|
+
},
|
|
4115
|
+
atlas: { meaning: "the candidate's atlas, when it is not beside the skeleton — one candidate only" },
|
|
4116
|
+
animation: {
|
|
4117
|
+
meaning:
|
|
4118
|
+
"the animation to start on (default: each candidate's own first). With several candidates every one of " +
|
|
4119
|
+
'them must have it, or the run is refused naming the one that does not',
|
|
4120
|
+
},
|
|
3638
4121
|
out: {
|
|
3639
4122
|
value: '<file>',
|
|
3640
4123
|
meaning: 'the .html file to write (default `preview.html`); a directory means "the default name in here"',
|
|
@@ -3723,6 +4206,21 @@ const COMMANDS: CommandDoc[] = [
|
|
|
3723
4206
|
},
|
|
3724
4207
|
},
|
|
3725
4208
|
},
|
|
4209
|
+
{
|
|
4210
|
+
name: 'skills',
|
|
4211
|
+
usage: [`rigc skills install [--dir ${DEFAULT_SKILLS_DIR}] [--copy] (every skill this package ships, where an agent host looks)`],
|
|
4212
|
+
flags: ['dir', 'copy'],
|
|
4213
|
+
notes: [
|
|
4214
|
+
'the skills installed are the skills/ directory of the package this command runs from,',
|
|
4215
|
+
'never whatever the working directory holds. Each becomes <dir>/<name>: a relative',
|
|
4216
|
+
'symlink into that folder, or with --copy a copy of it. An entry already there that',
|
|
4217
|
+
'is not a link to the same folder — or, with --copy, not the same bytes — is refused',
|
|
4218
|
+
'by name, exit 1, and nothing is written; a run over only what this command made has',
|
|
4219
|
+
'nothing to do and exits 0. Codex, Gemini CLI and Antigravity read',
|
|
4220
|
+
`<workspace>/${DEFAULT_SKILLS_DIR}; Claude Code installs the plugin instead (README,`,
|
|
4221
|
+
'"Install it into your agent").',
|
|
4222
|
+
],
|
|
4223
|
+
},
|
|
3726
4224
|
];
|
|
3727
4225
|
|
|
3728
4226
|
const KNOWN_COMMANDS = COMMANDS.map((c) => c.name);
|
|
@@ -3829,6 +4327,13 @@ const USAGE = [
|
|
|
3829
4327
|
'answer rather than a missing one, and a result whose hashes are not the ballot\'s is',
|
|
3830
4328
|
'refused by name instead of appended.',
|
|
3831
4329
|
'',
|
|
4330
|
+
'skills install puts the agent skills this package ships where an agent host looks',
|
|
4331
|
+
'for them, since none of them reads node_modules:',
|
|
4332
|
+
` rigc skills install relative links in ${DEFAULT_SKILLS_DIR}, which Codex, Gemini CLI`,
|
|
4333
|
+
' and Antigravity read; --copy writes the folders instead',
|
|
4334
|
+
'An entry already there that this command did not make is refused by name and',
|
|
4335
|
+
'nothing is written; a second run has nothing to do. See `rigc skills --help`.',
|
|
4336
|
+
'',
|
|
3832
4337
|
'a cuts.json is { "<name>": { "rig": "...", "motion": "...", "out": "...",',
|
|
3833
4338
|
' "manifest": "..." (optional) } }, with every path',
|
|
3834
4339
|
'resolved relative to the cuts.json file itself.',
|
|
@@ -3866,10 +4371,11 @@ try {
|
|
|
3866
4371
|
else if (command === 'bench') cmdBench(flags, positional);
|
|
3867
4372
|
else if (command === 'bonedist') cmdBoneDist(flags);
|
|
3868
4373
|
else if (command === 'render') cmdRender(flags);
|
|
3869
|
-
else if (command === 'preview') cmdPreview(flags);
|
|
4374
|
+
else if (command === 'preview') cmdPreview(flags, lists.candidate ?? []);
|
|
3870
4375
|
else if (command === 'pose') cmdPose(flags);
|
|
3871
4376
|
else if (command === 'chainfit') cmdChainFit(flags);
|
|
3872
4377
|
else if (command === 'vote') cmdVote(flags, lists.candidate ?? []);
|
|
4378
|
+
else if (command === 'skills') cmdSkills(flags, positional);
|
|
3873
4379
|
} catch (err) {
|
|
3874
4380
|
if (err instanceof UsageError) {
|
|
3875
4381
|
console.error(`rigc: ${err.message}\n\n${USAGE}`);
|
|
@@ -3940,5 +4446,13 @@ try {
|
|
|
3940
4446
|
console.error(`rigc: ${err.message}`);
|
|
3941
4447
|
process.exit(1);
|
|
3942
4448
|
}
|
|
4449
|
+
// An install refused before its first write (issue #831): the invocation was
|
|
4450
|
+
// fine and an entry on disk was not what this command would write, so exit 1
|
|
4451
|
+
// like a file that is not a PNG. The message names every such entry, what is
|
|
4452
|
+
// there and what was required; the usage under it would bury that.
|
|
4453
|
+
if (err instanceof SkillsInstallError) {
|
|
4454
|
+
console.error(`rigc skills install: ${err.message}`);
|
|
4455
|
+
process.exit(1);
|
|
4456
|
+
}
|
|
3943
4457
|
throw err;
|
|
3944
4458
|
}
|