@erclx/canon 5.57.0 → 5.59.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 (40) 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/context-fold/references/context.md +1 -1
  5. package/claude/skills/document-health/REQUIREMENT.md +2 -2
  6. package/claude/skills/document-health/SKILL.md +14 -0
  7. package/claude/skills/document-health/references/axes.md +12 -0
  8. package/claude/skills/document-repair/REQUIREMENT.md +6 -1
  9. package/claude/skills/document-repair/SKILL.md +21 -0
  10. package/claude/skills/plan-feature/SKILL.md +1 -1
  11. package/claude/skills/target-update/SKILL.md +5 -1
  12. package/docs/agents/context-audit.md +1 -1
  13. package/docs/agents/install-and-sync.md +1 -1
  14. package/docs/agents/migrate.md +6 -0
  15. package/docs/target/sync.md +3 -1
  16. package/docs/workflow/operating-model.md +1 -1
  17. package/governance/rules/code/routing.md +5 -1
  18. package/governance/rules/ui/design-taste.md +4 -0
  19. package/package.json +3 -3
  20. package/scripts/core/bootstrap.ts +120 -0
  21. package/scripts/core/install-check.ts +193 -0
  22. package/src/cli/retired.ts +63 -0
  23. package/src/cli/ui.ts +3 -5
  24. package/src/cli.ts +8 -2
  25. package/src/commands/gov.ts +8 -2
  26. package/src/commands/migrate.ts +7 -1
  27. package/src/commands/pr.ts +10 -1
  28. package/src/context/audit.ts +26 -1
  29. package/src/gov/adapter.ts +2 -0
  30. package/src/gov/coverage.ts +62 -0
  31. package/src/migrate/callers.ts +192 -0
  32. package/src/migrate/status.ts +11 -3
  33. package/src/pr/merge.ts +77 -1
  34. package/src/pr/rest.ts +1 -0
  35. package/src/sandbox/expect.ts +1 -1
  36. package/src/sync/engine.ts +84 -0
  37. package/src/sync/stamp.ts +51 -1
  38. package/scripts/core/bootstrap.sh +0 -86
  39. package/scripts/core/install-check.sh +0 -113
  40. package/scripts/lib/ui.sh +0 -45
