@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +1 -1
  3. package/claude/.claude-plugin/plugin.json +1 -1
  4. package/claude/skills/document-health/REQUIREMENT.md +1 -1
  5. package/claude/skills/document-health/SKILL.md +19 -0
  6. package/claude/skills/document-health/references/axes.md +18 -0
  7. package/claude/skills/plan-feature/SKILL.md +1 -1
  8. package/claude/skills/review-pr/SKILL.md +1 -1
  9. package/claude/skills/review-pr/references/scope.md +10 -0
  10. package/claude/skills/target-update/SKILL.md +5 -1
  11. package/docs/agents/install-and-sync.md +1 -1
  12. package/docs/agents/migrate.md +6 -0
  13. package/docs/target/sync.md +3 -1
  14. package/docs/workflow/operating-model.md +1 -1
  15. package/package.json +3 -3
  16. package/scripts/core/bootstrap.ts +120 -0
  17. package/scripts/core/install-check.ts +193 -0
  18. package/src/cli/retired.ts +63 -0
  19. package/src/cli/ui.ts +3 -5
  20. package/src/cli.ts +8 -2
  21. package/src/commands/design.ts +57 -1
  22. package/src/commands/gov.ts +8 -2
  23. package/src/commands/migrate.ts +7 -1
  24. package/src/commands/pr.ts +10 -1
  25. package/src/commands/tooling.ts +17 -2
  26. package/src/design/audit.ts +109 -0
  27. package/src/design/css.ts +1 -5
  28. package/src/gov/adapter.ts +2 -0
  29. package/src/gov/coverage.ts +62 -0
  30. package/src/migrate/callers.ts +192 -0
  31. package/src/migrate/status.ts +11 -3
  32. package/src/pr/merge.ts +77 -1
  33. package/src/pr/rest.ts +1 -0
  34. package/src/sandbox/expect.ts +1 -1
  35. package/src/sync/engine.ts +84 -0
  36. package/src/sync/stamp.ts +75 -5
  37. package/src/tooling/inject.ts +6 -3
  38. package/src/tooling/install.ts +55 -0
  39. package/scripts/core/bootstrap.sh +0 -86
  40. package/scripts/core/install-check.sh +0 -113
  41. package/scripts/lib/ui.sh +0 -45
@@ -1,4 +1,5 @@
1
1
  import type { RunCommand } from '@/gate/measures'
2
+ import { type RetiredCaller, scanRetiredCallers } from '@/migrate/callers'
2
3
 
