@erclx/canon 4.65.0 → 4.66.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/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/canon-frames-read/REQUIREMENT.md +34 -0
- package/claude/skills/canon-frames-read/SKILL.md +46 -0
- package/claude/skills/claude-worktree/SKILL.md +18 -4
- package/docs/agents/demo.md +17 -1
- package/docs/agents/index.md +1 -1
- package/docs/workflow/ai-workflow.md +9 -5
- package/package.json +1 -1
- package/src/claude/cases/claude-workflow.ts +5 -0
- package/src/commands/claude.ts +15 -1
- package/src/commands/demo.ts +97 -1
- package/src/commands/design.ts +12 -3
- package/src/commands/docs.ts +4 -1
- package/src/commands/gov.ts +24 -4
- package/src/commands/migrate.ts +4 -1
- package/src/commands/sandbox.ts +7 -1
- package/src/commands/snippets.ts +8 -2
- package/src/commands/tooling.ts +10 -8
- package/src/demo/container.ts +95 -0
- package/src/exec.ts +5 -1
- package/src/project-root.ts +17 -0
- package/src/sync/engine.ts +4 -0
- package/src/target.ts +5 -1
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: canon-frames-read
|
|
3
|
+
description: Why a recording gets read back through numbered frames rather than left for a person to open, and why the report stops at description and never reaches a verdict
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Canon frames read requirement
|
|
7
|
+
|
|
8
|
+
## Gap
|
|
9
|
+
|
|
10
|
+
`ffmpeg` already ships as a dependency of the demo path, converting the raw recording to mp4 and gif, but nothing reads a recording back today. A demo run's own output is verified only when a person opens the video by hand, so a broken recording ships until someone happens to look.
|
|
11
|
+
|
|
12
|
+
## Must
|
|
13
|
+
|
|
14
|
+
- Call `canon demo frames <video> --json` rather than reading the video any other way
|
|
15
|
+
- Read every path the record's `frames` array names, in order, with the Read tool
|
|
16
|
+
- Report one plain description per frame, covering the visible UI state, on-screen text, cursor position, and what changed since the frame before it
|
|
17
|
+
- Branch on the record's `reason` for a refusal (`video-missing`, `converter-missing`, `extraction-failed`) and report it rather than guessing what the recording shows
|
|
18
|
+
|
|
19
|
+
## Must not
|
|
20
|
+
|
|
21
|
+
- Write a pass-fail judgment, a "looks correct" line, or a "looks broken" line anywhere in the report. A frame read is evidence a person weighs, not a verdict this skill hands them.
|
|
22
|
+
- Drive the application. This skill only reads files the verb already wrote.
|
|
23
|
+
- Edit, trim, or otherwise modify the recording.
|
|
24
|
+
- Assume this skill's own invocation frequency needs no check. Nothing names it as a step after `canon demo run` beyond an operator typing it or a body pointing here by hand, so a review pass some months in should read that back rather than take it on faith.
|
|
25
|
+
|
|
26
|
+
## Guards
|
|
27
|
+
|
|
28
|
+
- No recording path given: stop rather than guessing which video to read
|
|
29
|
+
|
|
30
|
+
## Out of scope
|
|
31
|
+
|
|
32
|
+
- Recording the video itself, which `canon demo run` owns
|
|
33
|
+
- Judging whether the recording is correct, which nothing in the toolkit does yet
|
|
34
|
+
- Sampling fewer frames than the verb's `--fps` default produces, since nothing has measured a long or high-fps recording as a real problem yet
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: canon-frames-read
|
|
3
|
+
description: Pulls numbered still frames from a recorded video through `canon demo frames`, reads each one with the Read tool, and reports one plain description per frame. Never judges the recording, since a frame read is evidence rather than a verdict. Use when asked to "check the recording", "read the demo frames", "see what the video shows", or right after `canon demo run` writes a video and nobody has opened it yet. Do NOT use to record the video, which is `canon-record`, or to state whether the recording looks correct or broken, which is out of scope for every surface in the toolkit today.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Canon frames read
|
|
7
|
+
|
|
8
|
+
## Guards
|
|
9
|
+
|
|
10
|
+
- If no recording path is given, stop: `❌ No recording path. Pass the video canon demo run wrote.`
|
|
11
|
+
- Never write a pass-fail judgment, a "looks correct" line, or a "looks broken" line, anywhere in the report. A frame read is evidence a person weighs, not a verdict this skill hands them.
|
|
12
|
+
- Never drive the application. Everything this skill touches is the frame files the verb already wrote, and it opens no browser and clicks nothing.
|
|
13
|
+
- Never edit, trim, or otherwise modify the recording. The frames verb writes new files beside it, and this skill only reads what that writes.
|
|
14
|
+
|
|
15
|
+
## Step 1: extract frames
|
|
16
|
+
|
|
17
|
+
Run:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
canon demo frames <video> --json
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Branch on the record's `reason` rather than the exit code:
|
|
24
|
+
|
|
25
|
+
- `video-missing`: report that the path does not resolve and stop.
|
|
26
|
+
- `converter-missing`: report that ffmpeg is not installed and stop. `canon demo frames --help` names the install line.
|
|
27
|
+
- `extraction-failed`: report the record's `message` and stop.
|
|
28
|
+
- No `reason` key, meaning frames were written: continue to Step 2 with the record's `frames` array.
|
|
29
|
+
|
|
30
|
+
## Step 2: read every frame
|
|
31
|
+
|
|
32
|
+
Read each path in `frames`, in array order, with the Read tool. Do not skip any and do not sample a subset, since a gap in the sequence is a gap in what the report covers.
|
|
33
|
+
|
|
34
|
+
## Step 3: report
|
|
35
|
+
|
|
36
|
+
One numbered list entry per frame, each a plain description of what is on screen: the visible UI state, on-screen text, cursor position, and anything that changed from the entry before it. Skip a frame that is identical to its predecessor rather than repeating the same sentence.
|
|
37
|
+
|
|
38
|
+
```markdown
|
|
39
|
+
1. <plain description of frame 1>
|
|
40
|
+
2. <plain description of frame 2>
|
|
41
|
+
...
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Close with the frame count and the source video path. Say nothing about whether the recording succeeded or looks right. That call belongs to whoever reads the report.
|
|
45
|
+
|
|
46
|
+
Say so plainly if `canon demo frames` is not available, rather than reading the video some other way. The verb ships with the CLI and this skill ships with the plugin, so a project carrying one and not the other is a real state.
|
|
@@ -118,14 +118,28 @@ A linked worktree is a second working directory over one repository, and every e
|
|
|
118
118
|
|
|
119
119
|
Report the state on one line. Do not install. Entering a worktree to read is as common as entering one to run, and an install is slow, needs a network, and picks an ecosystem on the session's behalf.
|
|
120
120
|
|
|
121
|
-
Read the worktree root and evaluate node and python independently, each emitting its own line regardless of the other's state:
|
|
121
|
+
Read the worktree root and evaluate node and python independently against a literal presence test, each emitting its own line regardless of the other's state:
|
|
122
122
|
|
|
123
|
-
- Node. `package.json
|
|
124
|
-
- Python.
|
|
123
|
+
- Node. `[ -f package.json ] && [ ! -d node_modules ]`: `Dependencies are not installed. Run <install> before any build, test, or server command.` `[ -f package.json ] && [ -d node_modules ]`: `Node dependencies are installed.`
|
|
124
|
+
- Python. `{ [ -f pyproject.toml ] || [ -f requirements.txt ]; } && [ ! -d .venv ]`: `No virtual environment. Create and populate one before running anything.` `{ [ -f pyproject.toml ] || [ -f requirements.txt ]; } && [ -d .venv ]`: `Python dependencies are installed.`
|
|
125
|
+
|
|
126
|
+
`[ -d node_modules ]` reads a symlinked `node_modules` as present, which is the correct read rather than a regression.
|
|
125
127
|
|
|
126
128
|
Name the ecosystem in both installed lines rather than leaving `Dependencies are installed.` unqualified. Both checks can fire on one project, so an unqualified line reported the same sentence twice for a dual-root project with both folders present, and a reader could not tell which half each line answered.
|
|
127
129
|
|
|
128
|
-
|
|
130
|
+
Take `<install>` from the lockfile beside the manifest, checked in this order, first match wins:
|
|
131
|
+
|
|
132
|
+
| Lockfile | Install command |
|
|
133
|
+
| ------------------------- | --------------- |
|
|
134
|
+
| `bun.lock` or `bun.lockb` | `bun install` |
|
|
135
|
+
| `pnpm-lock.yaml` | `pnpm install` |
|
|
136
|
+
| `yarn.lock` | `yarn install` |
|
|
137
|
+
| `package-lock.json` | `npm install` |
|
|
138
|
+
| none of the above | `bun install` |
|
|
139
|
+
|
|
140
|
+
The order matters only when more than one lockfile sits beside the manifest, such as a project mid-migration between package managers. It is fixed rather than derived from anything about the project, so a reader hitting that rare case checks which manager the project actually uses rather than trusting the row the table picked first.
|
|
141
|
+
|
|
142
|
+
Emit the closing line only on its own direct test, run ahead of the two checks above rather than reached by falling through them unmatched: `[ ! -f package.json ] && [ ! -f pyproject.toml ] && [ ! -f requirements.txt ]`: `No package manifest, so there is nothing to install.` A dual-root project matches both checks above, and a fallthrough test would route it here by accident.
|
|
129
143
|
|
|
130
144
|
The closing line is what keeps the step honest on a stack this skill cannot read. Entry is not stack-aware, and silence is indistinguishable from a check that passed.
|
|
131
145
|
|
package/docs/agents/demo.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Demo
|
|
3
|
-
description: Compiling a screencast draft into a runnable plan, driving a served application to a recording and a still, the pointer the recording paints, and what each refusal reports
|
|
3
|
+
description: Compiling a screencast draft into a runnable plan, driving a served application to a recording and a still, reading numbered frames back out of a recording, the pointer the recording paints, and what each refusal reports
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Demo
|
|
@@ -13,6 +13,7 @@ Two verbs, and they are separate because the artifact between them is edited.
|
|
|
13
13
|
canon demo compile .canon/tmp/screencast/inline-edit.md
|
|
14
14
|
canon demo run demos/inline-edit.json
|
|
15
15
|
canon demo run demos/inline-edit.json --cursor ~/cursors/theme --out assets
|
|
16
|
+
canon demo frames demos/inline-edit.webm --fps 2
|
|
16
17
|
```
|
|
17
18
|
|
|
18
19
|
## The draft and the plan are different files
|
|
@@ -53,6 +54,18 @@ When `ffmpeg` is on PATH, the run also writes an mp4 beside the webm, since webm
|
|
|
53
54
|
|
|
54
55
|
A step waits on its `waitFor` selector becoming visible and then holds for its own `holdMs`, which is what puts a finished state on screen long enough to read. `navigate` uses the plan's URL unless the step names its own.
|
|
55
56
|
|
|
57
|
+
## What frames does
|
|
58
|
+
|
|
59
|
+
`canon demo frames` pulls numbered PNG stills back out of a recorded video through the same `ffmpeg` binary the mp4 and gif conversion already shells to. Nothing reads a recording back today, so a broken one ships until a person opens it, and this verb is what a skill calls to read one instead. It writes one frame a second by default, sampling at a rate matched to how long a tuned recording runs rather than at a rate tuned for any one clip. Frames land beside the video by default, named `<video-basename>-frame-<NNN>.png`, so they fall under the same `demos/*.png` gitignore entry the still already uses.
|
|
60
|
+
|
|
61
|
+
| Option | Behavior |
|
|
62
|
+
| ----------------- | -------------------------------------------------------- |
|
|
63
|
+
| `-o, --out <dir>` | Directory to write frames into, default beside the video |
|
|
64
|
+
| `--fps <n>` | Frames extracted per second of video, default `1` |
|
|
65
|
+
| `--json` | Add a record on stdout carrying every frame path written |
|
|
66
|
+
|
|
67
|
+
`canon-frames-read` is the routed way to call this verb and read the frames back: it runs the verb, reads each returned frame with the Read tool, and reports one plain description per frame. It never renders a verdict, since a frame read is evidence a person weighs rather than a pass or fail this toolkit states on their behalf.
|
|
68
|
+
|
|
56
69
|
## The pointer is painted inside the page
|
|
57
70
|
|
|
58
71
|
The browser engine offers an annotation of its own that draws a dot on the interacted element and a title naming the API call it made, and this recorder does not turn it on. It paints no cursor, so a run relying on it looks like the pointer teleports between targets, and the two overlays below supersede it: a real cursor where the dot is a marker, and the beat's narration where the title reads `Mouse move`. Running both put four overlays on the frame, and the two the engine drew were the two a viewer reads as noise.
|
|
@@ -77,6 +90,9 @@ Every refusal exits 1 and names its reason in the `--json` record, so a skill br
|
|
|
77
90
|
| `plan-unreadable` | The plan is not JSON, or a step names a kind nothing drives |
|
|
78
91
|
| `browser-missing` | The browser binary is not installed, with `install` carrying the command |
|
|
79
92
|
| `engine-missing` | The browser package itself did not resolve |
|
|
93
|
+
| `video-missing` | No file at the path given to `frames` |
|
|
94
|
+
| `converter-missing` | `ffmpeg` is not on PATH, so `frames` extracted nothing |
|
|
95
|
+
| `extraction-failed` | `ffmpeg` exited non-zero, with `message` carrying its stderr |
|
|
80
96
|
|
|
81
97
|
## The browser reaches every target
|
|
82
98
|
|
package/docs/agents/index.md
CHANGED
|
@@ -16,7 +16,7 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
|
|
|
16
16
|
- [Context audit checks](context-audit-checks.md): What each non-gating check reports, the unit each checkpoint is measured in, the architecture record's length gate and claim coverage, which folders each check reaches, and what moved to the attribute tier
|
|
17
17
|
- [Context audit](context-audit.md): Running the audit, its flags and folder scope, the exit codes, the citation gate, and the widened gate the seed stage runs
|
|
18
18
|
- [Self-stated counts](counts.md): Reading a sentence that asserts a closed catalog's size, how a match is decided, the plausibility filter that keeps a generic word from matching a subset, and why the sweep reports rather than gates
|
|
19
|
-
- [Demo](demo.md): Compiling a screencast draft into a runnable plan, driving a served application to a recording and a still, the pointer the recording paints, and what each refusal reports
|
|
19
|
+
- [Demo](demo.md): Compiling a screencast draft into a runnable plan, driving a served application to a recording and a still, reading numbered frames back out of a recording, the pointer the recording paints, and what each refusal reports
|
|
20
20
|
- [Docs](docs.md): How canon docs resolves the toolkit's own reference surface from an install root, and how a split domain is named
|
|
21
21
|
- [Driver](driver.md): Walking a page through named interactions, the probe catalog and the false finding each one carries, why viewport heights are never defaulted, and what each refusal reports
|
|
22
22
|
- [Merge gate](gate.md): Running the gate this repository verifies a branch with, what the stage table holds and what stays a script, how the changed set scopes three stages, and why a stage that cannot read its input reports rather than passing
|
|
@@ -204,6 +204,7 @@ This section is the corpus the coverage claim is measured against: every name `c
|
|
|
204
204
|
| `canon:setup-smoke` | After `setup-verify` passes, to check the dev and preview servers, end-to-end tests, and the screenshot harness |
|
|
205
205
|
| `canon:claude-design-extract` | Before the first UI feature, to draft `.claude/DESIGN.md` |
|
|
206
206
|
| `canon:claude-diagram` | Once the architecture is written, to draft per-kind entries under `.canon/diagrams/` |
|
|
207
|
+
| `canon:repo-metadata` | When the GitHub About text, homepage, or topics may have drifted, to reconcile them against the README |
|
|
207
208
|
|
|
208
209
|
### Decide what to build
|
|
209
210
|
|
|
@@ -265,11 +266,13 @@ This section is the corpus the coverage claim is measured against: every name `c
|
|
|
265
266
|
|
|
266
267
|
### Run several tracks at once
|
|
267
268
|
|
|
268
|
-
| Skill | When to use
|
|
269
|
-
| -------------------------- |
|
|
270
|
-
| `canon:claude-orchestrate` | To assert the control session that owns the queue and reviews each worker's PR
|
|
271
|
-
| `canon:
|
|
272
|
-
| `canon:
|
|
269
|
+
| Skill | When to use |
|
|
270
|
+
| -------------------------- | ------------------------------------------------------------------------------- |
|
|
271
|
+
| `canon:claude-orchestrate` | To assert the control session that owns the queue and reviews each worker's PR |
|
|
272
|
+
| `canon:claude-planner` | To assert the planner role for a cold session writing one plan under one task |
|
|
273
|
+
| `canon:claude-worker` | To assert the worker role for a cold session building one branch under one plan |
|
|
274
|
+
| `canon:session-resume` | At the start of a session, to pick up what a previous one left |
|
|
275
|
+
| `canon:session-map` | At the close of a session, to write the handoff a compaction would destroy |
|
|
273
276
|
|
|
274
277
|
### Keep the project current with the toolkit
|
|
275
278
|
|
|
@@ -311,6 +314,7 @@ This section is the corpus the coverage claim is measured against: every name `c
|
|
|
311
314
|
| `canon:canon-cli` | Before running an unfamiliar verb, a sync, or an install, to learn which command to run, which reference doc covers it, or what it overwrites, merges, or leaves alone |
|
|
312
315
|
| `canon:index-lookup` | To find where a topic is documented across the tracked `index.md` catalogs |
|
|
313
316
|
| `canon:youtube-transcripts` | When a video transcript is wanted in the repo as context |
|
|
317
|
+
| `canon:canon-frames-read` | To read a recorded demo back frame by frame and report what each one shows, with no verdict on whether the recording looks right |
|
|
314
318
|
| `canon:claude-teach` | To learn a subject across sessions, in a workspace that holds the progress |
|
|
315
319
|
| `canon:write-human` | Before drafting or revising prose, for voice, rhythm, and density |
|
|
316
320
|
| `canon:restate-plainly` | When an answer or a document has to be read again in plain words |
|
package/package.json
CHANGED
|
@@ -38,6 +38,11 @@ export const CLAUDE_WORKFLOW_CASES: readonly SkillCase[] = [
|
|
|
38
38
|
'Sketch out a plan for adding this new capability before we touch any code.',
|
|
39
39
|
expect: 'claude-feature',
|
|
40
40
|
},
|
|
41
|
+
{
|
|
42
|
+
prompt:
|
|
43
|
+
'The video just finished recording. Read it back and tell me what each part shows.',
|
|
44
|
+
expect: 'canon-frames-read',
|
|
45
|
+
},
|
|
41
46
|
{
|
|
42
47
|
prompt:
|
|
43
48
|
'We need to measure this properly before committing to an approach.',
|
package/src/commands/claude.ts
CHANGED
|
@@ -50,7 +50,7 @@ import {
|
|
|
50
50
|
} from '@/claude/settings'
|
|
51
51
|
import { copyPreservingMode } from '@/copy'
|
|
52
52
|
import { execScript } from '@/exec'
|
|
53
|
-
import { PROJECT_ROOT } from '@/project-root'
|
|
53
|
+
import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
|
|
54
54
|
import { recordDir } from '@/record-root'
|
|
55
55
|
import { isDirectory, resolveTarget } from '@/target'
|
|
56
56
|
import { injectGitignore, pruneGitignore } from '@/tooling/inject'
|
|
@@ -620,15 +620,21 @@ async function sameContent(src: string, dest: string): Promise<boolean> {
|
|
|
620
620
|
* human listing stays on the timeline. Only the human mode opens a frame.
|
|
621
621
|
*/
|
|
622
622
|
async function runSeedsList(opts: SeedsListOptions): Promise<number> {
|
|
623
|
+
// The warning is a frame-interior line, so it goes out after `intro` on the
|
|
624
|
+
// path that opens one and bare on the two that return first and open none.
|
|
625
|
+
// One position cannot serve all three.
|
|
626
|
+
const mismatch = checkoutMismatchWarning(process.cwd())
|
|
623
627
|
const listings = listSeeds(PROJECT_ROOT)
|
|
624
628
|
|
|
625
629
|
if (opts.json) {
|
|
630
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
626
631
|
const withContent = await readSeedContents(listings)
|
|
627
632
|
process.stdout.write(`${JSON.stringify(withContent)}\n`)
|
|
628
633
|
return 0
|
|
629
634
|
}
|
|
630
635
|
|
|
631
636
|
if (opts.names) {
|
|
637
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
632
638
|
process.stdout.write(
|
|
633
639
|
listings.map((listing) => listing.target).join('\n') + '\n',
|
|
634
640
|
)
|
|
@@ -637,6 +643,7 @@ async function runSeedsList(opts: SeedsListOptions): Promise<number> {
|
|
|
637
643
|
|
|
638
644
|
const { GREY, NC } = palette(process.stderr)
|
|
639
645
|
intro('canon claude')
|
|
646
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
640
647
|
logStep('Seed docs')
|
|
641
648
|
for (const listing of listings) {
|
|
642
649
|
logInfo(`${listing.target} ${GREY}← ${listing.source}${NC}`)
|
|
@@ -646,14 +653,20 @@ async function runSeedsList(opts: SeedsListOptions): Promise<number> {
|
|
|
646
653
|
}
|
|
647
654
|
|
|
648
655
|
function runSkillsList(opts: SkillsListOptions): number {
|
|
656
|
+
// The warning is a frame-interior line, so it goes out after `intro` on the
|
|
657
|
+
// path that opens one and bare on the two that return first and open none.
|
|
658
|
+
// One position cannot serve all three.
|
|
659
|
+
const mismatch = checkoutMismatchWarning(process.cwd())
|
|
649
660
|
const listings = listSkills(PROJECT_ROOT)
|
|
650
661
|
|
|
651
662
|
if (opts.json) {
|
|
663
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
652
664
|
process.stdout.write(`${JSON.stringify({ skills: listings })}\n`)
|
|
653
665
|
return 0
|
|
654
666
|
}
|
|
655
667
|
|
|
656
668
|
if (opts.names) {
|
|
669
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
657
670
|
process.stdout.write(
|
|
658
671
|
listings.map((listing) => listing.name).join('\n') + '\n',
|
|
659
672
|
)
|
|
@@ -661,6 +674,7 @@ function runSkillsList(opts: SkillsListOptions): number {
|
|
|
661
674
|
}
|
|
662
675
|
|
|
663
676
|
intro('canon claude')
|
|
677
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
664
678
|
logStep('Plugin skills')
|
|
665
679
|
for (const listing of listings) {
|
|
666
680
|
logInfo(listing.name)
|
package/src/commands/demo.ts
CHANGED
|
@@ -4,7 +4,12 @@ import type { Command } from 'commander'
|
|
|
4
4
|
import { INSTALL_BROWSER, isEngineMissing } from '@/browser/engine'
|
|
5
5
|
import { parseDraft } from '@/demo/beats'
|
|
6
6
|
import { compilePlan, parsePlan, unresolved } from '@/demo/compile'
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
convertToGif,
|
|
9
|
+
convertToMp4,
|
|
10
|
+
extractFrames,
|
|
11
|
+
INSTALL_CONVERTER,
|
|
12
|
+
} from '@/demo/container'
|
|
8
13
|
import { DEFAULT_CURSORS } from '@/demo/cursors'
|
|
9
14
|
import { loadCursorTheme } from '@/demo/theme'
|
|
10
15
|
import { intro, logError, logInfo, logStep, logWarn, outro, plural } from '@/ui'
|
|
@@ -34,6 +39,12 @@ interface RunOptions {
|
|
|
34
39
|
readonly json?: boolean
|
|
35
40
|
}
|
|
36
41
|
|
|
42
|
+
interface FramesOptions {
|
|
43
|
+
readonly out?: string
|
|
44
|
+
readonly fps: string
|
|
45
|
+
readonly json?: boolean
|
|
46
|
+
}
|
|
47
|
+
|
|
37
48
|
export function register(program: Command): void {
|
|
38
49
|
const demo = program
|
|
39
50
|
.command('demo')
|
|
@@ -111,6 +122,38 @@ export function register(program: Command): void {
|
|
|
111
122
|
.action(async (plan: string, opts: RunOptions) => {
|
|
112
123
|
process.exitCode = await runDrive(plan, opts)
|
|
113
124
|
})
|
|
125
|
+
|
|
126
|
+
demo
|
|
127
|
+
.command('frames')
|
|
128
|
+
.description('Pull numbered still frames from a recorded video')
|
|
129
|
+
.argument('<video>', 'Video written by canon demo run')
|
|
130
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
131
|
+
.option(
|
|
132
|
+
'-o, --out <dir>',
|
|
133
|
+
'Directory to write frames into, default beside the video',
|
|
134
|
+
)
|
|
135
|
+
.option('--fps <n>', 'Frames extracted per second of video', '1')
|
|
136
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
137
|
+
.addHelpText(
|
|
138
|
+
'after',
|
|
139
|
+
[
|
|
140
|
+
'',
|
|
141
|
+
'Needs ffmpeg on PATH, the same binary demo run already shells to for',
|
|
142
|
+
`mp4 and gif conversion. Install it with: ${INSTALL_CONVERTER}`,
|
|
143
|
+
'',
|
|
144
|
+
'Exit codes:',
|
|
145
|
+
' 0 frames were written',
|
|
146
|
+
' 1 refused, with the reason on stderr',
|
|
147
|
+
'',
|
|
148
|
+
'Examples:',
|
|
149
|
+
' canon demo frames demos/inline-edit.webm',
|
|
150
|
+
' canon demo frames demos/inline-edit.webm --fps 2 --out frames',
|
|
151
|
+
'',
|
|
152
|
+
].join('\n'),
|
|
153
|
+
)
|
|
154
|
+
.action(async (video: string, opts: FramesOptions) => {
|
|
155
|
+
process.exitCode = await runFrames(video, opts)
|
|
156
|
+
})
|
|
114
157
|
}
|
|
115
158
|
|
|
116
159
|
function runCompile(draftPath: string, opts: CompileOptions): number {
|
|
@@ -344,6 +387,59 @@ async function runDrive(planPath: string, opts: RunOptions): Promise<number> {
|
|
|
344
387
|
return 0
|
|
345
388
|
}
|
|
346
389
|
|
|
390
|
+
async function runFrames(
|
|
391
|
+
videoPath: string,
|
|
392
|
+
opts: FramesOptions,
|
|
393
|
+
): Promise<number> {
|
|
394
|
+
intro('canon demo frames')
|
|
395
|
+
|
|
396
|
+
const source = resolve(process.cwd(), videoPath)
|
|
397
|
+
if (!existsSync(source)) {
|
|
398
|
+
logStep('Video')
|
|
399
|
+
logError(`${videoPath} not found`)
|
|
400
|
+
outro()
|
|
401
|
+
emit(opts.json, { video: source, reason: 'video-missing' })
|
|
402
|
+
return 1
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
logStep('Video')
|
|
406
|
+
logInfo(display(source))
|
|
407
|
+
|
|
408
|
+
const outDir = opts.out ? resolve(process.cwd(), opts.out) : undefined
|
|
409
|
+
const fps = Number(opts.fps)
|
|
410
|
+
|
|
411
|
+
logStep('Frames')
|
|
412
|
+
const extracted = await extractFrames(source, outDir, { fps })
|
|
413
|
+
if (extracted.status === 'skipped') {
|
|
414
|
+
logError('ffmpeg is not installed, so no frames were written.')
|
|
415
|
+
logWarn(`Install it with: ${INSTALL_CONVERTER}`)
|
|
416
|
+
outro()
|
|
417
|
+
emit(opts.json, { video: source, reason: extracted.reason })
|
|
418
|
+
return 1
|
|
419
|
+
}
|
|
420
|
+
if (extracted.status === 'failed') {
|
|
421
|
+
logError(`frame extraction failed: ${extracted.reason}`)
|
|
422
|
+
outro()
|
|
423
|
+
emit(opts.json, {
|
|
424
|
+
video: source,
|
|
425
|
+
reason: 'extraction-failed',
|
|
426
|
+
message: extracted.reason,
|
|
427
|
+
})
|
|
428
|
+
return 1
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
for (const framePath of extracted.framePaths) logInfo(display(framePath))
|
|
432
|
+
logInfo(`${plural(extracted.framePaths.length, 'frame')} at ${fps} fps`)
|
|
433
|
+
outro()
|
|
434
|
+
|
|
435
|
+
emit(opts.json, {
|
|
436
|
+
video: source,
|
|
437
|
+
frames: extracted.framePaths,
|
|
438
|
+
fps,
|
|
439
|
+
})
|
|
440
|
+
return 0
|
|
441
|
+
}
|
|
442
|
+
|
|
347
443
|
type CursorChoice =
|
|
348
444
|
| { status: 'ready'; value: typeof DEFAULT_CURSORS; label: string }
|
|
349
445
|
| { status: 'failed'; reason: string }
|
package/src/commands/design.ts
CHANGED
|
@@ -9,11 +9,11 @@ import {
|
|
|
9
9
|
import { buildDesignCss } from '@/design/css'
|
|
10
10
|
import { renderDesignDoc } from '@/design/render'
|
|
11
11
|
import { DESIGN_BASE_CSS, DESIGN_DOCUMENT, regenDesign } from '@/design/regen'
|
|
12
|
-
import { PROJECT_ROOT } from '@/project-root'
|
|
12
|
+
import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
|
|
13
13
|
import { creationRel } from '@/record-root'
|
|
14
14
|
import { recordStamp, runDomainSync } from '@/sync/engine'
|
|
15
15
|
import { resolveTarget } from '@/target'
|
|
16
|
-
import { intro, logAdd, logError, logInfo, outro, palette } from '@/ui'
|
|
16
|
+
import { intro, logAdd, logError, logInfo, logWarn, outro, palette } from '@/ui'
|
|
17
17
|
|
|
18
18
|
export function register(program: Command): void {
|
|
19
19
|
const design = program
|
|
@@ -39,13 +39,21 @@ export function register(program: Command): void {
|
|
|
39
39
|
.action(() => {
|
|
40
40
|
const { GREEN, GREY, NC, RED, WHITE } = palette(process.stderr)
|
|
41
41
|
|
|
42
|
+
// The guard below catches a `PROJECT_ROOT` with no record at all, which
|
|
43
|
+
// is an installed package. It cannot catch a second real checkout that
|
|
44
|
+
// has one, so both paths carry the warning. It is a frame-interior line,
|
|
45
|
+
// so each emits it after its own opener rather than ahead of the branch.
|
|
46
|
+
const mismatch = checkoutMismatchWarning(process.cwd())
|
|
47
|
+
|
|
42
48
|
// Both outputs resolve from `PROJECT_ROOT`, which is the installed
|
|
43
49
|
// package directory when the CLI runs out of a target's `node_modules`.
|
|
44
50
|
// The record is the one output that is already committed here and ships
|
|
45
51
|
// with no package, so its absence is what separates the two.
|
|
46
52
|
if (!existsSync(join(PROJECT_ROOT, DESIGN_DOCUMENT))) {
|
|
53
|
+
process.stderr.write(`${GREY}┌${NC}\n`)
|
|
54
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
47
55
|
process.stderr.write(
|
|
48
|
-
`${GREY}
|
|
56
|
+
`${GREY}│${NC} ${RED}✗${NC} No ${DESIGN_DOCUMENT} at ${PROJECT_ROOT}. Regen runs in the toolkit checkout, not against a target.\n${GREY}└${NC}\n`,
|
|
49
57
|
)
|
|
50
58
|
process.exitCode = 1
|
|
51
59
|
return
|
|
@@ -54,6 +62,7 @@ export function register(program: Command): void {
|
|
|
54
62
|
process.stderr.write(
|
|
55
63
|
`${GREY}┌${NC}\n${GREY}│${NC} ${WHITE}Regenerate design source${NC}\n`,
|
|
56
64
|
)
|
|
65
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
57
66
|
const result = regenDesign(PROJECT_ROOT)
|
|
58
67
|
for (const path of [result.documentPath, result.cssPath]) {
|
|
59
68
|
process.stderr.write(
|
package/src/commands/docs.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { Command } from 'commander'
|
|
|
2
2
|
import { registerPassThroughVerbs } from '@/commands/pass-through'
|
|
3
3
|
import { listTopics, readTopic, resolveTopic } from '@/docs/read'
|
|
4
4
|
import { execScript } from '@/exec'
|
|
5
|
-
import { PROJECT_ROOT } from '@/project-root'
|
|
5
|
+
import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
|
|
6
6
|
import { intro, logError, logInfo, logStep, logWarn, outro } from '@/ui'
|
|
7
7
|
|
|
8
8
|
export function register(program: Command): void {
|
|
@@ -42,6 +42,9 @@ export function register(program: Command): void {
|
|
|
42
42
|
function get(topic: string): number {
|
|
43
43
|
intro('canon docs')
|
|
44
44
|
|
|
45
|
+
const mismatch = checkoutMismatchWarning(process.cwd())
|
|
46
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
47
|
+
|
|
45
48
|
const resolved = resolveTopic(PROJECT_ROOT, topic)
|
|
46
49
|
|
|
47
50
|
if (!resolved) {
|
package/src/commands/gov.ts
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
type CountsReport,
|
|
9
9
|
scanCounts,
|
|
10
10
|
} from '@/counts/scan'
|
|
11
|
-
import { PROJECT_ROOT } from '@/project-root'
|
|
11
|
+
import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
|
|
12
12
|
import { creationRel, SCRATCH } from '@/record-root'
|
|
13
13
|
import { createGovAdapter } from '@/gov/adapter'
|
|
14
14
|
import { regenConsumedRules } from '@/gov/consumed'
|
|
@@ -203,8 +203,11 @@ export function register(program: Command): void {
|
|
|
203
203
|
'',
|
|
204
204
|
].join('\n'),
|
|
205
205
|
)
|
|
206
|
-
.action(async (opts: RegenOptions) => {
|
|
207
|
-
process.exitCode = await runRegen(
|
|
206
|
+
.action(async (opts: RegenOptions, cmd: Command) => {
|
|
207
|
+
process.exitCode = await runRegen(
|
|
208
|
+
opts,
|
|
209
|
+
cmd.getOptionValueSource('root') === 'cli',
|
|
210
|
+
)
|
|
208
211
|
})
|
|
209
212
|
|
|
210
213
|
gov
|
|
@@ -1052,10 +1055,15 @@ function selectedSections(opts: ListOptions): {
|
|
|
1052
1055
|
* untouched by it.
|
|
1053
1056
|
*/
|
|
1054
1057
|
function runList(opts: ListOptions): number {
|
|
1058
|
+
// The warning is a frame-interior line, so it goes out after `intro` on the
|
|
1059
|
+
// path that opens one and bare on the `--json` path, which returns first and
|
|
1060
|
+
// opens none. One position cannot serve both.
|
|
1061
|
+
const mismatch = checkoutMismatchWarning(process.cwd())
|
|
1055
1062
|
const catalog = buildGovCatalog(PROJECT_ROOT)
|
|
1056
1063
|
const sections = selectedSections(opts)
|
|
1057
1064
|
|
|
1058
1065
|
if (opts.json) {
|
|
1066
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
1059
1067
|
process.stdout.write(
|
|
1060
1068
|
`${JSON.stringify({
|
|
1061
1069
|
...(sections.stacks ? { stacks: catalog.stacks } : {}),
|
|
@@ -1067,6 +1075,7 @@ function runList(opts: ListOptions): number {
|
|
|
1067
1075
|
}
|
|
1068
1076
|
|
|
1069
1077
|
intro('canon gov list')
|
|
1078
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
1070
1079
|
|
|
1071
1080
|
if (sections.stacks) {
|
|
1072
1081
|
logStep('Stacks')
|
|
@@ -1092,7 +1101,18 @@ function runList(opts: ListOptions): number {
|
|
|
1092
1101
|
* the only work that stage does now. The installed set is readable on disk, so
|
|
1093
1102
|
* printing it would only add noise to every `bun run check`.
|
|
1094
1103
|
*/
|
|
1095
|
-
async function runRegen(
|
|
1104
|
+
async function runRegen(
|
|
1105
|
+
opts: RegenOptions,
|
|
1106
|
+
rootPassed: boolean,
|
|
1107
|
+
): Promise<number> {
|
|
1108
|
+
// An operator naming `--root` names their own target on purpose, so only the
|
|
1109
|
+
// default can silently regenerate against a checkout nobody is standing in.
|
|
1110
|
+
// The value alone cannot separate the two, since the flag carries a default.
|
|
1111
|
+
if (!rootPassed) {
|
|
1112
|
+
const mismatch = checkoutMismatchWarning(process.cwd())
|
|
1113
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1096
1116
|
const result = await regenConsumedRules(resolve(opts.root ?? PROJECT_ROOT))
|
|
1097
1117
|
|
|
1098
1118
|
if (!result.ok) {
|
package/src/commands/migrate.ts
CHANGED
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
type ScratchEvidencePlan,
|
|
32
32
|
walkScratchEvidenceCorpus,
|
|
33
33
|
} from '@/migrate/scratch-evidence'
|
|
34
|
-
import { PROJECT_ROOT } from '@/project-root'
|
|
34
|
+
import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
|
|
35
35
|
import { readStamp, stampedHashes } from '@/sync/stamp'
|
|
36
36
|
import { logError, logInfo, logStep, logWarn, pipeOutput, plural } from '@/ui'
|
|
37
37
|
|
|
@@ -584,6 +584,9 @@ interface RuleLayoutOptions {
|
|
|
584
584
|
async function runRuleLayout(opts: RuleLayoutOptions): Promise<number> {
|
|
585
585
|
const root = opts.root ?? process.cwd()
|
|
586
586
|
|
|
587
|
+
const mismatch = checkoutMismatchWarning(process.cwd())
|
|
588
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
589
|
+
|
|
587
590
|
const files = await walkFlatRules(root)
|
|
588
591
|
const hashes = stampedHashes(readStamp(root), 'governance')
|
|
589
592
|
const catalog = new Set(indexSourceRules(PROJECT_ROOT).keys())
|
package/src/commands/sandbox.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import type { Command } from 'commander'
|
|
4
4
|
import { execScript } from '@/exec'
|
|
5
|
-
import { PROJECT_ROOT } from '@/project-root'
|
|
5
|
+
import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
|
|
6
6
|
import {
|
|
7
7
|
assertedPercent,
|
|
8
8
|
collectCensus,
|
|
@@ -286,6 +286,9 @@ function runCoverage(options: CoverageOptions): void {
|
|
|
286
286
|
|
|
287
287
|
intro('canon sandbox coverage')
|
|
288
288
|
|
|
289
|
+
const mismatch = checkoutMismatchWarning(process.cwd())
|
|
290
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
291
|
+
|
|
289
292
|
const report = collectCoverage(PROJECT_ROOT)
|
|
290
293
|
|
|
291
294
|
// A broken `exempt.toml` reads as a caller error, not as a crash. The parser
|
|
@@ -341,6 +344,9 @@ function runCheck(
|
|
|
341
344
|
): void {
|
|
342
345
|
intro('canon sandbox check')
|
|
343
346
|
|
|
347
|
+
const mismatch = checkoutMismatchWarning(process.cwd())
|
|
348
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
349
|
+
|
|
344
350
|
const parsed = parseTarget(target)
|
|
345
351
|
if (parsed === undefined) {
|
|
346
352
|
logError('Invalid target. Use <category>:<command>, e.g. claude:docs.')
|
package/src/commands/snippets.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { Command } from 'commander'
|
|
2
2
|
import { registerPassThroughVerbs } from '@/commands/pass-through'
|
|
3
|
-
import { PROJECT_ROOT } from '@/project-root'
|
|
3
|
+
import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
|
|
4
4
|
import { BASE_CATEGORY } from '@/snippets/categories'
|
|
5
5
|
import { buildSnippetsCatalog } from '@/snippets/list'
|
|
6
|
-
import { intro, logInfo, logStep, outro } from '@/ui'
|
|
6
|
+
import { intro, logInfo, logStep, logWarn, outro } from '@/ui'
|
|
7
7
|
|
|
8
8
|
const PASS_THROUGH_VERBS = ['create'] as const
|
|
9
9
|
|
|
@@ -42,14 +42,20 @@ export function register(program: Command): void {
|
|
|
42
42
|
* emitted section headers and the pass-through above it owned the `┌`.
|
|
43
43
|
*/
|
|
44
44
|
function runList(opts: ListOptions): number {
|
|
45
|
+
// The warning is a frame-interior line, so it goes out after `intro` on the
|
|
46
|
+
// path that opens one and bare on the `--json` path, which returns first and
|
|
47
|
+
// opens none. One position cannot serve both.
|
|
48
|
+
const mismatch = checkoutMismatchWarning(process.cwd())
|
|
45
49
|
const catalog = buildSnippetsCatalog(PROJECT_ROOT)
|
|
46
50
|
|
|
47
51
|
if (opts.json) {
|
|
52
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
48
53
|
process.stdout.write(`${JSON.stringify(catalog)}\n`)
|
|
49
54
|
return 0
|
|
50
55
|
}
|
|
51
56
|
|
|
52
57
|
intro('canon snippets list')
|
|
58
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
53
59
|
|
|
54
60
|
const showCategories = opts.entries !== true
|
|
55
61
|
const showEntries = opts.categories !== true
|
package/src/commands/tooling.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { resolve } from 'node:path'
|
|
2
2
|
import type { Command } from 'commander'
|
|
3
3
|
import { execScript } from '@/exec'
|
|
4
|
-
import {
|
|
4
|
+
import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
|
|
5
5
|
import {
|
|
6
6
|
injectConfigs,
|
|
7
7
|
injectGitignore,
|
|
@@ -180,14 +180,20 @@ export function register(program: Command): void {
|
|
|
180
180
|
* pass-through loop below skips the `intro` the shared helper carries.
|
|
181
181
|
*/
|
|
182
182
|
function runList(opts: ListOptions): number {
|
|
183
|
+
// The warning is a frame-interior line, so it goes out after `intro` on the
|
|
184
|
+
// path that opens one and bare on the `--json` path, which returns first and
|
|
185
|
+
// opens none. One position cannot serve both.
|
|
186
|
+
const mismatch = checkoutMismatchWarning(process.cwd())
|
|
183
187
|
const stacks = buildStackSummaries(PROJECT_ROOT)
|
|
184
188
|
|
|
185
189
|
if (opts.json) {
|
|
190
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
186
191
|
process.stdout.write(`${JSON.stringify({ stacks })}\n`)
|
|
187
192
|
return 0
|
|
188
193
|
}
|
|
189
194
|
|
|
190
195
|
intro('canon tooling list')
|
|
196
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
191
197
|
logStep('Stacks')
|
|
192
198
|
for (const summary of stacks) logInfo(describeStack(summary))
|
|
193
199
|
outro()
|
|
@@ -229,6 +235,9 @@ function printReference(stack: string): number {
|
|
|
229
235
|
* stack the way it drove `merge_gitignore` before.
|
|
230
236
|
*/
|
|
231
237
|
function prepare(stack: string, target: string, skip?: string): Prepared {
|
|
238
|
+
const mismatch = checkoutMismatchWarning(process.cwd())
|
|
239
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
240
|
+
|
|
232
241
|
if (!stackExists(PROJECT_ROOT, stack)) {
|
|
233
242
|
return { ok: false, error: `Stack not found: ${stack}` }
|
|
234
243
|
}
|
|
@@ -268,13 +277,6 @@ async function runSync(
|
|
|
268
277
|
): Promise<number> {
|
|
269
278
|
intro('canon tooling sync')
|
|
270
279
|
|
|
271
|
-
const mismatch = findCheckoutMismatch(process.cwd())
|
|
272
|
-
if (mismatch !== undefined) {
|
|
273
|
-
logWarn(
|
|
274
|
-
`Resolved via ${PROJECT_ROOT}, not the checkout at ${mismatch}. Run \`bun ${mismatch}/src/cli.ts tooling sync ...\` to sync against that checkout instead.`,
|
|
275
|
-
)
|
|
276
|
-
}
|
|
277
|
-
|
|
278
280
|
if (opts.check === true && opts.write === true) {
|
|
279
281
|
logWarn('Pass --check or --write, not both.')
|
|
280
282
|
outro()
|
package/src/demo/container.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, rmSync } from 'node:fs'
|
|
1
2
|
import { join, parse } from 'node:path'
|
|
2
3
|
import { execa } from 'execa'
|
|
3
4
|
|
|
@@ -20,6 +21,15 @@ export type GifResult =
|
|
|
20
21
|
| { status: 'skipped'; reason: 'converter-missing' }
|
|
21
22
|
| { status: 'failed'; reason: string }
|
|
22
23
|
|
|
24
|
+
export type FramesResult =
|
|
25
|
+
| { status: 'extracted'; framePaths: string[] }
|
|
26
|
+
| { status: 'skipped'; reason: 'converter-missing' }
|
|
27
|
+
| { status: 'failed'; reason: string }
|
|
28
|
+
|
|
29
|
+
export interface ExtractFramesOptions {
|
|
30
|
+
readonly fps?: number
|
|
31
|
+
}
|
|
32
|
+
|
|
23
33
|
/**
|
|
24
34
|
* Writes mp4 beside the webm rather than instead of it, since both stated use
|
|
25
35
|
* cases are a `<video>` tag on a page the operator controls, where webm
|
|
@@ -122,3 +132,88 @@ export async function convertToGif(
|
|
|
122
132
|
}
|
|
123
133
|
return { status: 'converted', gifPath }
|
|
124
134
|
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Written beside the video by default, so frames fall under the same
|
|
138
|
+
* `demos/*.png` gitignore entry the still already uses with no new rule
|
|
139
|
+
* needed. One frame a second by default, matched to the seconds-to-tens-of-
|
|
140
|
+
* seconds length a tuned recording runs, without a smarter sampling strategy
|
|
141
|
+
* nobody has asked for.
|
|
142
|
+
*
|
|
143
|
+
* The missing-binary and failure handling matches `convertToMp4` exactly: the
|
|
144
|
+
* recording already succeeded by the time this runs, so an optional step
|
|
145
|
+
* never fails the run.
|
|
146
|
+
*
|
|
147
|
+
* A prior extraction's frames are cleared before ffmpeg runs, not left for
|
|
148
|
+
* `-y` to overwrite. ffmpeg's `-y` only overwrites the indices this run
|
|
149
|
+
* produces, so a shorter re-extraction against the same video's stable
|
|
150
|
+
* output path (the plan's declared `output.video`, unchanged across
|
|
151
|
+
* re-records) would otherwise leave the previous run's tail in place,
|
|
152
|
+
* sorted into the returned list as if it were still current.
|
|
153
|
+
*/
|
|
154
|
+
export async function extractFrames(
|
|
155
|
+
videoPath: string,
|
|
156
|
+
outDir?: string,
|
|
157
|
+
opts: ExtractFramesOptions = {},
|
|
158
|
+
bin: string = CONVERTER_BIN,
|
|
159
|
+
): Promise<FramesResult> {
|
|
160
|
+
const { dir, name } = parse(videoPath)
|
|
161
|
+
const targetDir = outDir ?? dir
|
|
162
|
+
const fps = opts.fps ?? 1
|
|
163
|
+
if (outDir) mkdirSync(outDir, { recursive: true })
|
|
164
|
+
|
|
165
|
+
if (existsSync(targetDir)) {
|
|
166
|
+
for (const stale of new Bun.Glob(`${name}-frame-*.png`).scanSync({
|
|
167
|
+
cwd: targetDir,
|
|
168
|
+
onlyFiles: true,
|
|
169
|
+
})) {
|
|
170
|
+
rmSync(join(targetDir, stale))
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const pattern = join(targetDir, `${name}-frame-%03d.png`)
|
|
175
|
+
const result = await execa(
|
|
176
|
+
bin,
|
|
177
|
+
['-y', '-i', videoPath, '-vf', `fps=${fps}`, pattern],
|
|
178
|
+
{ reject: false },
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
if (result.failed && result.code === 'ENOENT') {
|
|
182
|
+
return { status: 'skipped', reason: 'converter-missing' }
|
|
183
|
+
}
|
|
184
|
+
if (result.exitCode !== 0) {
|
|
185
|
+
return {
|
|
186
|
+
status: 'failed',
|
|
187
|
+
reason: result.stderr?.trim() || `ffmpeg exited ${result.exitCode}`,
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return { status: 'extracted', framePaths: collectFrames(targetDir, name) }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The glob-and-sort step `extractFrames` reads its result from, pulled out so
|
|
196
|
+
* a test can drive it against a directory it wrote by hand rather than
|
|
197
|
+
* through a real extraction, which is the only way to reach the four-digit
|
|
198
|
+
* boundary below without an ffmpeg run long enough to produce one.
|
|
199
|
+
*
|
|
200
|
+
* ffmpeg's `%03d` pads to three characters and then widens rather than
|
|
201
|
+
* truncating, so a run producing 1000 or more frames writes `-1000.png`
|
|
202
|
+
* beside `-999.png`, and a lexicographic sort reads the wider name first.
|
|
203
|
+
* `frameIndex` sorts on the parsed number instead.
|
|
204
|
+
*/
|
|
205
|
+
export function collectFrames(targetDir: string, name: string): string[] {
|
|
206
|
+
return [
|
|
207
|
+
...new Bun.Glob(`${name}-frame-*.png`).scanSync({
|
|
208
|
+
cwd: targetDir,
|
|
209
|
+
onlyFiles: true,
|
|
210
|
+
}),
|
|
211
|
+
]
|
|
212
|
+
.sort((a, b) => frameIndex(a) - frameIndex(b))
|
|
213
|
+
.map((frame) => join(targetDir, frame))
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function frameIndex(filename: string): number {
|
|
217
|
+
const match = filename.match(/-frame-(\d+)\.png$/)
|
|
218
|
+
return match ? Number(match[1]) : 0
|
|
219
|
+
}
|
package/src/exec.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { join } from 'node:path'
|
|
2
2
|
import { execa } from 'execa'
|
|
3
|
-
import { PROJECT_ROOT } from '@/project-root'
|
|
3
|
+
import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
|
|
4
|
+
import { logWarn } from '@/ui'
|
|
4
5
|
|
|
5
6
|
export async function execScript(
|
|
6
7
|
script: string,
|
|
7
8
|
args: string[],
|
|
8
9
|
): Promise<void> {
|
|
10
|
+
const mismatch = checkoutMismatchWarning(process.cwd())
|
|
11
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
12
|
+
|
|
9
13
|
const scriptPath = join(PROJECT_ROOT, 'scripts', script)
|
|
10
14
|
const result = await execa(scriptPath, args, {
|
|
11
15
|
stdio: 'inherit',
|
package/src/project-root.ts
CHANGED
|
@@ -51,3 +51,20 @@ export function findCheckoutMismatch(startDir: string): string | undefined {
|
|
|
51
51
|
dir = parent
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The warning line every verb that answers from `PROJECT_ROOT` emits when the
|
|
57
|
+
* caller's cwd sits inside a second checkout, or `undefined` when it does not.
|
|
58
|
+
*
|
|
59
|
+
* It returns the string rather than logging it so this module keeps no `@/ui`
|
|
60
|
+
* dependency and the wording is unit-testable on its own. The wording names
|
|
61
|
+
* both roots and no subcommand: the four chokepoints that carry it stand under
|
|
62
|
+
* eight-plus verbs each, so a per-verb suffix would cost a parameter at every
|
|
63
|
+
* call site to buy back one verb's exact string.
|
|
64
|
+
*/
|
|
65
|
+
export function checkoutMismatchWarning(cwd: string): string | undefined {
|
|
66
|
+
const mismatch = findCheckoutMismatch(cwd)
|
|
67
|
+
if (mismatch === undefined) return undefined
|
|
68
|
+
|
|
69
|
+
return `Resolved via ${PROJECT_ROOT}, not the checkout at ${mismatch}. Run \`bun ${mismatch}/src/cli.ts ...\` to answer from that checkout instead.`
|
|
70
|
+
}
|
package/src/sync/engine.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from 'node:fs'
|
|
|
2
2
|
import { rm } from 'node:fs/promises'
|
|
3
3
|
import { isAbsolute, relative, resolve } from 'node:path'
|
|
4
4
|
import { copyPreservingMode } from '@/copy'
|
|
5
|
+
import { checkoutMismatchWarning } from '@/project-root'
|
|
5
6
|
import { findInstalledOrigin, readHistoryIndex } from '@/sync/history'
|
|
6
7
|
import {
|
|
7
8
|
type DomainHashes,
|
|
@@ -339,6 +340,9 @@ export async function runDomainSync(
|
|
|
339
340
|
): Promise<number> {
|
|
340
341
|
intro(adapter.banner)
|
|
341
342
|
|
|
343
|
+
const mismatch = checkoutMismatchWarning(process.cwd())
|
|
344
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
345
|
+
|
|
342
346
|
const resolved = resolve(target)
|
|
343
347
|
|
|
344
348
|
if (!isDirectory(resolved)) {
|
package/src/target.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { statSync } from 'node:fs'
|
|
2
2
|
import { resolve } from 'node:path'
|
|
3
|
-
import {
|
|
3
|
+
import { checkoutMismatchWarning } from '@/project-root'
|
|
4
|
+
import { logError, logWarn, outro } from '@/ui'
|
|
4
5
|
|
|
5
6
|
export function isDirectory(path: string): boolean {
|
|
6
7
|
try {
|
|
@@ -23,6 +24,9 @@ export function resolveTarget(
|
|
|
23
24
|
target: string,
|
|
24
25
|
protectedRoot: string,
|
|
25
26
|
): string | number {
|
|
27
|
+
const mismatch = checkoutMismatchWarning(process.cwd())
|
|
28
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
29
|
+
|
|
26
30
|
const resolved = resolve(target)
|
|
27
31
|
|
|
28
32
|
if (!isDirectory(resolved)) {
|