@@ -0,0 +1,192 @@
1
+ import { readdir, readFile, stat } from 'node:fs/promises'
2
+ import { isAbsolute, join, relative } from 'node:path'
3
+ import { RETIRED_VERBS, type RetiredVerb } from '@/cli/retired'
4
+
5
+ export interface RetiredCaller {
6
+ /** Project-relative path of the file holding the call. */
7
+ readonly file: string
8
+ /** One-based line of the call. */
9
+ readonly line: number
10
+ /** The call as typed, such as `canon design render`. */
11
+ readonly verb: string
12
+ readonly release: string
13
+ readonly movedTo: string
14
+ }
15
+
16
+ const WORKFLOWS = '.github/workflows'
17
+ const SCRIPTS = 'scripts'
18
+ const CODE_FILE = /\.(?:sh|bash|zsh|ts|mts|cts|js|mjs|cjs)$/
19
+ const WORKFLOW_FILE = /\.ya?ml$/
20
+ // A script path a command line names, such as `bun tools/sync.ts`.
21
+ const NAMED_FILE = /[\w./-]+\.(?:sh|bash|zsh|ts|mts|cts|js|mjs|cjs)\b/g
22
+ const MAX_FILE_BYTES = 1_000_000
23
+
24
+ function escape(word: string): string {
25
+ return word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
26
+ }
27
+
28
+ /**
29
+ * The two call shapes, a command line and an argument list. A command line may
30
+ * go through `bunx -y` or `npx`, and an argument list carries `canon` as its
31
+ * own quoted element ahead of the verb words.
32
+ */
33
+ function callPatterns(verb: RetiredVerb): readonly RegExp[] {
34
+ const words = verb.path.map(escape)
35
+ const shell = words.join('\\s+')
36
+ const list = words.map((word) => `['"]${word}['"]`).join('\\s*,\\s*')
37
+ return [
38
+ new RegExp(`(?:^|[\\s;&|(\`"'])canon\\s+${shell}(?![\\w-])`),
39
+ new RegExp(`['"]canon['"]\\s*,\\s*(?:\\[\\s*)?${list}`),
40
+ ]
41
+ }
42
+
43
+ const PATTERNS = RETIRED_VERBS.map((verb) => ({
44
+ verb,
45
+ patterns: callPatterns(verb),
46
+ }))
47
+
48
+ function isComment(line: string): boolean {
49
+ const trimmed = line.trimStart()
50
+ return trimmed.startsWith('#') || trimmed.startsWith('//')
51
+ }
52
+
53
+ function scanText(file: string, text: string): RetiredCaller[] {
54
+ const callers: RetiredCaller[] = []
55
+ text.split('\n').forEach((content, index) => {
56
+ if (isComment(content)) return
57
+ for (const { verb, patterns } of PATTERNS) {
58
+ if (!patterns.some((pattern) => pattern.test(content))) continue
59
+ callers.push({
60
+ file,
61
+ line: index + 1,
62
+ verb: `canon ${verb.path.join(' ')}`,
63
+ release: verb.release,
64
+ movedTo: verb.movedTo,
65
+ })
66
+ }
67
+ })
68
+ return callers
69
+ }
70
+
71
+ async function readText(root: string, file: string): Promise<string | null> {
72
+ try {
73
+ const path = join(root, file)
74
+ if ((await stat(path)).size > MAX_FILE_BYTES) return null
75
+ return await readFile(path, 'utf8')
76
+ } catch {
77
+ return null
78
+ }
79
+ }
80
+
81
+ async function listFiles(
82
+ root: string,
83
+ dir: string,
84
+ accepts: RegExp,
85
+ ): Promise<string[]> {
86
+ try {
87
+ const entries = await readdir(join(root, dir), { withFileTypes: true })
88
+ const nested = await Promise.all(
89
+ entries.map(async (entry) => {
90
+ const path = `${dir}/${entry.name}`
91
+ if (entry.isDirectory()) {
92
+ return entry.name === 'node_modules'
93
+ ? []
94
+ : listFiles(root, path, accepts)
95
+ }
96
+ return entry.isFile() && accepts.test(entry.name) ? [path] : []
97
+ }),
98
+ )
99
+ return nested.flat()
100
+ } catch {
101
+ return []
102
+ }
103
+ }
104
+
105
+ async function readScripts(root: string): Promise<Record<string, string>> {
106
+ const text = await readText(root, 'package.json')
107
+ if (text === null) return {}
108
+ try {
109
+ const scripts = (JSON.parse(text) as { scripts?: unknown }).scripts
110
+ if (typeof scripts !== 'object' || scripts === null) return {}
111
+ return Object.fromEntries(
112
+ Object.entries(scripts).filter(
113
+ (entry): entry is [string, string] => typeof entry[1] === 'string',
114
+ ),
115
+ )
116
+ } catch {
117
+ return {}
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Reads the script values rather than the whole manifest, so a description
123
+ * naming a verb never reports. The line is the script's key, since JSON keeps
124
+ * no line for a value.
125
+ */
126
+ async function scanPackageScripts(
127
+ root: string,
128
+ scripts: Record<string, string>,
129
+ ): Promise<RetiredCaller[]> {
130
+ const lines = ((await readText(root, 'package.json')) ?? '').split('\n')
131
+ return Object.entries(scripts).flatMap(([name, command]) => {
132
+ const key = `${JSON.stringify(name)}:`
133
+ const line = lines.findIndex((text) => text.includes(key)) + 1
134
+ return scanText('package.json', command).map((caller) => ({
135
+ ...caller,
136
+ line: Math.max(line, 1),
137
+ }))
138
+ })
139
+ }
140
+
141
+ function namedFiles(commandLines: readonly string[]): string[] {
142
+ return commandLines.flatMap((line) => line.match(NAMED_FILE) ?? [])
143
+ }
144
+
145
+ function insideRoot(root: string, file: string): boolean {
146
+ if (isAbsolute(file)) return false
147
+ const path = relative(root, join(root, file))
148
+ return path !== '' && !path.startsWith('..')
149
+ }
150
+
151
+ /**
152
+ * Finds the places a project calls a retired verb. It reads only the workflow
153
+ * folder, the `package.json` scripts, `scripts/`, and the files those scripts
154
+ * and workflow steps name, and it runs nothing. A markdown file is never read,
155
+ * so prose naming a verb never reports.
156
+ */
157
+ export async function scanRetiredCallers(
158
+ root: string,
159
+ ): Promise<RetiredCaller[]> {
160
+ const scripts = await readScripts(root)
161
+ const workflows = await listFiles(root, WORKFLOWS, WORKFLOW_FILE)
162
+ const ownScripts = await listFiles(root, SCRIPTS, CODE_FILE)
163
+ const workflowTexts = await Promise.all(
164
+ workflows.map(async (file) => ({
165
+ file,
166
+ text: (await readText(root, file)) ?? '',
167
+ })),
168
+ )
169
+
170
+ const named = namedFiles([
171
+ ...Object.values(scripts),
172
+ ...workflowTexts.flatMap(({ text }) => text.split('\n')),
173
+ ])
174
+ .map((file) => file.replace(/^\.\//, ''))
175
+ .filter((file) => insideRoot(root, file))
176
+
177
+ const codeFiles = [...new Set([...ownScripts, ...named])]
178
+ const codeTexts = await Promise.all(
179
+ codeFiles.map(async (file) => ({
180
+ file,
181
+ text: await readText(root, file),
182
+ })),
183
+ )
184
+
185
+ return [
186
+ ...(await scanPackageScripts(root, scripts)),
187
+ ...workflowTexts.flatMap(({ file, text }) => scanText(file, text)),
188
+ ...codeTexts.flatMap(({ file, text }) =>
189
+ text === null ? [] : scanText(file, text),
190
+ ),
191
+ ]
192
+ }
@@ -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
@@ -286,7 +286,7 @@ async function putDomain(
286
286
 
287
287
  const path = stampPath(target)
288
288
  await mkdir(dirname(path), { recursive: true })
289
- await writeFile(path, `${JSON.stringify(stamp, null, 2)}\n`)
289
+ await writeFile(path, `${serializeStamp(stamp)}\n`)
290
290
 
291
291
  // The stamp hashes every file but itself, so reshaping it cannot read as
292
292
  // drift. Seeds and goldens stay as copied for the opposite reason: their
@@ -302,6 +302,56 @@ async function putDomain(
302
302
  recordTarget(target, now)
303
303
  }
304
304
 
305
+ const WIDTH = 80
306
+
307
+ /**
308
+ * `JSON.stringify` expands every array one element per line, a shape oxfmt and
309
+ * prettier both reject, so a sync with no formatter to run would leave a
310
+ * target's format gate red. This inlines a string array that fits the default
311
+ * print width and expands one that does not, as both formatters would, and
312
+ * leaves objects expanded, which both preserve. Content and key order are
313
+ * `JSON.stringify`'s own.
314
+ */
315
+ function serializeStamp(value: unknown): string {
316
+ return serialize(value, 0, 0, true)
317
+ }
318
+
319
+ function serialize(
320
+ value: unknown,
321
+ depth: number,
322
+ taken: number,
323
+ last: boolean,
324
+ ): string {
325
+ if (Array.isArray(value) && value.every((item) => typeof item === 'string')) {
326
+ const inline = JSON.stringify(value).replace(/","/g, '", "')
327
+ // A comma follows every entry but the last of its object, and counts
328
+ // against the width as it does for a formatter.
329
+ return taken + inline.length + (last ? 0 : 1) <= WIDTH
330
+ ? inline
331
+ : expand(value, depth)
332
+ }
333
+
334
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
335
+ const entries = Object.entries(value).filter(([, v]) => v !== undefined)
336
+ if (entries.length === 0) return '{}'
337
+
338
+ const pad = ' '.repeat(depth + 1)
339
+ const lines = entries.map(([key, child], index) => {
340
+ const head = `${pad}${JSON.stringify(key)}: `
341
+ return `${head}${serialize(child, depth + 1, head.length, index === entries.length - 1)}`
342
+ })
343
+ return `{\n${lines.join(',\n')}\n${' '.repeat(depth)}}`
344
+ }
345
+
346
+ return JSON.stringify(value)
347
+ }
348
+
349
+ function expand(items: readonly string[], depth: number): string {
350
+ const pad = ' '.repeat(depth + 1)
351
+ const lines = items.map((item) => `${pad}${JSON.stringify(item)}`)
352
+ return `[\n${lines.join(',\n')}\n${' '.repeat(depth)}]`
353
+ }
354
+
305
355
  const commitCache = new Map<string, Promise<string | undefined>>()
306
356
 
307
357
  /**
@@ -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 "$@"