@erclx/canon 5.58.0 → 5.60.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/README.md +1 -1
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/document-health/REQUIREMENT.md +1 -1
- package/claude/skills/document-health/SKILL.md +19 -0
- package/claude/skills/document-health/references/axes.md +18 -0
- package/claude/skills/plan-feature/SKILL.md +1 -1
- package/claude/skills/review-pr/SKILL.md +1 -1
- package/claude/skills/review-pr/references/scope.md +10 -0
- package/claude/skills/target-update/SKILL.md +5 -1
- package/docs/agents/install-and-sync.md +1 -1
- package/docs/agents/migrate.md +6 -0
- package/docs/target/sync.md +3 -1
- package/docs/workflow/operating-model.md +1 -1
- package/package.json +3 -3
- package/scripts/core/bootstrap.ts +120 -0
- package/scripts/core/install-check.ts +193 -0
- package/src/cli/retired.ts +63 -0
- package/src/cli/ui.ts +3 -5
- package/src/cli.ts +8 -2
- package/src/commands/design.ts +57 -1
- package/src/commands/gov.ts +8 -2
- package/src/commands/migrate.ts +7 -1
- package/src/commands/pr.ts +10 -1
- package/src/commands/tooling.ts +17 -2
- package/src/design/audit.ts +109 -0
- package/src/design/css.ts +1 -5
- package/src/gov/adapter.ts +2 -0
- package/src/gov/coverage.ts +62 -0
- package/src/migrate/callers.ts +192 -0
- package/src/migrate/status.ts +11 -3
- package/src/pr/merge.ts +77 -1
- package/src/pr/rest.ts +1 -0
- package/src/sandbox/expect.ts +1 -1
- package/src/sync/engine.ts +84 -0
- package/src/sync/stamp.ts +75 -5
- package/src/tooling/inject.ts +6 -3
- package/src/tooling/install.ts +55 -0
- package/scripts/core/bootstrap.sh +0 -86
- package/scripts/core/install-check.sh +0 -113
- package/scripts/lib/ui.sh +0 -45
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [5.60.0](https://github.com/erclx/canon/compare/v5.59.0...v5.60.0) (2026-10-24)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **design:** add canon design audit verb ([#2661](https://github.com/erclx/canon/issues/2661)) ([303a52a](https://github.com/erclx/canon/commit/303a52ada294d9740205885336b6f1ce006ddbba))
|
|
9
|
+
* **skills:** reach design-taste in planner and later review passes ([#2657](https://github.com/erclx/canon/issues/2657)) ([dedc351](https://github.com/erclx/canon/commit/dedc35132a57cf8f82e9a25603e6128e0c77d6ed))
|
|
10
|
+
* **tooling:** bound the dev dependency install ([#2666](https://github.com/erclx/canon/issues/2666)) ([09017a6](https://github.com/erclx/canon/commit/09017a6bfcf11d3feffa1eccfa1ec99f8a0ff3ae))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* **sync:** drop retired domains when reading the stamp ([#2664](https://github.com/erclx/canon/issues/2664)) ([21e0a1c](https://github.com/erclx/canon/commit/21e0a1cccfcab13dba5e50e3a55ea620d0d77907))
|
|
16
|
+
|
|
17
|
+
## [5.59.0](https://github.com/erclx/canon/compare/v5.58.0...v5.59.0) (2026-10-24)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
### Features
|
|
21
|
+
|
|
22
|
+
* **cli:** name the release that retired a verb and find its callers ([#2662](https://github.com/erclx/canon/issues/2662)) ([32fddb5](https://github.com/erclx/canon/commit/32fddb5f8c363441fc18ffb4243c08a65a863a67))
|
|
23
|
+
* **gov:** report rule coverage after gov sync ([#2660](https://github.com/erclx/canon/issues/2660)) ([c087965](https://github.com/erclx/canon/commit/c087965acd64249f8dfbe9ca1d98a7857621d380))
|
|
24
|
+
* **pr:** compose bot pull request merge messages ([#2659](https://github.com/erclx/canon/issues/2659)) ([698991c](https://github.com/erclx/canon/commit/698991c71b6423279756f023c86cd2537ae46017))
|
|
25
|
+
* **sandbox:** add a canon-feedback-triage anchor scenario ([#2656](https://github.com/erclx/canon/issues/2656)) ([ae45573](https://github.com/erclx/canon/commit/ae45573e2b385b97cdfbf300b4d1f9bb0f3db839))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
### Bug Fixes
|
|
29
|
+
|
|
30
|
+
* **sync:** write the stamp in the formatter shape ([#2655](https://github.com/erclx/canon/issues/2655)) ([a34c5fb](https://github.com/erclx/canon/commit/a34c5fb2d18314ec31d83640be387057b68638a7))
|
|
31
|
+
|
|
3
32
|
## [5.58.0](https://github.com/erclx/canon/compare/v5.57.0...v5.58.0) (2026-10-24)
|
|
4
33
|
|
|
5
34
|
|
package/README.md
CHANGED
|
@@ -140,7 +140,7 @@ Bun, Git, and a shell are required, and the GitHub CLI is optional.
|
|
|
140
140
|
- [Bun](https://bun.sh) for the CLI runtime and scripts
|
|
141
141
|
- [Git](https://git-scm.com) with worktree support
|
|
142
142
|
- [GitHub CLI](https://cli.github.com) (optional) for ship flows
|
|
143
|
-
- Shell: `zsh
|
|
143
|
+
- Shell: `zsh`, since bootstrap appends its aliases to `~/.zshrc`.
|
|
144
144
|
|
|
145
145
|
Clone the repo, then run the bootstrap script. It installs dependencies, links the CLI globally, and appends a marked block of Claude Code shell aliases to your `~/.zshrc`.
|
|
146
146
|
|
|
@@ -17,7 +17,7 @@ Length, placement, and staleness answer to different evidence, and one list hand
|
|
|
17
17
|
- Propose a split or an owner as a structure line for each overgrown entry and doubled or unnamed coverage finding, with the headings and the reason
|
|
18
18
|
- Ship the staleness axis as a reading rather than cutting it for lacking a verb
|
|
19
19
|
- Report a refusal, an absent key, or a missing subcommand as an unread axis naming the cause, and say which layer answered the classifier sweep
|
|
20
|
-
- Run standalone,
|
|
20
|
+
- Run standalone, reading the requirements record for drift with its reminder, and the design record through `canon design audit --json`, routing its findings to `design-record`
|
|
21
21
|
|
|
22
22
|
## Must not
|
|
23
23
|
|
|
@@ -60,6 +60,16 @@ A refusal, an absent key, and a subcommand the project's binary does not carry e
|
|
|
60
60
|
- `canon context audit` refuses with `reason: "no-folders"` where the layout is not the expected one. Reading that honest refusal as an empty finding set reports a healthy corpus nothing looked at.
|
|
61
61
|
- A narrower read is not an unread one. The sweep running on its regex layer alone still answered, so report it with the layer named.
|
|
62
62
|
|
|
63
|
+
### The design record
|
|
64
|
+
|
|
65
|
+
`canon/DESIGN.md` reads outside both verbs above, since neither measures its prose budget or its tokens. When the scope reaches it, read it through its own verb:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
canon design audit --json
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The record carries `prose`, the lines past the budget with their counts, and `tokens`, the custom properties the stylesheet does not declare. `${CLAUDE_SKILL_DIR}/references/axes.md` holds how each reads and where it routes. Skip the verb when the scope excludes the record or the record is `null`.
|
|
72
|
+
|
|
63
73
|
## Step 3: read the staleness axis
|
|
64
74
|
|
|
65
75
|
No verb answers whether a document still describes the tree it was written about. Read each document against the surfaces it names and report where the two have parted, taking the file paths, commands, and counts it states as the claims to test.
|
|
@@ -121,3 +131,12 @@ Structure `canon/context/surfaces/`: `cli.md` (300 lines) splits into `cli.md` t
|
|
|
121
131
|
The line is a proposal for the operator, with the headings each entry takes and why. Health applies nothing, and `document-repair` offers the proposal before it rewrites.
|
|
122
132
|
|
|
123
133
|
Keep the evidence line under the table on every run. A table read without it flattens a judgment into a measurement, which is the one shape the three classes exist to prevent.
|
|
134
|
+
|
|
135
|
+
### The design record row
|
|
136
|
+
|
|
137
|
+
When Step 2 read the design record, add a row for `canon/DESIGN.md`, with both findings `measured`.
|
|
138
|
+
|
|
139
|
+
- Put the `prose` findings in the Length cell and the `tokens` findings in the Placement cell
|
|
140
|
+
- Write `unread: <reason>` in the Placement cell when `tokens.status` reads `unchecked`, since a check that could not run is no pass
|
|
141
|
+
- Write `n/a: <reason>` there when it reads `not-applicable`, a values record with no property to compare
|
|
142
|
+
- Route every finding to `design-record`, never to `document-repair`
|
|
@@ -73,6 +73,24 @@ Both verbs resolve their folders by surface root rather than by a path written i
|
|
|
73
73
|
- The audit refuses with `reason: "no-folders"`, naming the folders it looked for. Report that refusal as an unread axis.
|
|
74
74
|
- The sweep with the model layer off answers from its regex layer alone, a narrower read rather than a refusal, so say which layer answered
|
|
75
75
|
|
|
76
|
+
## The design record
|
|
77
|
+
|
|
78
|
+
| Field | Value |
|
|
79
|
+
| -------------- | ------------------------------------------------------------------------------------------------ |
|
|
80
|
+
| Measures | Whether the design record holds to its prose budget and names only properties its stylesheet has |
|
|
81
|
+
| Verb | `canon design audit --json` |
|
|
82
|
+
| Evidence class | `measured` |
|
|
83
|
+
|
|
84
|
+
The record is exempt from the length and placement verbs above, so this is the only measured read it gets.
|
|
85
|
+
|
|
86
|
+
- `prose`: each place the record passes the budget, with the count and the cap it was read against. Take the caps from the finding rather than from a number written here.
|
|
87
|
+
- `tokens.missing`: a custom property a table names that the stylesheet the lead names does not declare
|
|
88
|
+
- `tokens.status: "unchecked"`: the lead names no stylesheet, or the one it names does not resolve. Report the axis as unread with the `reason`, since nothing was compared.
|
|
89
|
+
- `tokens.status: "not-applicable"`: a values record, which carries `Value` where an intent record carries `Property`. Report it as not applicable with the `reason`.
|
|
90
|
+
- `record: null`: the project holds no design record, so there is nothing to report
|
|
91
|
+
|
|
92
|
+
**Routes to:** `design-record`, which owns the record's shape. `document-repair` rewrites a context folder and does not know the budget.
|
|
93
|
+
|
|
76
94
|
## Staleness
|
|
77
95
|
|
|
78
96
|
| Field | Value |
|
|
@@ -38,7 +38,7 @@ Read these in parallel from the project root, skipping any that do not exist:
|
|
|
38
38
|
|
|
39
39
|
Also read `canon/DESIGN.md` when the feature touches code or UI, for visual intent and component rules, with values read from the stylesheet its lead names. Skip it for prose, docs, catalog, or config-only changes. For a UI feature, read `canon/design/index.md` too, if it exists, for the surfaces with an accepted design.
|
|
40
40
|
|
|
41
|
-
Coding standards live in `.claude/rules/`. Claude Code loads them automatically, and path-scoped rules apply to the files they match.
|
|
41
|
+
Coding standards live in `.claude/rules/`. Claude Code loads them automatically, and path-scoped rules apply to the files they match. For a UI feature, load `canon:design-taste` whether or not `canon/DESIGN.md` exists.
|
|
42
42
|
|
|
43
43
|
### Read the domain's context entry
|
|
44
44
|
|
|
@@ -78,7 +78,7 @@ Read these in parallel from the project root, skipping any that do not exist:
|
|
|
78
78
|
- `.canon/tasks/priority.md`: where this feature sits on the board and what each neighbor waits on, resolved at the main root the way `session-worktree` does, since a linked worktree holds no copy
|
|
79
79
|
- `.canon/plans/feature-<slug>.md` for the branch, when present: the intent the PR should satisfy, and its `**Review focus:**` section as the inputs this pass must confirm the diff handles
|
|
80
80
|
|
|
81
|
-
Coding standards from `.claude/rules/` are auto-loaded by Claude Code.
|
|
81
|
+
Coding standards from `.claude/rules/` are auto-loaded by Claude Code for the files a first pass reads. A path-scoped rule loads only when a session reads a matching file, so a later pass opens each changed file through the Read tool per `references/scope.md`, which loads the rules its trunk path matches and names the gap for an added file.
|
|
82
82
|
|
|
83
83
|
## Step 2: scope the read
|
|
84
84
|
|
|
@@ -117,3 +117,13 @@ Carry `<prior-heading>` into Step 4 and run Step 3 unchanged. This guard turns o
|
|
|
117
117
|
## The read
|
|
118
118
|
|
|
119
119
|
Read each changed file in scope. Skip deleted files. Run reads in parallel.
|
|
120
|
+
|
|
121
|
+
### Load the path-scoped rules on a later pass
|
|
122
|
+
|
|
123
|
+
A path-scoped rule loads when a session uses the Read tool on a matching file, and a Bash call such as `git show` loads none. A later pass reads only `<prior-oid>..<headRefOid>`, so the head copy of each changed file has to reach the Read tool.
|
|
124
|
+
|
|
125
|
+
- Open the trunk path with the Read tool when it exists, as the trigger, and take the content from `git show <headRefOid>:<path>`, since trunk content is stale
|
|
126
|
+
- For an added file, write its head blob to `.canon/tmp/pr/review/head-read/<number>-<short-sha>/<path>`, in the review body folder, then open that copy with the Read tool
|
|
127
|
+
- The copy loads only `**/` globs, since a root-anchored glob such as `canon/design/**` misses its prefix, so an added file under one loads no rule
|
|
128
|
+
- Read only files the range changed, never the whole tree, so the cost stays bounded by the range
|
|
129
|
+
- A range whose only change is a deletion has no file to read, so skip it and carry on
|
|
@@ -64,6 +64,8 @@ Target update: `<path>`, canon `<installed>` to `<running>`.
|
|
|
64
64
|
|
|
65
65
|
**Migrations, in run order:** <each pending or refused entry from migrate status, with its verb>
|
|
66
66
|
|
|
67
|
+
**Retired calls:** <each `retiredCallers` entry from migrate status, with its file, line, the release that retired the verb, and where the work went, or omitted when the list is empty>
|
|
68
|
+
|
|
67
69
|
**Drift:** <each non-empty cause from sync --check, with its count and paths>
|
|
68
70
|
|
|
69
71
|
**Where it lands:** <the linked worktree this run is already in, or the worktree `.claude/worktrees/<name>/` it will enter, or the branch it will create in place when the operator declines a worktree>
|
|
@@ -73,11 +75,13 @@ Target update: `<path>`, canon `<installed>` to `<running>`.
|
|
|
73
75
|
**Commands, in order:** <the exact commands Steps 4 to 6 will run>
|
|
74
76
|
```
|
|
75
77
|
|
|
78
|
+
A retired call is no migration, so no step here fixes it. Name it in the preview and leave the edit to the operator, who owns the script.
|
|
79
|
+
|
|
76
80
|
Summarize the changelog for this project, not for the toolkit. Drop an entry that touches nothing the target holds, and lead with anything a migration or a sync will change.
|
|
77
81
|
|
|
78
82
|
### Nothing pending
|
|
79
83
|
|
|
80
|
-
When the changelog range is empty, `migrate status` lists nothing pending or refused
|
|
84
|
+
When the changelog range is empty, `migrate status` lists nothing pending or refused and no `retiredCallers`, and the drift lines hold nothing a step here would take, write nothing. Invoke `canon:target-check` and report the target current with its table.
|
|
81
85
|
|
|
82
86
|
### Take a branch after the go-ahead
|
|
83
87
|
|
|
@@ -24,7 +24,7 @@ A flat installed-rule layout makes `gov sync` report "No governance surfaces fou
|
|
|
24
24
|
|
|
25
25
|
## Governance sync
|
|
26
26
|
|
|
27
|
-
`canon gov sync [target]` updates what `.claude/rules/canon/` holds and adds nothing. `.claude/rules/project/` is never read, so put project rules there.
|
|
27
|
+
`canon gov sync [target]` updates what `.claude/rules/canon/` holds and adds nothing. `.claude/rules/project/` is never read, so put project rules there. After an apply it lists each copied rule with its globs and matching file count, and `--json` puts `{ "coverage": [...] }` on stdout.
|
|
28
28
|
|
|
29
29
|
- A rule the toolkit no longer ships is deleted, edited or not
|
|
30
30
|
- A rule renamed in `governance/renames.toml` moves, and its local edits are dropped
|
package/docs/agents/migrate.md
CHANGED
|
@@ -38,6 +38,12 @@ kind: reference
|
|
|
38
38
|
|
|
39
39
|
`status` runs each verb as a dry run and exits 2 while any is pending, with a refusal outranking that. Each entry's `verb` carries the flags its dry run used, so append `--write` to it. A clean entry can turn pending once an earlier one applies, so run it again after each `--write`.
|
|
40
40
|
|
|
41
|
+
`status --json` also carries `retiredCallers`, the project calls to a verb a release retired.
|
|
42
|
+
|
|
43
|
+
- Each entry has `file`, `line`, `verb`, `release`, and `movedTo`
|
|
44
|
+
- The scan reads the workflow folder, the `package.json` scripts, `scripts/`, and the files those name, matching a command line or an argument list and never prose
|
|
45
|
+
- A caller changes no exit code, since no `--write` fixes one, so edit the script by hand
|
|
46
|
+
|
|
41
47
|
Three markers keep an old spelling on a line. Each also covers the nearest non-blank line, below it for `canon-keep-record-root` and above it for `canon-keep-surface-root`.
|
|
42
48
|
|
|
43
49
|
| Marker | Keeps |
|
package/docs/target/sync.md
CHANGED
|
@@ -43,10 +43,12 @@ Add `--json` for a machine-readable report and `--exit-code` to fail CI when a t
|
|
|
43
43
|
## Targeted
|
|
44
44
|
|
|
45
45
|
- Seed docs such as `CLAUDE.md` and `canon/REQUIREMENTS.md`: merge by hand, keeping the project's customizations
|
|
46
|
-
- Installed governance rules: `canon gov sync <path>`, which adds no new rules
|
|
46
|
+
- Installed governance rules: `canon gov sync <path>`, which adds no new rules and reports coverage afterward
|
|
47
47
|
- Tooling configs and seeds: read differences with `canon tooling diff <stack> <path>`, then `canon tooling sync <stack> <path> --write`, which overwrites golden configs and copies each seed once
|
|
48
48
|
- Index regeneration: `canon indexes regen`
|
|
49
49
|
|
|
50
|
+
After `canon gov sync` applies, it lists each rule it copied with its globs and how many project files match. A `ui/` rule adds a pointer to `/canon:ux-audit`. `--json` writes `{ "coverage": [{ "rule", "paths", "files", "ui" }] }` to stdout, empty when nothing was copied, with `files` null outside git. The report never changes the exit code.
|
|
51
|
+
|
|
50
52
|
Try a scenario from [sandbox](../../canon/context/development/sandbox.md) before syncing a real project.
|
|
51
53
|
|
|
52
54
|
## Running sync from an agent session
|
|
@@ -36,7 +36,7 @@ Each role keeps to its own writes, and five rules cover how the roles meet.
|
|
|
36
36
|
7. A pass posts `## Review` while anything is open and `## Review closed` when nothing is. The reviewer then runs `canon pr lift`.
|
|
37
37
|
8. The human merges with `canon pr merge <number>`. The orchestrator tells any trailing worker whose files the merge touched to run `review-address`, which rebases.
|
|
38
38
|
|
|
39
|
-
Each worker halts at the PR, and the merge stays a human gate. `canon pr merge` squashes with the body's Summary, Key changes, and Technical context as the message, and the seeded deny rule covers the forms an agent writes by default.
|
|
39
|
+
Each worker halts at the PR, and the merge stays a human gate. `canon pr merge` squashes with the body's Summary, Key changes, and Technical context as the message, and the seeded deny rule covers the forms an agent writes by default. A release-please or Dependabot pull request merges the same way, from the bot's own body.
|
|
40
40
|
|
|
41
41
|
## The review layer
|
|
42
42
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@erclx/canon",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "5.
|
|
4
|
+
"version": "5.60.0",
|
|
5
5
|
"description": "Runs your agent's work and keeps its code good, on one setup for every repo.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"bin": {
|
|
@@ -30,14 +30,14 @@
|
|
|
30
30
|
"check:shell": "find scripts tooling claude .claude/hooks -name '*.sh' -exec shellcheck --severity=warning {} + && find .husky -maxdepth 1 -type f -exec shellcheck --severity=warning --shell=sh {} +",
|
|
31
31
|
"check:lint": "oxlint --disable-nested-config --max-warnings 0",
|
|
32
32
|
"check:types": "tsc --noEmit",
|
|
33
|
-
"check:install": "
|
|
33
|
+
"check:install": "bun scripts/core/install-check.ts",
|
|
34
34
|
"test": "bun --bun vitest run",
|
|
35
35
|
"prepare": "husky",
|
|
36
36
|
"check": "bun src/cli.ts gate run",
|
|
37
37
|
"check:ci": "bun src/cli.ts gate run --all",
|
|
38
38
|
"update": "bun update --interactive && bun run check",
|
|
39
39
|
"clean": "rm -rf node_modules && bun pm cache rm && bun install",
|
|
40
|
-
"bootstrap": "
|
|
40
|
+
"bootstrap": "bun scripts/core/bootstrap.ts",
|
|
41
41
|
"canon:sandbox": "canon sandbox",
|
|
42
42
|
"canon:sandbox:reset": "canon sandbox reset",
|
|
43
43
|
"web:tokens": "bun src/cli.ts design css --no-components > web/src/styles/tokens.css.new && (echo '/* Generated by `canon design css --no-components`. Regenerate with `bun run web:tokens`. Do not hand-edit. */'; cat web/src/styles/tokens.css.new) > web/src/styles/tokens.css && rm web/src/styles/tokens.css.new",
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Installs dependencies, links the canon CLI globally, and appends the marked
|
|
3
|
+
* alias block to the user's `.zshrc`, the only file this writes outside the
|
|
4
|
+
* repository. Runs on Bun alone, so a stock macOS bash 3.2 never enters it.
|
|
5
|
+
*/
|
|
6
|
+
import { appendFileSync, existsSync, readFileSync } from 'node:fs'
|
|
7
|
+
import { homedir } from 'node:os'
|
|
8
|
+
import { join } from 'node:path'
|
|
9
|
+
import { fileURLToPath } from 'node:url'
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
intro,
|
|
13
|
+
logAdd,
|
|
14
|
+
logError,
|
|
15
|
+
logInfo,
|
|
16
|
+
logStep,
|
|
17
|
+
logWarn,
|
|
18
|
+
outro,
|
|
19
|
+
} from '../../src/cli/ui'
|
|
20
|
+
|
|
21
|
+
const root = fileURLToPath(new URL('../..', import.meta.url)).replace(/\/$/, '')
|
|
22
|
+
|
|
23
|
+
const MARKER_OPEN = '# >>> canon aliases >>>'
|
|
24
|
+
const MARKER_CLOSE = '# <<< canon aliases <<<'
|
|
25
|
+
|
|
26
|
+
function zshrcPath(): string {
|
|
27
|
+
const dir = process.env.ZDOTDIR
|
|
28
|
+
return join(dir !== undefined && dir !== '' ? dir : homedir(), '.zshrc')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** `$TOOLKIT` stays a literal in the block, so the shell expands it on load. */
|
|
32
|
+
function aliasBlock(): string {
|
|
33
|
+
return `${MARKER_OPEN}
|
|
34
|
+
TOOLKIT="${root}"
|
|
35
|
+
export CLAUDE_CODE_ENABLE_TODO_TOOLS=1
|
|
36
|
+
|
|
37
|
+
alias cl='claude'
|
|
38
|
+
alias clr='cl -r'
|
|
39
|
+
alias clc='cl -c'
|
|
40
|
+
alias clw='cl -w'
|
|
41
|
+
alias cls='cl --model sonnet'
|
|
42
|
+
|
|
43
|
+
alias clp='claude --plugin-dir $TOOLKIT/claude'
|
|
44
|
+
alias clpc='clp -c'
|
|
45
|
+
alias clps='clp --model sonnet'
|
|
46
|
+
alias clpa='clp agents'
|
|
47
|
+
alias clpac='clpa --cwd .'
|
|
48
|
+
${MARKER_CLOSE}`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function run(step: string, cmd: string[]): void {
|
|
52
|
+
const result = Bun.spawnSync(cmd, {
|
|
53
|
+
cwd: root,
|
|
54
|
+
stdout: 'ignore',
|
|
55
|
+
stderr: 'pipe',
|
|
56
|
+
})
|
|
57
|
+
if (result.exitCode !== 0) {
|
|
58
|
+
throw new Error(`${step} failed: ${result.stderr.toString().trim()}`)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function installCli(): void {
|
|
63
|
+
logStep('Installing the canon CLI')
|
|
64
|
+
run('bun install', ['bun', 'install', '--cwd', root])
|
|
65
|
+
run('bun link', ['bun', 'link', '--cwd', root])
|
|
66
|
+
logInfo('Dependencies installed and canon linked')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function installAliases(): void {
|
|
70
|
+
const zshrc = zshrcPath()
|
|
71
|
+
logStep('Installing Claude Code shell aliases')
|
|
72
|
+
logInfo(
|
|
73
|
+
`This step manages a marked alias block in ${zshrc}, the only file outside the repo this script writes.`,
|
|
74
|
+
)
|
|
75
|
+
logInfo(
|
|
76
|
+
'Delete the block and its two marker comments to remove the aliases. Re-running never adds a second copy.',
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
const existing = existsSync(zshrc) ? readFileSync(zshrc, 'utf8') : ''
|
|
80
|
+
if (existing.includes(MARKER_OPEN)) {
|
|
81
|
+
logInfo(`Aliases already present in ${zshrc}, skipping`)
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
if (/^alias clp=/m.test(existing)) {
|
|
85
|
+
logWarn(
|
|
86
|
+
`An unmanaged clp alias is already in ${zshrc}. Leaving it untouched.`,
|
|
87
|
+
)
|
|
88
|
+
logWarn('Remove it and re-run to switch to the managed block.')
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
appendFileSync(zshrc, `\n${aliasBlock()}\n`)
|
|
93
|
+
logAdd(`Appended canon alias block to ${zshrc}`)
|
|
94
|
+
logInfo(`Run 'source ${zshrc}' or open a new shell to load them`)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function verifyCli(): void {
|
|
98
|
+
logStep('Verifying the install')
|
|
99
|
+
if (Bun.which('canon') !== null) {
|
|
100
|
+
logInfo('canon resolves on PATH')
|
|
101
|
+
} else {
|
|
102
|
+
logWarn(
|
|
103
|
+
'canon not on PATH yet. Ensure $BUN_INSTALL/bin is exported, then re-open your shell.',
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
intro('canon bootstrap')
|
|
109
|
+
let failed = false
|
|
110
|
+
try {
|
|
111
|
+
installCli()
|
|
112
|
+
installAliases()
|
|
113
|
+
verifyCli()
|
|
114
|
+
} catch (error) {
|
|
115
|
+
failed = true
|
|
116
|
+
logError(error instanceof Error ? error.message : String(error))
|
|
117
|
+
} finally {
|
|
118
|
+
outro()
|
|
119
|
+
}
|
|
120
|
+
process.exit(failed ? 1 : 0)
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verifies the published install path end to end: packs this repo, installs
|
|
3
|
+
* the tarball for production, and runs `canon init` from it into a fresh
|
|
4
|
+
* project. Runs on Bun alone.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, mkdirSync, rmSync } from 'node:fs'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
import { fileURLToPath } from 'node:url'
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
intro,
|
|
12
|
+
logError,
|
|
13
|
+
logInfo,
|
|
14
|
+
logStep,
|
|
15
|
+
outro,
|
|
16
|
+
pipeOutput,
|
|
17
|
+
} from '../../src/cli/ui'
|
|
18
|
+
|
|
19
|
+
const root = fileURLToPath(new URL('../..', import.meta.url)).replace(/\/$/, '')
|
|
20
|
+
const tmpRoot = join(root, '.canon/tmp/install-check')
|
|
21
|
+
const packDir = join(tmpRoot, 'pack')
|
|
22
|
+
const extractDir = join(tmpRoot, 'extract')
|
|
23
|
+
const targetDir = join(tmpRoot, 'target')
|
|
24
|
+
|
|
25
|
+
const HELP = `Usage: scripts/core/install-check.ts [--keep]
|
|
26
|
+
|
|
27
|
+
Verifies the published install path end to end:
|
|
28
|
+
1. Packs this repo into a tarball at .canon/tmp/install-check/pack
|
|
29
|
+
2. Extracts it and asserts sandbox is absent from the tree
|
|
30
|
+
3. Runs bun install --production in the extracted tree
|
|
31
|
+
4. Runs the CLI with --help from the extracted tree to confirm it resolves
|
|
32
|
+
5. Scaffolds a fresh project in .canon/tmp/install-check/target
|
|
33
|
+
6. Runs canon init from the extracted package and asserts a scaffold landed
|
|
34
|
+
|
|
35
|
+
Flags:
|
|
36
|
+
--keep Keep the tmp tree on exit for inspection
|
|
37
|
+
`
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Every domain canon init installs needs at least one path here. A domain with
|
|
41
|
+
* no assertion can truncate silently: init still exits 0 because run_domain
|
|
42
|
+
* catches a failed domain, and the gate stays green while the target is
|
|
43
|
+
* missing everything that domain provides.
|
|
44
|
+
*
|
|
45
|
+
* Authoring references name no path because they ship inside the plugin's
|
|
46
|
+
* skills rather than installing into a target. The two rule paths stand for
|
|
47
|
+
* the `claude` and `canon` groups `base` takes whole.
|
|
48
|
+
*/
|
|
49
|
+
const SCAFFOLD_PATHS = [
|
|
50
|
+
'CLAUDE.md',
|
|
51
|
+
'.claude',
|
|
52
|
+
'canon/context/index.md',
|
|
53
|
+
'.oxfmtrc.json',
|
|
54
|
+
'.lintstagedrc',
|
|
55
|
+
'.husky/pre-commit',
|
|
56
|
+
'.github/workflows/verify.yml',
|
|
57
|
+
'scripts/verify.ts',
|
|
58
|
+
'.claude/rules/canon/claude/output.md',
|
|
59
|
+
'.claude/rules/canon/canon/scratch.md',
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
const args = process.argv.slice(2)
|
|
63
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
64
|
+
process.stdout.write(HELP)
|
|
65
|
+
process.exit(0)
|
|
66
|
+
}
|
|
67
|
+
const keep = args.includes('--keep')
|
|
68
|
+
|
|
69
|
+
/** Runs a command to completion, showing its output inside the frame. */
|
|
70
|
+
function run(
|
|
71
|
+
failure: string,
|
|
72
|
+
cmd: string[],
|
|
73
|
+
opts: { cwd?: string; show?: boolean } = {},
|
|
74
|
+
): string {
|
|
75
|
+
const result = Bun.spawnSync(cmd, {
|
|
76
|
+
cwd: opts.cwd ?? root,
|
|
77
|
+
stdin: 'ignore',
|
|
78
|
+
stdout: 'pipe',
|
|
79
|
+
stderr: 'pipe',
|
|
80
|
+
})
|
|
81
|
+
const stdout = result.stdout.toString()
|
|
82
|
+
const output = `${stdout}${result.stderr.toString()}`.trim()
|
|
83
|
+
if (opts.show && output !== '') pipeOutput(output)
|
|
84
|
+
if (result.exitCode !== 0) {
|
|
85
|
+
if (!opts.show && output !== '') pipeOutput(output)
|
|
86
|
+
throw new Error(failure)
|
|
87
|
+
}
|
|
88
|
+
return stdout
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function check(): void {
|
|
92
|
+
logStep('Assert excluded path exists in source')
|
|
93
|
+
// An absence check over a path the source tree no longer holds passes on
|
|
94
|
+
// nothing, so the excluded tree has to exist here before its absence below
|
|
95
|
+
// can mean the publish left it out.
|
|
96
|
+
if (!existsSync(join(root, 'sandbox/exempt.toml'))) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
'sandbox/exempt.toml is missing from the source tree, so the absence check below would pass on nothing',
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
logInfo('Present: sandbox/exempt.toml')
|
|
102
|
+
|
|
103
|
+
logStep('Pack')
|
|
104
|
+
const packed = run('bun pm pack failed', [
|
|
105
|
+
'bun',
|
|
106
|
+
'pm',
|
|
107
|
+
'pack',
|
|
108
|
+
'--quiet',
|
|
109
|
+
'--ignore-scripts',
|
|
110
|
+
'--destination',
|
|
111
|
+
packDir,
|
|
112
|
+
])
|
|
113
|
+
const tarball = packed.trim().split('\n').pop() ?? ''
|
|
114
|
+
logInfo(`Packed to ${tarball}`)
|
|
115
|
+
|
|
116
|
+
logStep('Extract')
|
|
117
|
+
run('tar extract failed', [
|
|
118
|
+
'tar',
|
|
119
|
+
'-xzf',
|
|
120
|
+
tarball,
|
|
121
|
+
'-C',
|
|
122
|
+
extractDir,
|
|
123
|
+
'--strip-components=1',
|
|
124
|
+
])
|
|
125
|
+
logInfo(`Extracted to ${extractDir}`)
|
|
126
|
+
|
|
127
|
+
logStep('Assert excluded path is absent')
|
|
128
|
+
if (existsSync(join(extractDir, 'sandbox'))) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
'sandbox shipped in the tarball, and the files field in package.json leaves it out',
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
logInfo('Absent: sandbox')
|
|
134
|
+
|
|
135
|
+
logStep('Install dependencies (production)')
|
|
136
|
+
run(
|
|
137
|
+
'bun install --production failed',
|
|
138
|
+
['bun', 'install', '--production', '--ignore-scripts', '--silent'],
|
|
139
|
+
{ cwd: extractDir, show: true },
|
|
140
|
+
)
|
|
141
|
+
logInfo('Dependencies installed without devDependencies')
|
|
142
|
+
|
|
143
|
+
logStep('Confirm CLI runs')
|
|
144
|
+
run('canon --help failed', ['bun', 'run', 'src/cli.ts', '--help'], {
|
|
145
|
+
cwd: extractDir,
|
|
146
|
+
})
|
|
147
|
+
logInfo('canon --help ran clean from the extracted package')
|
|
148
|
+
|
|
149
|
+
logStep('Scaffold fresh project')
|
|
150
|
+
mkdirSync(targetDir, { recursive: true })
|
|
151
|
+
run('git init failed', ['git', 'init', '--quiet'], { cwd: targetDir })
|
|
152
|
+
logInfo(`Initialized git in ${targetDir}`)
|
|
153
|
+
|
|
154
|
+
logStep('Run canon init')
|
|
155
|
+
run(
|
|
156
|
+
'canon init failed',
|
|
157
|
+
['bun', 'run', join(extractDir, 'src/cli.ts'), 'init', '--stack', 'base'],
|
|
158
|
+
{ cwd: targetDir, show: true },
|
|
159
|
+
)
|
|
160
|
+
logInfo('canon init completed')
|
|
161
|
+
|
|
162
|
+
logStep('Assert scaffold')
|
|
163
|
+
for (const path of SCAFFOLD_PATHS) {
|
|
164
|
+
if (!existsSync(join(targetDir, path))) {
|
|
165
|
+
throw new Error(`Missing after canon init: ${path}`)
|
|
166
|
+
}
|
|
167
|
+
logInfo(`Found: ${path}`)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
logStep('Verification passed')
|
|
171
|
+
logInfo('Manual check still needed: bun link and global canon invocation')
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
rmSync(tmpRoot, { recursive: true, force: true })
|
|
175
|
+
mkdirSync(packDir, { recursive: true })
|
|
176
|
+
mkdirSync(extractDir, { recursive: true })
|
|
177
|
+
|
|
178
|
+
intro('Install verification')
|
|
179
|
+
let failed = false
|
|
180
|
+
try {
|
|
181
|
+
check()
|
|
182
|
+
} catch (error) {
|
|
183
|
+
failed = true
|
|
184
|
+
logError(error instanceof Error ? error.message : String(error))
|
|
185
|
+
} finally {
|
|
186
|
+
if (keep) {
|
|
187
|
+
logInfo(`Kept artifacts at ${tmpRoot}`)
|
|
188
|
+
} else {
|
|
189
|
+
rmSync(tmpRoot, { recursive: true, force: true })
|
|
190
|
+
}
|
|
191
|
+
outro()
|
|
192
|
+
}
|
|
193
|
+
process.exit(failed ? 1 : 0)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export interface RetiredVerb {
|
|
2
|
+
/** The words a caller types after `canon`, one for a group and two for a subcommand. */
|
|
3
|
+
readonly path: readonly string[]
|
|
4
|
+
/** The release that dropped the verb, from the changelog. */
|
|
5
|
+
readonly release: string
|
|
6
|
+
readonly movedTo: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const DESIGN_MOVED =
|
|
10
|
+
'the design values now live in the stylesheet your canon/DESIGN.md lead names'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Every verb a release dropped, with the release and where the work went. The
|
|
14
|
+
* router reads this ahead of the parser, and `canon migrate status` reads it to
|
|
15
|
+
* find project calls. A verb that returns leaves this table in the same change.
|
|
16
|
+
*/
|
|
17
|
+
export const RETIRED_VERBS: readonly RetiredVerb[] = [
|
|
18
|
+
{
|
|
19
|
+
path: ['canvas'],
|
|
20
|
+
release: '5.54.0',
|
|
21
|
+
movedTo: 'Frameboard, which carries the design board in its own repository',
|
|
22
|
+
},
|
|
23
|
+
{ path: ['design', 'render'], release: '5.54.0', movedTo: DESIGN_MOVED },
|
|
24
|
+
{ path: ['design', 'install'], release: '5.54.0', movedTo: DESIGN_MOVED },
|
|
25
|
+
{ path: ['design', 'sync'], release: '5.54.0', movedTo: DESIGN_MOVED },
|
|
26
|
+
{ path: ['design', 'fonts'], release: '5.54.0', movedTo: DESIGN_MOVED },
|
|
27
|
+
{ path: ['design', 'list'], release: '5.54.0', movedTo: DESIGN_MOVED },
|
|
28
|
+
{ path: ['design', 'add'], release: '5.54.0', movedTo: DESIGN_MOVED },
|
|
29
|
+
{
|
|
30
|
+
path: ['teach'],
|
|
31
|
+
release: '5.53.0',
|
|
32
|
+
movedTo: 'the canon:teach-craft skill',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
path: ['slides'],
|
|
36
|
+
release: '5.53.0',
|
|
37
|
+
movedTo: 'the canon:teach-craft skill',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
path: ['demo'],
|
|
41
|
+
release: '5.53.0',
|
|
42
|
+
movedTo: 'a tool built for composed video',
|
|
43
|
+
},
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Matches the leading words of argv, stopping at the first option so a flag's
|
|
48
|
+
* value is never read as a verb name.
|
|
49
|
+
*/
|
|
50
|
+
export function findRetired(args: readonly string[]): RetiredVerb | undefined {
|
|
51
|
+
const words: string[] = []
|
|
52
|
+
for (const arg of args) {
|
|
53
|
+
if (arg.startsWith('-')) break
|
|
54
|
+
words.push(arg)
|
|
55
|
+
}
|
|
56
|
+
return RETIRED_VERBS.find((verb) =>
|
|
57
|
+
verb.path.every((word, index) => words[index] === word),
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function retiredMessage(verb: RetiredVerb): string {
|
|
62
|
+
return `error: 'canon ${verb.path.join(' ')}' was retired in v${verb.release}. The work moved to ${verb.movedTo}.`
|
|
63
|
+
}
|