3
4
  interface Migration {
4
5
  readonly name: string
@@ -38,6 +39,8 @@ export interface StatusReport {
38
39
  readonly migrations: readonly MigrationStatus[]
39
40
  readonly pending: number
40
41
  readonly refused: number
42
+ /** Project calls to a retired verb. Reported only, since no `--write` fixes one. */
43
+ readonly retiredCallers: readonly RetiredCaller[]
41
44
  }
42
45
 
43
46
  function parseRecord(stdout: string): unknown {
@@ -111,14 +114,19 @@ async function readOne(
111
114
  * subcommand shares: 0 with nothing to move, 2 with a plan and no `--write`,
112
115
  * anything else a refusal. It never passes `--write`, and it reads no count,
113
116
  * since the records share no field to count from.
117
+ * A retired caller is listed beside them and never changes the exit, since
118
+ * exit 2 means the pending verbs can be run.
114
119
  */
115
120
  export async function readMigrationStatus(
116
121
  run: RunCommand,
122
+ root: string,
117
123
  ): Promise<StatusReport> {
118
- const migrations = await Promise.all(
119
- MIGRATIONS.map((migration) => readOne(run, migration)),
120
- )
124
+ const [migrations, retiredCallers] = await Promise.all([
125
+ Promise.all(MIGRATIONS.map((migration) => readOne(run, migration))),
126
+ scanRetiredCallers(root),
127
+ ])
121
128
  return {
129
+ retiredCallers,
122
130
  migrations,
123
131
  pending: migrations.filter((m) => m.state === 'pending').length,
124
132
  refused: migrations.filter((m) => m.state === 'refused').length,
package/src/pr/merge.ts CHANGED
@@ -22,8 +22,14 @@ export interface MergePull {
22
22
  readonly body: string
23
23
  /** The head the merge is pinned to, so a push after the read refuses. */
24
24
  readonly head: string
25
+ /** The head branch name, which a bot pull request is recognized by. */
26
+ readonly branch: string
27
+ /** Whether the author is a bot account, which a release pull request's is not. */
28
+ readonly isBotAuthor: boolean
25
29
  }
26
30
 
31
+ export type BotKind = 'release-please' | 'dependabot'
32
+
27
33
  export interface MergeRequest {
28
34
  readonly title: string
29
35
  readonly description: string
@@ -105,6 +111,72 @@ export function composeDescription(body: string): Composed {
105
111
  }
106
112
  }
107
113
 
114
+ const RELEASE_BRANCH = 'release-please--branches--'
115
+ const RELEASE_OPENING = ':robot: I have created a release'
116
+ const RELEASE_FOOTER = 'This PR was generated with'
117
+ const DEPENDABOT_BRANCH = 'dependabot/'
118
+
119
+ /**
120
+ * The bot a pull request comes from, read off the pull request itself. The
121
+ * release pull request's author is the operator's own account, so the head
122
+ * branch carries it, and a second condition keeps a human pull request that
123
+ * borrows the branch prefix on the human path.
124
+ */
125
+ export function botKind(
126
+ pull: Pick<MergePull, 'branch' | 'body' | 'isBotAuthor'>,
127
+ ): BotKind | undefined {
128
+ if (
129
+ pull.branch.startsWith(RELEASE_BRANCH) &&
130
+ pull.body.trimStart().startsWith(RELEASE_OPENING)
131
+ ) {
132
+ return 'release-please'
133
+ }
134
+ if (pull.branch.startsWith(DEPENDABOT_BRANCH) && pull.isBotAuthor) {
135
+ return 'dependabot'
136
+ }
137
+ return undefined
138
+ }
139
+
140
+ /**
141
+ * Where a Dependabot body stops. A group body runs a table and then one
142
+ * `Updates ` line per package, and a single-package body has neither and goes
143
+ * from its `Bumps` lead straight to a `<details>` block, so either line ends
144
+ * the kept text. Neither match relies on the commands footer, which a truncated
145
+ * body lacks.
146
+ */
147
+ function dependabotCut(lines: readonly string[]): number {
148
+ const cut = lines.findIndex(
149
+ (line) =>
150
+ line.startsWith('Updates ') || line.trim().startsWith('<details>'),
151
+ )
152
+ return cut >= 0 ? cut : lines.length
153
+ }
154
+
155
+ /**
156
+ * The squash description of a bot pull request, from the bot's own body. A
157
+ * release keeps its changelog without the robot line, the dividers, and the
158
+ * footer. Dependabot keeps its lead and package table. A body with nothing left
159
+ * refuses `missing-summary`.
160
+ */
161
+ export function composeBotDescription(kind: BotKind, body: string): Composed {
162
+ const lines = body.replaceAll('\r\n', '\n').split('\n')
163
+ const kept =
164
+ kind === 'release-please'
165
+ ? lines.filter((line) => {
166
+ const text = line.trim()
167
+ return (
168
+ text !== '---' &&
169
+ !text.startsWith(RELEASE_OPENING) &&
170
+ !text.startsWith(RELEASE_FOOTER)
171
+ )
172
+ })
173
+ : lines.slice(0, dependabotCut(lines))
174
+ const description = kept.join('\n').trim()
175
+ return description === ''
176
+ ? { kind: 'refused', reason: 'missing-summary' }
177
+ : { kind: 'composed', description }
178
+ }
179
+
108
180
  /** The suffix the web merge adds and the trunk log carries. */
109
181
  export function mergeTitle(title: string, number: number): string {
110
182
  return `${title} (#${number})`
@@ -137,7 +209,11 @@ export async function merge(
137
209
  if (pull.isDraft) return refused('pull-draft', number)
138
210
 
139
211
  const title = mergeTitle(pull.title, number)
140
- const composed = composeDescription(pull.body)
212
+ const bot = botKind(pull)
213
+ const composed =
214
+ bot === undefined
215
+ ? composeDescription(pull.body)
216
+ : composeBotDescription(bot, pull.body)
141
217
  if (composed.kind === 'refused') {
142
218
  return refused(composed.reason, number, { title })
143
219
  }
package/src/pr/rest.ts CHANGED
@@ -20,6 +20,7 @@ export interface RestPull {
20
20
  readonly mergeable_state?: string
21
21
  readonly state?: string
22
22
  readonly draft?: boolean
23
+ readonly user?: { readonly type?: string }
23
24
  readonly node_id?: string
24
25
  }
25
26
 
@@ -224,7 +224,7 @@ export function parseExpectation(source: string): Expectation {
224
224
  /**
225
225
  * `manual` is deliberately excluded. Counting it would let an arm declare five
226
226
  * prose lines, assert nothing, and still read green, which is the vacuous case
227
- * `scripts/core/install-check.sh` warns about in its own comment.
227
+ * `scripts/core/install-check.ts` warns about in its own comment.
228
228
  */
229
229
  export function countMechanicalAssertions(expectation: Expectation): number {
230
230
  return (
@@ -27,6 +27,7 @@ import {
27
27
  logWarn,
28
28
  outro,
29
29
  outroSuccess,
30
+ plural,
30
31
  select,
31
32
  } from '@/cli/ui'
32
33
  import { compareVersions, parseVersion } from '@/version/compare'
@@ -112,6 +113,17 @@ export interface SyncPlan {
112
113
  readonly historyUnavailable: boolean
113
114
  }
114
115
 
116
+ /**
117
+ * What an applied rule governs in the target. `paths` empty means the rule
118
+ * loads everywhere, and `files` unset means the target could not be listed.
119
+ */
120
+ export interface RuleCoverage {
121
+ readonly rule: string
122
+ readonly paths: readonly string[]
123
+ readonly files: number | undefined
124
+ readonly ui: boolean
125
+ }
126
+
115
127
  /**
116
128
  * What a headless run does once the plan has changes. Domains whose files the
117
129
  * toolkit owns apply them. Domains whose files a project is expected to edit
@@ -189,6 +201,14 @@ export interface SyncAdapter {
189
201
  readonly nonInteractive?: NonInteractivePolicy
190
202
  /** Runs on a completed sync, including one with no changes. */
191
203
  onComplete?(target: string): Promise<void>
204
+ /**
205
+ * What each rule an apply copied governs in the target. Called only after
206
+ * an apply, and advisory: a throw is reported and never changes the exit.
207
+ */
208
+ coverage?(
209
+ target: string,
210
+ changes: readonly SyncChange[],
211
+ ): Promise<readonly RuleCoverage[]>
192
212
  /** Where this domain's hashes are stamped. Unset domains go unstamped. */
193
213
  readonly stamp?: StampSource
194
214
  }
@@ -403,6 +423,8 @@ export async function applyChanges(
403
423
  export interface SyncRunOptions {
404
424
  /** Path the sync refuses to run against, normally the toolkit root. */
405
425
  readonly protectedRoot: string
426
+ /** Writes the coverage record to stdout on every run that exits 0. */
427
+ readonly json?: boolean
406
428
  }
407
429
 
408
430
  /**
@@ -442,6 +464,7 @@ export async function runDomainSync(
442
464
  plan.retired.length === 0
443
465
  ) {
444
466
  logWarn(adapter.missingMessage)
467
+ emitCoverage(options, [])
445
468
  outro()
446
469
  return 0
447
470
  }
@@ -452,6 +475,7 @@ export async function runDomainSync(
452
475
  if (count === 0) {
453
476
  await adapter.onComplete?.(resolved)
454
477
  await recordStamp(adapter, resolved, new Date())
478
+ emitCoverage(options, [])
455
479
  outroSuccess('Everything up to date')
456
480
  return 0
457
481
  }
@@ -464,6 +488,7 @@ export async function runDomainSync(
464
488
  ) {
465
489
  logWarn(policy.message)
466
490
  logInfo(policy.hint)
491
+ emitCoverage(options, [])
467
492
  outro()
468
493
  return 0
469
494
  }
@@ -479,6 +504,7 @@ export async function runDomainSync(
479
504
 
480
505
  if (!shouldApply) {
481
506
  logWarn('Sync cancelled')
507
+ emitCoverage(options, [])
482
508
  outro()
483
509
  return 0
484
510
  }
@@ -487,10 +513,68 @@ export async function runDomainSync(
487
513
  await adapter.onComplete?.(resolved)
488
514
  await recordStamp(adapter, resolved, new Date())
489
515
 
516
+ const coverage = await readAdapterCoverage(adapter, resolved, plan.changes)
517
+ reportCoverage(coverage)
518
+ emitCoverage(options, coverage)
519
+
490
520
  outroSuccess('Sync complete', `(${count} ${adapter.unit})`)
491
521
  return 0
492
522
  }
493
523
 
524
+ async function readAdapterCoverage(
525
+ adapter: SyncAdapter,
526
+ target: string,
527
+ changes: readonly SyncChange[],
528
+ ): Promise<readonly RuleCoverage[]> {
529
+ if (adapter.coverage === undefined) return []
530
+
531
+ try {
532
+ return await adapter.coverage(target, changes)
533
+ } catch (error) {
534
+ logWarn(
535
+ `Coverage unavailable: ${error instanceof Error ? error.message : String(error)}`,
536
+ )
537
+ return []
538
+ }
539
+ }
540
+
541
+ function reportCoverage(coverage: readonly RuleCoverage[]): void {
542
+ if (coverage.length === 0) return
543
+
544
+ logStep('Coverage')
545
+ for (const entry of coverage) {
546
+ logInfo(describeCoverage(entry))
547
+ if (entry.ui) {
548
+ logInfo(' Audit those files with /canon:ux-audit.')
549
+ }
550
+ }
551
+ }
552
+
553
+ function describeCoverage(entry: RuleCoverage): string {
554
+ if (entry.paths.length === 0) return `${entry.rule} (loads everywhere)`
555
+
556
+ const globs = entry.paths.join(', ')
557
+ if (entry.files === undefined) return `${entry.rule} (${globs})`
558
+
559
+ return `${entry.rule} (${globs}): ${plural(entry.files, 'file')}`
560
+ }
561
+
562
+ /** The one stdout write of a `--json` run, so a pipe reads JSON alone. */
563
+ function emitCoverage(
564
+ options: SyncRunOptions,
565
+ coverage: readonly RuleCoverage[],
566
+ ): void {
567
+ if (options.json !== true) return
568
+
569
+ const records = coverage.map((entry) => ({
570
+ rule: entry.rule,
571
+ paths: entry.paths,
572
+ files: entry.files ?? null,
573
+ ui: entry.ui,
574
+ }))
575
+ process.stdout.write(`${JSON.stringify({ coverage: records })}\n`)
576
+ }
577
+
494
578
  /**
495
579
  * Reports in walk order rather than grouped by state, so a long rule tree
496
580
  * reads as a directory listing with drift called out in place.
package/src/sync/stamp.ts CHANGED
@@ -16,9 +16,10 @@ import { readInstalled } from '@/version/installed'
16
16
  *
17
17
  * A stamp written before the standards or snippets install channel closed
18
18
  * still carries a `standards` or `snippets` record, and one written before the
19
- * design install retired carries a `design` record. `isStamp` ignores the key
20
- * and `sortDomains` drops it on the next write, so the target loses a domain
21
- * nothing can refresh rather than losing the whole file.
19
+ * design install retired carries a `design` record. `isStamp` ignores the key,
20
+ * `readStamp` drops it from what it returns, and `sortDomains` drops it from
21
+ * the file on the next write, so the target loses a domain nothing can refresh
22
+ * rather than losing the whole file.
22
23
  */
23
24
  export const STAMP_DOMAINS = ['governance', 'tooling'] as const
24
25
 
@@ -183,12 +184,31 @@ function readStampFile(path: string): Stamp | undefined {
183
184
 
184
185
  try {
185
186
  const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
186
- return isStamp(parsed) ? parsed : undefined
187
+ return isStamp(parsed) ? withoutRetired(parsed) : undefined
187
188
  } catch {
188
189
  return undefined
189
190
  }
190
191
  }
191
192
 
193
+ /**
194
+ * A stamp written before a domain retired still names it until the next write.
195
+ * Filtering here keeps a report on a target that has not synced since from
196
+ * listing a domain nothing can refresh. The file itself is left as it is.
197
+ */
198
+ function withoutRetired(stamp: Stamp): Stamp {
199
+ const domains: Partial<Record<StampDomain, DomainStamp>> = {}
200
+ for (const domain of STAMP_DOMAINS) {
201
+ const record = stamp.domains[domain]
202
+ if (record !== undefined) domains[domain] = record
203
+ }
204
+
205
+ const covers = Array.isArray(stamp.covers)
206
+ ? stamp.covers.filter((domain) => STAMP_DOMAINS.includes(domain))
207
+ : []
208
+
209
+ return { covers, domains }
210
+ }
211
+
192
212
  /** The newest canon release that has written this domain, when one was recorded. */
193
213
  export function stampedVersion(
194
214
  stamp: Stamp | undefined,
@@ -286,7 +306,7 @@ async function putDomain(
286
306
 
287
307
  const path = stampPath(target)
288
308
  await mkdir(dirname(path), { recursive: true })
289
- await writeFile(path, `${JSON.stringify(stamp, null, 2)}\n`)
309
+ await writeFile(path, `${serializeStamp(stamp)}\n`)
290
310
 
291
311
  // The stamp hashes every file but itself, so reshaping it cannot read as
292
312
  // drift. Seeds and goldens stay as copied for the opposite reason: their
@@ -302,6 +322,56 @@ async function putDomain(
302
322
  recordTarget(target, now)
303
323
  }
304
324
 
325
+ const WIDTH = 80
326
+
327
+ /**
328
+ * `JSON.stringify` expands every array one element per line, a shape oxfmt and
329
+ * prettier both reject, so a sync with no formatter to run would leave a
330
+ * target's format gate red. This inlines a string array that fits the default
331
+ * print width and expands one that does not, as both formatters would, and
332
+ * leaves objects expanded, which both preserve. Content and key order are
333
+ * `JSON.stringify`'s own.
334
+ */
335
+ function serializeStamp(value: unknown): string {
336
+ return serialize(value, 0, 0, true)
337
+ }
338
+
339
+ function serialize(
340
+ value: unknown,
341
+ depth: number,
342
+ taken: number,
343
+ last: boolean,
344
+ ): string {
345
+ if (Array.isArray(value) && value.every((item) => typeof item === 'string')) {
346
+ const inline = JSON.stringify(value).replace(/","/g, '", "')
347
+ // A comma follows every entry but the last of its object, and counts
348
+ // against the width as it does for a formatter.
349
+ return taken + inline.length + (last ? 0 : 1) <= WIDTH
350
+ ? inline
351
+ : expand(value, depth)
352
+ }
353
+
354
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
355
+ const entries = Object.entries(value).filter(([, v]) => v !== undefined)
356
+ if (entries.length === 0) return '{}'
357
+
358
+ const pad = ' '.repeat(depth + 1)
359
+ const lines = entries.map(([key, child], index) => {
360
+ const head = `${pad}${JSON.stringify(key)}: `
361
+ return `${head}${serialize(child, depth + 1, head.length, index === entries.length - 1)}`
362
+ })
363
+ return `{\n${lines.join(',\n')}\n${' '.repeat(depth)}}`
364
+ }
365
+
366
+ return JSON.stringify(value)
367
+ }
368
+
369
+ function expand(items: readonly string[], depth: number): string {
370
+ const pad = ' '.repeat(depth + 1)
371
+ const lines = items.map((item) => `${pad}${JSON.stringify(item)}`)
372
+ return `[\n${lines.join(',\n')}\n${' '.repeat(depth)}]`
373
+ }
374
+
305
375
  const commitCache = new Map<string, Promise<string | undefined>>()
306
376
 
307
377
  /**
@@ -1,8 +1,8 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'
3
3
  import { dirname, join, relative } from 'node:path'
4
- import { $ } from 'bun'
5
4
  import { copyPreservingMode } from '@/sync/copy'
5
+ import { type InstallResult, installDevDependencies } from '@/tooling/install'
6
6
  import { rewritesOnInstall, stripSeedMarker } from '@/tooling/seed-marker'
7
7
  import { resolveSurfacePath } from '@/roots/surface'
8
8
  import { mergeSections, pruneSections } from '@/tooling/gitignore'
@@ -165,7 +165,7 @@ export async function pruneGitignore(
165
165
  export async function injectManifest(
166
166
  chain: readonly Manifest[],
167
167
  target: string,
168
- ): Promise<void> {
168
+ ): Promise<InstallResult> {
169
169
  const packagePath = join(target, 'package.json')
170
170
 
171
171
  if (!existsSync(packagePath) && declaresInstalls(chain)) {
@@ -183,7 +183,8 @@ export async function injectManifest(
183
183
 
184
184
  if (missing.length > 0) {
185
185
  logStep('Installing missing dependencies')
186
- await $`bun add -D ${missing}`.cwd(target)
186
+ const installed = await installDevDependencies(missing, target)
187
+ if (!installed.ok) return installed
187
188
  for (const spec of missing) logAdd(spec)
188
189
  }
189
190
 
@@ -202,6 +203,8 @@ export async function injectManifest(
202
203
  await injectGitignore(chain, target)
203
204
 
204
205
  if (!pkg) warnUnreadablePackage(chain, target)
206
+
207
+ return { ok: true }
205
208
  }
206
209
 
207
210
  /**
@@ -0,0 +1,55 @@
1
+ import { execa } from 'execa'
2
+
3
+ export const INSTALL_TIMEOUT_MS = 120_000
4
+ const TAIL_LINES = 20
5
+
6
+ export type InstallResult =
7
+ | { ok: true }
8
+ | {
9
+ ok: false
10
+ reason: 'install-timeout' | 'install-failed'
11
+ message: string
12
+ }
13
+
14
+ /**
15
+ * Runs `bun add -D` for the specs and bounds it, since a registry that accepts
16
+ * a connection and never answers would otherwise hang the caller for good. A
17
+ * stall or a failure comes back as a refusal naming the packages and the tail
18
+ * of the output, never as a thrown error.
19
+ */
20
+ export async function installDevDependencies(
21
+ specs: readonly string[],
22
+ cwd: string,
23
+ timeoutMs: number = INSTALL_TIMEOUT_MS,
24
+ ): Promise<InstallResult> {
25
+ const tried = specs.join(', ')
26
+ const result = await execa('bun', ['add', '-D', ...specs], {
27
+ cwd,
28
+ all: true,
29
+ reject: false,
30
+ stdin: 'ignore',
31
+ timeout: timeoutMs,
32
+ })
33
+
34
+ if (!result.failed) return { ok: true }
35
+
36
+ if (result.timedOut) {
37
+ return {
38
+ ok: false,
39
+ reason: 'install-timeout',
40
+ message: `Install timed out after ${Math.round(timeoutMs / 1000)}s. Tried: ${tried}`,
41
+ }
42
+ }
43
+
44
+ const detail = tail(result.all) || String(result.shortMessage ?? '')
45
+ return {
46
+ ok: false,
47
+ reason: 'install-failed',
48
+ message: `Install failed. Tried: ${tried}${detail === '' ? '' : `\n${detail}`}`,
49
+ }
50
+ }
51
+
52
+ function tail(output: unknown): string {
53
+ if (typeof output !== 'string') return ''
54
+ return output.replace(/\n$/, '').split('\n').slice(-TAIL_LINES).join('\n')
55
+ }
@@ -1,86 +0,0 @@
1
- #!/usr/bin/env bash
2
- set -e
3
- set -o pipefail
4
-
5
- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
- PROJECT_ROOT="${PROJECT_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
7
-
8
- source "$PROJECT_ROOT/scripts/lib/ui.sh"
9
-
10
- ZSHRC="${ZDOTDIR:-$HOME}/.zshrc"
11
- MARKER_OPEN="# >>> canon aliases >>>"
12
- MARKER_CLOSE="# <<< canon aliases <<<"
13
-
14
- check_dependencies() {
15
- command -v bun >/dev/null 2>&1 || log_error "bun is not installed. See https://bun.sh, then re-run."
16
- }
17
-
18
- install_cli() {
19
- log_step "Installing the canon CLI"
20
- bun install --cwd "$PROJECT_ROOT" >/dev/null
21
- bun link --cwd "$PROJECT_ROOT" >/dev/null
22
- log_info "Dependencies installed and canon linked"
23
- }
24
-
25
- alias_block() {
26
- cat <<EOF
27
- $MARKER_OPEN
28
- TOOLKIT="$PROJECT_ROOT"
29
- export CLAUDE_CODE_ENABLE_TODO_TOOLS=1
30
-
31
- alias cl='claude'
32
- alias clr='cl -r'
33
- alias clc='cl -c'
34
- alias clw='cl -w'
35
- alias cls='cl --model sonnet'
36
-
37
- alias clp='claude --plugin-dir \$TOOLKIT/claude'
38
- alias clpc='clp -c'
39
- alias clps='clp --model sonnet'
40
- alias clpa='clp agents'
41
- alias clpac='clpa --cwd .'
42
- $MARKER_CLOSE
43
- EOF
44
- }
45
-
46
- install_aliases() {
47
- log_step "Installing Claude Code shell aliases"
48
- log_info "This step manages a marked alias block in $ZSHRC, the only file outside the repo this script writes."
49
- log_info "Delete the block and its two marker comments to remove the aliases. Re-running never adds a second copy."
50
-
51
- if [ -f "$ZSHRC" ] && grep -qF "$MARKER_OPEN" "$ZSHRC"; then
52
- log_info "Aliases already present in $ZSHRC, skipping"
53
- return
54
- fi
55
-
56
- if [ -f "$ZSHRC" ] && grep -qE "^alias clp=" "$ZSHRC"; then
57
- log_warn "An unmanaged clp alias is already in $ZSHRC. Leaving it untouched."
58
- log_warn "Remove it and re-run to switch to the managed block."
59
- return
60
- fi
61
-
62
- printf '\n%s\n' "$(alias_block)" >>"$ZSHRC"
63
- log_add "Appended canon alias block to $ZSHRC"
64
- log_info "Run 'source $ZSHRC' or open a new shell to load them"
65
- }
66
-
67
- verify_cli() {
68
- log_step "Verifying the install"
69
- if canon --help >/dev/null 2>&1; then
70
- log_info "canon resolves on PATH"
71
- else
72
- log_warn "canon not on PATH yet. Ensure \$BUN_INSTALL/bin is exported, then re-open your shell."
73
- fi
74
- }
75
-
76
- main() {
77
- open_timeline "canon bootstrap"
78
- trap close_timeline EXIT
79
-
80
- check_dependencies
81
- install_cli
82
- install_aliases
83
- verify_cli
84
- }
85
-
86
- main "$@"