rvlib-shipkit 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/package.json +2 -1
  3. package/src/assets/kits/identity-kit.ts +7 -3
  4. package/src/assets/run-assets.ts +18 -4
  5. package/src/checks/SK004-gitignore-policy.check.ts +11 -6
  6. package/src/checks/SK013-one-way-in-policy.check.ts +13 -8
  7. package/src/cli/cmd_CacheAudit.ts +3 -2
  8. package/src/cli/cmd_Calls.ts +53 -0
  9. package/src/cli/cmd_Ci.ts +4 -3
  10. package/src/cli/cmd_Commands.ts +158 -43
  11. package/src/cli/cmd_Fleet.ts +3 -1
  12. package/src/cli/cmd_GlobalProcesses.ts +7 -2
  13. package/src/cli/cmd_Shot.ts +63 -0
  14. package/src/cli/cmd_Start.ts +8 -4
  15. package/src/cli/cmd_Trailer.ts +108 -0
  16. package/src/cli/kit/builtins.ts +40 -0
  17. package/src/cli/kit/calls.ts +177 -0
  18. package/src/cli/kit/families.ts +67 -0
  19. package/src/cli/kit/index.ts +8 -0
  20. package/src/cli/kit/pages-check.ts +36 -0
  21. package/src/cli/kit/project-cli.ts +73 -0
  22. package/src/cli/kit/verb.ts +157 -0
  23. package/src/cli/list-flag.ts +3 -0
  24. package/src/cli.ts +13 -2
  25. package/src/core/layout.ts +10 -2
  26. package/src/core/migrate-layout.ts +31 -8
  27. package/src/core/one-way-in-policy.ts +12 -4
  28. package/src/mv/mv-md.ts +2 -1
  29. package/src/reflect/prompt-corpus.ts +5 -0
  30. package/src/scaffold/cli-skeleton.ts +149 -0
  31. package/src/scaffold/files/agent-index.md +2 -2
  32. package/src/scaffold/files/tsconfig.json +3 -0
  33. package/src/scaffold/files/verify-skill.md +1 -1
  34. package/src/scaffold/project-scaffold.ts +90 -12
  35. package/src/shipkit-files.ts +8 -4
  36. package/src/shot/client.ts +67 -0
  37. package/src/shot/clip-encoder.ts +146 -0
  38. package/src/shot/dom-clip.ts +124 -0
  39. package/src/shot/serve-shot.ts +54 -0
  40. package/src/shot/shooter.ts +343 -0
  41. package/src/shot/shot-ask.ts +189 -0
  42. package/src/shot/shot.shipkit.ts +14 -0
  43. package/src/smoke/run-smoke.ts +2 -2
  44. package/src/start-repo.ts +29 -14
  45. package/src/suite/judge-suites.ts +56 -10
  46. package/src/suite/select.ts +2 -5
  47. package/src/suite/suite-cli.ts +3 -2
  48. package/src/trailer/adapter.ts +23 -0
  49. package/src/trailer/build.ts +344 -0
  50. package/src/trailer/ffmpeg.ts +50 -0
  51. package/src/trailer/footage.ts +104 -0
  52. package/src/trailer/grammar.ts +915 -0
  53. package/src/trailer/graph.ts +323 -0
  54. package/src/trailer/load-unit.ts +14 -0
  55. package/src/trailer/look.ts +110 -0
  56. package/src/trailer/plan.ts +133 -0
  57. package/src/trailer/stitch.ts +150 -0
  58. package/src/trailer/titles.ts +274 -0
  59. package/src/trailer/url.ts +108 -0
  60. package/src/trailer/vertical-draw.ts +286 -0
  61. package/src/trailer/vertical.ts +148 -0
  62. package/src/trailer/vocab.ts +40 -0
  63. package/src/ts/rename.ts +9 -0
@@ -172,6 +172,8 @@ export type SwitchDeps = {
172
172
  /** the longest a switch is followed: a bring-up polls its health for minutes */
173
173
  export const SWITCH_WAIT_MS = 10 * 60_000
174
174
  const SWITCH_POLL_MS = 1_000
175
+ /** how long the state the switch left counts as pm not having acted yet */
176
+ export const SWITCH_GRACE_MS = 30_000
175
177
 
176
178
  /** flip a global process through pm, then follow pm's state until its probe
177
179
  * settles it: 0 once it is up (or down for stop), 1 otherwise. The launcher's
@@ -183,7 +185,8 @@ export async function switchProcess(id: string, act: SwitchAct, d: SwitchDeps, w
183
185
  return 1
184
186
  }
185
187
  const want = act === 'stop' ? 'down' : 'up'
186
- const end = d.now() + waitMs
188
+ const start = d.now()
189
+ const end = start + waitMs
187
190
  let said = ''
188
191
  for (;;) {
189
192
  const v = await d.view(id)
@@ -195,7 +198,9 @@ export async function switchProcess(id: string, act: SwitchAct, d: SwitchDeps, w
195
198
  said = v.status
196
199
  d.log(`${id}: ${v.status}`)
197
200
  }
198
- const moving = v.status === 'starting' || v.status === 'stopping' || v.want !== want
201
+ // the state it is leaving, read just after the switch, is pm not there yet
202
+ const leaving = v.status === (want === 'up' ? 'down' : 'up') && d.now() - start < SWITCH_GRACE_MS
203
+ const moving = v.status === 'starting' || v.status === 'stopping' || v.want !== want || leaving
199
204
  if (!moving) {
200
205
  if (v.status === want) return 0
201
206
  d.log(`FAIL ${id} is ${v.status}, not ${want}${v.note == null ? '' : `: ${v.note}`}`)
@@ -0,0 +1,63 @@
1
+ // sk shot: a png of any url, or a clip with --video, through the machine's
2
+ // one warm browser (shot/client.ts). A repo's own CLI wraps it with its setup
3
+ // flags; the trailer's `url` film calls the same functions.
4
+ import { relative } from 'path'
5
+ import { Command, Option } from 'clipanion'
6
+ import { clipUrl, shotUrl, type ShotResult } from '../shot/client.ts'
7
+ import { askOf, isClip } from '../shot/shot-ask.ts'
8
+
9
+ export const renderShot = (r: ShotResult, cwd: string): string[] => {
10
+ const phases = Object.entries(r.phases)
11
+ .map(([k, v]) => `${k} ${v}`)
12
+ .join(', ')
13
+ const out = relative(cwd, r.out)
14
+ const lines = [`ok ${out.startsWith('..') ? r.out : out} in ${r.ms} ms (${phases}; ${r.via}) ${r.note}`]
15
+ for (const e of r.errors.slice(0, 10)) lines.push(`WARN page ${e}`)
16
+ if (r.errors.length > 10) lines.push(`WARN page ${r.errors.length - 10} more errors`)
17
+ return lines
18
+ }
19
+
20
+ export class ShotCommand extends Command {
21
+ static override paths = [['shot']]
22
+
23
+ static override usage = Command.Usage({
24
+ category: 'Serve',
25
+ description: 'a png of any url through the warm browser, or an mp4 clip with --video',
26
+ details:
27
+ 'one warm headless chromium for the whole machine (the `shot` global process, started when down). A still reuses the warm page when it already shows the url, so a warm shot answers in about 0.1 s. A page with `<meta name="shot-ready" content="wait">` is shot once it sets content="ready"; any other page --wait ms after its load. --video films a page that is a canvas through its WebCodecs encoder (H.264 muxed with no second encode), and any other page as chromium paints it (its screencast, encoded by ffmpeg as the frames arrive); --dom films the page even when a canvas fills it',
28
+ examples: [
29
+ ['a png of a running dev server', 'sk shot localhost:5173/'],
30
+ ['a phone screen, at the pixel ratio of a phone', 'sk shot :5173/play --viewport 390,844 --dpr 3 --out shots/phone.png'],
31
+ ['a 3 s clip', 'sk shot :4808/ --video 3 --out clips/walk.mp4'],
32
+ ['a clip of a whole web app page, canvas or not', 'sk shot :5173/settings --video 3 --dom'],
33
+ ],
34
+ })
35
+
36
+ url = Option.String({ required: true, name: 'url' })
37
+ viewport = Option.String('--viewport', { description: 'w,h in css pixels (default 1280,720)' })
38
+ dpr = Option.String('--dpr', { description: 'device pixel ratio (default 1)' })
39
+ out = Option.String('--out', { description: 'the file (default .shipkit/tmp/shots/<url>.png, or .mp4 with --video)' })
40
+ reload = Option.Boolean('--reload', false, { description: 'load the page again even when the warm page shows this url' })
41
+ wait = Option.String('--wait', { description: 'ms after load for a page with no ready mark (default 300)' })
42
+ video = Option.String('--video', { description: 'seconds of clip: an mp4 of the page (its canvas when a canvas fills it)' })
43
+ fps = Option.String('--fps', { description: 'clip frames per second (default 30)' })
44
+ long = Option.Boolean('--long', false, { description: 'allow a clip past 30 s' })
45
+ dom = Option.Boolean('--dom', false, { description: 'clip the page itself even when a canvas fills it' })
46
+ json = Option.Boolean('--json', false, { description: 'one json line: out, ms, phases, errors, note, via' })
47
+
48
+ async execute(): Promise<number> {
49
+ const cwd = process.cwd()
50
+ const err = (l: string) => this.context.stderr.write(`${l}\n`)
51
+ try {
52
+ const a = askOf({ url: this.url, viewport: this.viewport, dpr: this.dpr, out: this.out, reload: this.reload, wait: this.wait, video: this.video, fps: this.fps, long: this.long, dom: this.dom }, cwd)
53
+ const r = isClip(a) ? await clipUrl(a, err) : await shotUrl(a, err)
54
+ if (this.json) this.context.stdout.write(`${JSON.stringify(r)}\n`)
55
+ else for (const l of renderShot(r, cwd)) this.context.stdout.write(`${l}\n`)
56
+ return 0
57
+ } catch (e) {
58
+ const msg = e instanceof Error ? e.message : String(e)
59
+ err(msg.startsWith('FAIL') ? msg : `FAIL shot: ${msg}`)
60
+ return 1
61
+ }
62
+ }
63
+ }
@@ -1,8 +1,7 @@
1
1
  // shipkit start — ensure <root>/<name> exists (+ a CLAUDE.md include),
2
2
  // printing the dir on stdout so a shell alias can cd there (a child process
3
- // can't cd its parent). Thin clipanion surface; logic in start-repo.ts.
3
+ // can't cd its parent). Thin clipanion surface; logic in start-repo.ts, imported when it runs.
4
4
  import { Command, Option } from 'clipanion'
5
- import { startRepo } from '../start-repo.ts'
6
5
 
7
6
  export class StartCommand extends Command {
8
7
  static override paths = [['start']]
@@ -10,13 +9,14 @@ export class StartCommand extends Command {
10
9
  static override usage = Command.Usage({
11
10
  category: 'Setup',
12
11
  description: 'create or enter ~/dev/<name>, ready to work in: a private github repo and a push unless --no-github',
13
- details: 'create (or enter) ~/dev/<name> from ANY cwd, READY to work in. On any dir with no commit history it writes the day-one shape (package.json + src/index.ts + lint/fmt/typecheck, tsconfig, .vscode tasks + settings, the verify skill, an agent/ wiki, .shipkit/reflections), installs the toolchain, wires shipkit (repo.config.ts via init), and runs the whole bootstrap chain: git init, first commit, PRIVATE github repo, push. Loose files ride in that first commit, and only a repo with real history is left as it is. Writes are create-only for files, additive for package.json scripts and .gitignore. CLAUDE.md is the two-line house template on its own (HOUSE_INCLUDE in src/start-repo.ts: the base wiki include, then `@./agent/index.md`, and --include overrides it), applied only on a machine where the house index exists, and the agent route lands last whenever the repo has agent/index.md. The house lines also opt the repo into the .claude/ policy files (a pre-approved shell allowlist + the attribution silencer), so a start with no house widens no agent permissions. Prints the dir on stdout so a shell alias can cd there. cwd-only sibling: `shipkit init`',
12
+ details: 'create (or enter) ~/dev/<name> from ANY cwd, READY to work in. On any dir with no commit history it writes the day-one shape (package.json + src/index.ts + the project CLI on the kit (src/cli.ts, src/cli/cmd_Dev.ts, the cli script and a bin of the repo name) + lint/fmt/typecheck, tsconfig, .vscode tasks + settings, the verify skill, an agent/ wiki, .shipkit/reflections), installs the toolchain, wires shipkit (repo.config.ts via init), and runs the whole bootstrap chain: git init, first commit, PRIVATE github repo, push. Loose files ride in that first commit, and only a repo with real history is left as it is. Writes are create-only for files, additive for package.json scripts and .gitignore. CLAUDE.md is the two-line house template on its own (HOUSE_INCLUDE in src/start-repo.ts: the base wiki include, then `@./agent/index.md`, and --include overrides it), applied only on a machine where the house index exists, and the agent route lands last whenever the repo has agent/index.md. The house lines also opt the repo into the .claude/ policy files (a pre-approved shell allowlist + the attribution silencer), so a start with no house widens no agent permissions. Prints the dir on stdout so a shell alias can cd there. cwd-only sibling: `shipkit init`',
14
13
  examples: [
15
14
  ['create or enter ~/dev/pong: dir, package.json, shipkit wiring, git, first commit, private github repo, push, the two-line CLAUDE.md', 'sk start pong'],
16
15
  ['another house: replace the include lines', "sk start pong --include '@~/wiki/index.md' --include '@./agent/index.md'"],
17
16
  ['adopt a pre-existing repo from anywhere', 'sk start my-old-project'],
18
17
  ['force the private github repo for a folder that was already there', 'sk start old-notes --github'],
19
18
  ['local git and the first commit, never touch github', 'sk start pong --no-github'],
19
+ ['give an existing repo its project CLI (a started repo has one)', 'sk start my-old-project --cli'],
20
20
  ],
21
21
  })
22
22
 
@@ -27,7 +27,11 @@ export class StartCommand extends Command {
27
27
  skip = Option.Array('--skip', { description: 'repo names shipkit never touches: no CLAUDE.md include, no wiring. Omitted: the house list (HOUSE_SKIP in src/start-repo.ts)' })
28
28
  github = Option.Boolean('--github', { description: 'ON by default when a repo is being STARTED (a dir this run created, or one with a package.json): it gets a PRIVATE github repo wired as origin, and the first commit is pushed to it. Pass --github to force it for a pre-existing folder, --no-github to keep everything local, the first commit included (SHIPKIT_NO_GITHUB=1 does it for every run)' })
29
29
 
30
+ withCli = Option.Boolean('--cli', false, { description: 'on a repo with history: write the project CLI skeleton (src/cli.ts on the kit, an example family, package.json cli and bin), refused when the repo already has a CLI. A started repo always gets it' })
31
+
30
32
  async execute(): Promise<number> {
31
- return startRepo(this.name, { root: this.root, include: this.include, alsoOk: this.alsoOk, skip: this.skip, github: this.github })
33
+ // loaded here, so the scaffold and the git chain cost nothing to every other command
34
+ const { startRepo } = await import('../start-repo.ts')
35
+ return startRepo(this.name, { root: this.root, include: this.include, alsoOk: this.alsoOk, skip: this.skip, github: this.github, cli: this.withCli })
32
36
  }
33
37
  }
@@ -0,0 +1,108 @@
1
+ // sk trailer: a cut's .trailer file → its mp4, filming only the takes whose film line changed (trailer/build.ts).
2
+ // Every module loads inside execute: the cli registers this file on every `sk` call
3
+ import { Command, Option } from 'clipanion'
4
+
5
+ const DETAILS = `the trailer language: shots with a film line each (clip <file>, image <file>, card, run "<cmd {out}>", url <route> through the shot service, or a film row of the project CLI), titles in the brand's look, cuts, sections in bars, music, sfx and voice; a \`vertical\` header finishes a short with the brand's lockup, watermark and end card. Outputs land in trailer/<trailer>/out/. Page: wiki build/trailer.md`
6
+
7
+ /** --refilm alone: every stale take; --refilm a,b: those shots */
8
+ const refilmOf = (v: string | boolean | undefined): 'stale' | ReadonlySet<string> | null => (v === undefined || v === false ? null : v === true ? 'stale' : new Set(v.split(',')))
9
+
10
+ async function run(context: { stdout: NodeJS.WritableStream; stderr: NodeJS.WritableStream }, body: (say: (l: string) => void, root: string, cwd: string) => Promise<number>): Promise<number> {
11
+ const say = (l: string): void => void context.stdout.write(`${l}\n`)
12
+ const { Refused } = await import('../trailer/build.ts')
13
+ const { findRepoRoot } = await import('../ts/config.ts')
14
+ const cwd = process.cwd()
15
+ try {
16
+ return await body(say, findRepoRoot(cwd), cwd)
17
+ } catch (e) {
18
+ context.stderr.write(`FAIL: ${e instanceof Refused ? `trailer: ${e.message}` : e instanceof Error ? (e.stack ?? e.message) : String(e)}\n`)
19
+ return 1
20
+ }
21
+ }
22
+
23
+ export class TrailerCommand extends Command {
24
+ static override paths = [['trailer']]
25
+
26
+ static override usage = Command.Usage({
27
+ category: 'Trailer',
28
+ description: 'build a cut from its .trailer: film what is missing, stitch, strip, montage',
29
+ details: DETAILS,
30
+ examples: [
31
+ ['the one cut under trailer/', 'sk trailer'],
32
+ ['one cut by its file', 'sk trailer trailer/launch/main.trailer'],
33
+ ['film again the takes of two shots', 'sk trailer --refilm intro,menu'],
34
+ ['encoded by x264 for a release', 'sk trailer --final'],
35
+ ],
36
+ })
37
+
38
+ file = Option.String({ required: false })
39
+ refilm = Option.String('--refilm', { tolerateBoolean: true, description: 'film again: every stale take, or the named shots (a,b)' })
40
+ final = Option.Boolean('--final', false, { description: 'encode with x264 for a release (default: the fast draft encoder)' })
41
+
42
+ async execute(): Promise<number> {
43
+ return run(this.context, async (say, root, cwd) => {
44
+ const b = await import('../trailer/build.ts')
45
+ const l = b.load(b.trailerFile(this.file, root, cwd), root)
46
+ if (!b.sayErrors(l, say, cwd)) return 1
47
+ await b.build(l, { refilm: refilmOf(this.refilm), final: this.final, say, cwd })
48
+ return 0
49
+ })
50
+ }
51
+ }
52
+
53
+ export class TrailerCheckCommand extends Command {
54
+ static override paths = [['trailer', 'check']]
55
+
56
+ static override usage = Command.Usage({
57
+ category: 'Trailer',
58
+ description: 'parse and check a .trailer: every error at its line, the timeline, each take\'s state',
59
+ details: DETAILS,
60
+ examples: [
61
+ ['the one cut under trailer/', 'sk trailer check'],
62
+ ['one cut by its folder', 'sk trailer check trailer/launch'],
63
+ ],
64
+ })
65
+
66
+ file = Option.String({ required: false })
67
+
68
+ async execute(): Promise<number> {
69
+ return run(this.context, async (say, root, cwd) => {
70
+ const t0 = performance.now()
71
+ const b = await import('../trailer/build.ts')
72
+ const l = b.load(b.trailerFile(this.file, root, cwd), root)
73
+ if (!b.sayErrors(l, say, cwd)) return 1
74
+ const { takesOf, worldStamp } = await import('../trailer/footage.ts')
75
+ for (const line of b.timeline(l, takesOf(l.t, root), worldStamp(root), cwd)) say(line)
76
+ say(`ok: checked in ${((performance.now() - t0) / 1000).toFixed(2)} s`)
77
+ return 0
78
+ })
79
+ }
80
+ }
81
+
82
+ export class TrailerShotCommand extends Command {
83
+ static override paths = [['trailer', 'shot']]
84
+
85
+ static override usage = Command.Usage({
86
+ category: 'Trailer',
87
+ description: 'one shot alone with its titles, no sound, and its nine frames on one png',
88
+ details: DETAILS,
89
+ examples: [
90
+ ['a shot of the one cut', 'sk trailer shot intro'],
91
+ ['a shot of a named cut, its take filmed again', 'sk trailer shot intro trailer/launch --refilm intro'],
92
+ ],
93
+ })
94
+
95
+ name = Option.String({ required: true })
96
+ file = Option.String({ required: false })
97
+ refilm = Option.String('--refilm', { tolerateBoolean: true, description: 'film again: every stale take, or the named shots (a,b)' })
98
+
99
+ async execute(): Promise<number> {
100
+ return run(this.context, async (say, root, cwd) => {
101
+ const b = await import('../trailer/build.ts')
102
+ const l = b.load(b.trailerFile(this.file, root, cwd), root)
103
+ if (!b.sayErrors(l, say, cwd)) return 1
104
+ await b.previewShot(l, this.name, { refilm: refilmOf(this.refilm), final: false, say, cwd })
105
+ return 0
106
+ })
107
+ }
108
+ }
@@ -0,0 +1,40 @@
1
+ // the commands every project CLI gets for free: its docs pages and its call
2
+ // log. They are shipkit's own `commands` and `calls` classes, given the
3
+ // project's name in their examples and the project's root for the log.
4
+ import { Command } from 'clipanion'
5
+ import { CallsCommand } from '../cmd_Calls.ts'
6
+ import { CommandsCommand } from '../cmd_Commands.ts'
7
+ import { callsFile } from './calls.ts'
8
+ import type { CommandClass, KitContext } from './verb.ts'
9
+
10
+ export function builtinCommands(bin: string): { docs: CommandClass[]; calls: CommandClass[] } {
11
+ class ProjectCommands extends CommandsCommand {
12
+ static override usage = Command.Usage({
13
+ category: 'Docs',
14
+ description: `every ${bin} command as markdown or json; --out writes the commands page`,
15
+ details: `the registry described by itself. --out rewrites the block between the ${bin}:commands markers, --essentials rewrites the everyday page whole, --check fails while a page lags the registry or a row lacks its category, description or example. --json is how another tool reads this registry.`,
16
+ examples: [
17
+ ['the markdown', `${bin} commands`],
18
+ ['as json, for another tool', `${bin} commands --json`],
19
+ ['write both agent pages', `${bin} commands --out agent/${bin}.md --essentials agent/${bin}-essentials.md`],
20
+ ['fail when a page is stale', `${bin} commands --out agent/${bin}.md --essentials agent/${bin}-essentials.md --check`],
21
+ ],
22
+ })
23
+ static override shapeRule = true
24
+ }
25
+ class ProjectCalls extends CallsCommand {
26
+ static override usage = Command.Usage({
27
+ category: 'Docs',
28
+ description: `every ${bin} run logged with its time: per command how often, how long, the slowest`,
29
+ details: `reads .shipkit/logs/${bin}-calls.jsonl under the repo root. A run with no end line was killed or still runs. ${bin.toUpperCase()}_LOG=off writes nothing.`,
30
+ examples: [
31
+ ['what agents waited on today', `${bin} calls --since 1d`],
32
+ ['one family', `${bin} calls --cmd <family>`],
33
+ ],
34
+ })
35
+ override logFile(): string {
36
+ return callsFile((this.context as unknown as KitContext).root, bin)
37
+ }
38
+ }
39
+ return { docs: [ProjectCommands as unknown as CommandClass], calls: [ProjectCalls as unknown as CommandClass] }
40
+ }
@@ -0,0 +1,177 @@
1
+ // every run of a CLI, logged: a start line at launch, an end line at any exit
2
+ // with its time, CPU and memory, the same id on both. A start with no end was
3
+ // killed or still runs. JSON lines, one append each, so runs at once never mix.
4
+ // `<cli> calls` reads it back, so a speed pass starts from what agents waited on.
5
+ // `<CLI>_LOG=off` writes nothing, and neither does a run under `bun test`. Past
6
+ // 2 MB the log moves to `<file>.1` and starts again, so it never grows unbounded.
7
+ import { appendFileSync, mkdirSync, renameSync, statSync } from 'fs'
8
+ import { dirname, join } from 'path'
9
+ import { LAYOUT } from '../../core/layout.ts'
10
+
11
+ export type CallStart = {
12
+ id: string
13
+ at: string
14
+ argv: string[]
15
+ cwd: string
16
+ pid: number
17
+ /** `<CLI>_AGENT`, else null: who ran it */
18
+ agent: string | null
19
+ /** the run of the same CLI that spawned this one, null for a run of its own */
20
+ parent: string | null
21
+ }
22
+
23
+ export type CallEnd = {
24
+ id: string
25
+ /** the command's path, null when none matched (a listing, a typo) */
26
+ cmd: string | null
27
+ /** from the process start */
28
+ ms: number
29
+ /** building the registry: the import of the families the call needed */
30
+ loadMs: number
31
+ cpuMs: number
32
+ rssMb: number
33
+ code: number
34
+ }
35
+
36
+ /** where a CLI's calls go: `<root>/.shipkit/logs/<bin>-calls.jsonl` */
37
+ export const callsFile = (root: string, bin: string): string => join(root, LAYOUT.logs, `${bin}-calls.jsonl`)
38
+
39
+ /** the env prefix of a CLI: `my-tool` → `MY_TOOL` */
40
+ export const envPrefix = (bin: string): string => bin.toUpperCase().replace(/[^A-Z0-9]/g, '_')
41
+
42
+ /** past this size the log moves to `<file>.1` (replacing the older one) and starts again */
43
+ export const CALLS_MAX_BYTES = 2 * 1024 * 1024
44
+
45
+ /** a long argument (a pasted text) is cut, so a line stays one append */
46
+ const ARG_MAX = 300
47
+
48
+ /** logs this run's start, and its end on any exit. The caller names the
49
+ * command and the registry load time once it knows them */
50
+ export function logCall(file: string, bin: string, argv: readonly string[], env: NodeJS.ProcessEnv = process.env): { matched(cmd: string | null, loadMs: number): void } {
51
+ const pre = envPrefix(bin)
52
+ if (env[`${pre}_LOG`] === 'off' || env.NODE_ENV === 'test') return { matched: () => {} }
53
+ let warned = false
54
+ try { if (statSync(file).size > CALLS_MAX_BYTES) renameSync(file, `${file}.1`) } catch { /* no log yet */ }
55
+ const append = (line: object): void => {
56
+ try {
57
+ mkdirSync(dirname(file), { recursive: true })
58
+ appendFileSync(file, `${JSON.stringify(line)}\n`)
59
+ } catch (e) {
60
+ if (!warned) console.error(`WARN ${bin}: the call log could not be written (${file}): ${(e as Error).message}`)
61
+ warned = true
62
+ }
63
+ }
64
+ const id = `${Date.now().toString(36)}-${process.pid}`
65
+ const end: Pick<CallEnd, 'cmd' | 'loadMs'> = { cmd: null, loadMs: 0 }
66
+ const start: CallStart = {
67
+ id,
68
+ at: new Date().toISOString(),
69
+ argv: argv.map((a) => (a.length > ARG_MAX ? `${a.slice(0, ARG_MAX)}…` : a)),
70
+ cwd: process.cwd(),
71
+ pid: process.pid,
72
+ agent: env[`${pre}_AGENT`]?.trim() || null,
73
+ parent: env[`${pre}_CALL`] ?? null,
74
+ }
75
+ append(start)
76
+ env[`${pre}_CALL`] = id
77
+ process.on('exit', (code) => {
78
+ const cpu = process.cpuUsage()
79
+ append({ id, ...end, ms: Math.round(performance.now()), cpuMs: Math.round((cpu.user + cpu.system) / 1000), rssMb: Math.round(process.resourceUsage().maxRSS / 1024), code } satisfies CallEnd)
80
+ })
81
+ return { matched: (cmd, loadMs) => { end.cmd = cmd; end.loadMs = Math.round(loadMs) } }
82
+ }
83
+
84
+ export type CallsQuery = {
85
+ /** epoch ms: only the runs that started after */
86
+ since?: number
87
+ /** a command path prefix */
88
+ cmd?: string
89
+ /** a folder: only the runs started inside it */
90
+ under?: string
91
+ /** keep the runs another run of the same CLI spawned */
92
+ nested?: boolean
93
+ /** how many slowest runs to list */
94
+ top?: number
95
+ /** the id of the run asking, which has no end yet */
96
+ self?: string
97
+ }
98
+
99
+ export type CallsRow = { cmd: string; runs: number; noEnd: number; failed: number; p50: number; p95: number; max: number; totalS: number; loadP50: number; rssMax: number }
100
+ export type CallsReport = { runs: number; rows: CallsRow[]; slowest: { ms: number; at: string; agent: string | null; argv: string }[] }
101
+
102
+ /** `30m`, `2h`, `3d` or a date, as epoch ms; null when unreadable */
103
+ export function sinceOf(s: string, now: number): number | null {
104
+ const m = /^(\d+)([mhd])$/.exec(s)
105
+ if (m) return now - Number(m[1]) * { m: 60e3, h: 3600e3, d: 86400e3 }[m[2] as 'm' | 'h' | 'd']
106
+ const t = Date.parse(s)
107
+ return Number.isNaN(t) ? null : t
108
+ }
109
+
110
+ const quantile = (xs: number[], p: number): number => {
111
+ if (xs.length === 0) return 0
112
+ const s = [...xs].sort((a, b) => a - b)
113
+ return s[Math.min(s.length - 1, Math.floor(p * s.length))] ?? 0
114
+ }
115
+
116
+ /** the call log read back: per command how often, how long, how heavy, and the slowest runs */
117
+ export function callsReport(text: string, q: CallsQuery = {}): CallsReport {
118
+ const starts = new Map<string, CallStart>()
119
+ const ends = new Map<string, CallEnd>()
120
+ for (const line of text.split('\n')) {
121
+ if (line.trim() === '') continue
122
+ let r: CallStart | CallEnd
123
+ try { r = JSON.parse(line) as CallStart | CallEnd } catch { continue }
124
+ if ('at' in r) starts.set(r.id, r)
125
+ else ends.set(r.id, r)
126
+ }
127
+ type Run = { s: CallStart; e: CallEnd | null; cmd: string }
128
+ const runs: Run[] = []
129
+ for (const s of starts.values()) {
130
+ if (s.id === q.self || Date.parse(s.at) < (q.since ?? 0) || (q.nested !== true && s.parent != null)) continue
131
+ if (q.under != null && s.cwd !== q.under && !s.cwd.startsWith(`${q.under}/`)) continue
132
+ const e = ends.get(s.id) ?? null
133
+ const flag = s.argv.findIndex((a) => a.startsWith('-'))
134
+ const words = flag === -1 ? s.argv : s.argv.slice(0, flag)
135
+ const cmd = e?.cmd ?? (words.slice(0, 2).join(' ') || '(none)')
136
+ if (q.cmd != null && !cmd.startsWith(q.cmd)) continue
137
+ runs.push({ s, e, cmd })
138
+ }
139
+ const byCmd = new Map<string, Run[]>()
140
+ for (const r of runs) byCmd.set(r.cmd, [...(byCmd.get(r.cmd) ?? []), r])
141
+ const rows = [...byCmd].map(([cmd, rs]): CallsRow => {
142
+ const done = rs.flatMap((r) => (r.e ? [r.e] : []))
143
+ const ms = done.map((e) => e.ms)
144
+ return {
145
+ cmd,
146
+ runs: rs.length,
147
+ noEnd: rs.length - done.length,
148
+ failed: done.filter((e) => e.code !== 0).length,
149
+ p50: quantile(ms, 0.5),
150
+ p95: quantile(ms, 0.95),
151
+ max: Math.max(0, ...ms),
152
+ totalS: Math.round(ms.reduce((a, b) => a + b, 0) / 1000),
153
+ loadP50: quantile(done.map((e) => e.loadMs), 0.5),
154
+ rssMax: Math.max(0, ...done.map((e) => e.rssMb)),
155
+ }
156
+ })
157
+ rows.sort((a, b) => b.totalS - a.totalS || b.p95 - a.p95 || a.cmd.localeCompare(b.cmd))
158
+ const slowest = runs
159
+ .flatMap((r) => (r.e ? [{ ms: r.e.ms, at: r.s.at, agent: r.s.agent, argv: r.s.argv.join(' ') }] : []))
160
+ .sort((a, b) => b.ms - a.ms)
161
+ .slice(0, q.top ?? 10)
162
+ return { runs: runs.length, rows, slowest }
163
+ }
164
+
165
+ /** the report as a table, then the slowest runs */
166
+ export function renderCallsReport(bin: string, r: CallsReport, file: string): string {
167
+ const head = ['command', 'runs', 'no end', 'failed', 'p50 ms', 'p95 ms', 'max ms', 'total s', 'load p50', 'rss max MB']
168
+ const table = [head, ...r.rows.map((x) => [x.cmd, x.runs, x.noEnd, x.failed, x.p50, x.p95, x.max, x.totalS, x.loadP50, x.rssMax].map(String))]
169
+ const widths = head.map((_, i) => Math.max(...table.map((t) => (t[i] ?? '').length)))
170
+ const out = [`${r.runs} runs · ${file}`, 'a run with no end line was killed or still runs; time is from the process start, load is building the registry', '']
171
+ for (const t of table) out.push(t.map((c, i) => (i === 0 ? c.padEnd(widths[i] ?? 0) : c.padStart(widths[i] ?? 0))).join(' '))
172
+ if (r.slowest.length > 0) {
173
+ out.push('', `the ${r.slowest.length} slowest runs`)
174
+ for (const s of r.slowest) out.push(`${String(s.ms).padStart(8)} ms ${s.at.slice(0, 19)} ${s.agent ?? '-'} ${bin} ${s.argv}`)
175
+ }
176
+ return `${out.join('\n')}\n`
177
+ }
@@ -0,0 +1,67 @@
1
+ // a project CLI is a table of families: the first word of a call loads the
2
+ // family that owns it and nothing else, a word nobody owns (a listing, a typo)
3
+ // loads them all. A family file exports `commands` and imports nothing heavy
4
+ // at its top, so even the full load answers in milliseconds.
5
+ import type { CommandClass } from './verb.ts'
6
+
7
+ export type Family = {
8
+ /** the first words its commands start with */
9
+ roots: string[]
10
+ load: () => Promise<{ commands: CommandClass[] }>
11
+ /** its commands read the whole registry (the docs): every family loads with it */
12
+ all?: true
13
+ }
14
+
15
+ /** the families a call needs: the one owning its first word, else every one */
16
+ export function familiesFor<F extends Family>(argv: readonly string[], families: readonly F[]): F[] {
17
+ const own = families.find((f) => f.roots.includes(argv[0] ?? ''))
18
+ return own != null && own.all !== true ? [own] : [...families]
19
+ }
20
+
21
+ /** the first words two families both claim: a call would load the wrong one */
22
+ export function rootClashes(families: readonly Family[]): string[] {
23
+ const seen = new Set<string>()
24
+ const out = new Set<string>()
25
+ for (const f of families) for (const r of f.roots) (seen.has(r) ? out : seen).add(r)
26
+ return [...out]
27
+ }
28
+
29
+ /** what clipanion tells of a registered command */
30
+ export type ListedDefinition = { path: string; category?: string | null; description?: string | null }
31
+
32
+ const HELP_WORDS = ['--help', '-h', 'help']
33
+
34
+ /** a definition's path without the binary name clipanion puts in front */
35
+ export const stripBin = (bin: string, path: string): string => (path.startsWith(`${bin} `) ? path.slice(bin.length + 1) : path)
36
+
37
+ /** the family word a call asks a listing of: '' for every command, a first
38
+ * word for its family, null to run the call. A word that is itself a command runs */
39
+ export function listingOf(bin: string, argv: readonly string[], defs: readonly ListedDefinition[]): string | null {
40
+ if (argv.length === 0 || (argv.length === 1 && HELP_WORDS.includes(argv[0]!))) return ''
41
+ if (argv.length > 2 || (argv.length === 2 && !HELP_WORDS.includes(argv[1]!))) return null
42
+ const word = argv[0]!
43
+ const paths = defs.map((d) => stripBin(bin, d.path))
44
+ if (paths.includes(word) || !paths.some((p) => p.startsWith(`${word} `))) return null
45
+ return word
46
+ }
47
+
48
+ /** every command of a family (all when ''), one line each, grouped by category */
49
+ export function listing(bin: string, defs: readonly ListedDefinition[], word: string, label?: string): string {
50
+ const rows = defs
51
+ .map((d) => ({ path: stripBin(bin, d.path), category: (d.category ?? '').trim() || 'Other', description: (d.description ?? '').trim() }))
52
+ .filter((r) => !r.path.startsWith('-') && (word === '' || r.path === word || r.path.startsWith(`${word} `)))
53
+ .sort((a, b) => a.category.localeCompare(b.category) || a.path.localeCompare(b.path))
54
+ const width = Math.max(0, ...rows.map((r) => r.path.length)) + bin.length + 3
55
+ const out = word === '' && label != null ? [label, ''] : []
56
+ let category = ''
57
+ for (const r of rows) {
58
+ if (r.category !== category) {
59
+ if (category !== '') out.push('')
60
+ out.push(r.category)
61
+ category = r.category
62
+ }
63
+ out.push(` ${`${bin} ${r.path}`.padEnd(width)}${r.description}`)
64
+ }
65
+ out.push('', `${bin} <command> --help: its usage, its examples and its flags`)
66
+ return `${out.join('\n')}\n`
67
+ }
@@ -0,0 +1,8 @@
1
+ // the project CLI kit: what a repo's `src/cli.ts` and its family files import
2
+ // (`rvlib-shipkit/cli-kit`). Command and Option come from here, so a project
3
+ // needs no clipanion of its own and its classes match the kit's.
4
+ export { Command, Option } from 'clipanion'
5
+ export { type Family, familiesFor, listing, listingOf } from './families.ts'
6
+ export { callsFile, logCall } from './calls.ts'
7
+ export { type ProjectCliSpec, projectCli, runProjectCli } from './project-cli.ts'
8
+ export { argvOf, type AskTool, type CommandClass, type Essential, type Film, filmCall, type KitContext, type Run, type Verb, verbs } from './verb.ts'
@@ -0,0 +1,36 @@
1
+ // the body of a project CLI's pages check (the file `sk start` writes in
2
+ // src/cli/): the CLI's `commands --check` says whether both agent pages match
3
+ // the registry, and the repair writes them. The check file itself declares
4
+ // where it runs, so the policy stays readable in the repo.
5
+ import type { FixResult } from '../../check.ts'
6
+ import type { CheckCtx } from '../../core/check-ctx.ts'
7
+
8
+ /** the first FAIL line of a run, else what it printed on one line */
9
+ const said = (out: string): string => {
10
+ const lines = out.trim().split('\n')
11
+ return lines.find((l) => l.startsWith('FAIL')) ?? lines.join('; ')
12
+ }
13
+
14
+ export function cliPagesCheck(name: string, entry = 'src/cli.ts'): {
15
+ description: string
16
+ inputs: string[]
17
+ run: (c: CheckCtx) => Promise<void>
18
+ repair: (c: CheckCtx) => Promise<FixResult>
19
+ } {
20
+ const all = `agent/${name}.md`
21
+ const essentials = `agent/${name}-essentials.md`
22
+ const gen = ['bun', entry, 'commands', '--out', all, '--essentials', essentials]
23
+ return {
24
+ description: `the ${name} command pages match its registry`,
25
+ inputs: [entry, entry.replace(/\.ts$/, ''), all, essentials],
26
+ run: async (c) => {
27
+ const r = await c.exec([...gen, '--check'], { cwd: c.root, env: { [`${name.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_LOG`]: 'off' } })
28
+ if (r.code === 0) c.ok(said(r.out))
29
+ else c.fail(said(r.out), `${name} ${gen.slice(2).join(' ')}`)
30
+ },
31
+ repair: async (c) => {
32
+ const r = await c.exec(gen, { cwd: c.root })
33
+ return { changed: r.code === 0 && r.out.includes('rewritten'), summary: said(r.out) }
34
+ },
35
+ }
36
+ }
@@ -0,0 +1,73 @@
1
+ // a project's own CLI in one call: `runProjectCli({ name, root, families })`.
2
+ // It loads only the family the first word needs, lists one line per command
3
+ // when asked for a listing, logs every run, and adds `commands` (the agent
4
+ // pages, generated) and `calls` (the log, read back). The bar: the bare
5
+ // listing answers in well under a second.
6
+ import { type BaseContext, Builtins, Cli } from 'clipanion'
7
+ import { callsFile, logCall } from './calls.ts'
8
+ import { type Family, familiesFor, listing, listingOf, rootClashes } from './families.ts'
9
+ import type { AskTool, CommandClass, KitContext } from './verb.ts'
10
+
11
+ export type ProjectCliSpec = {
12
+ /** the binary name: the listing, the examples, the log file and its env prefix use it */
13
+ name: string
14
+ /** the repo root: rows run from it, the call log lives under it */
15
+ root: string
16
+ families: Family[]
17
+ /** one line above the bare listing */
18
+ label?: string
19
+ version?: string
20
+ /** the warm path of `{ tool }` rows */
21
+ askTool?: AskTool
22
+ }
23
+
24
+ /** the kit's own families, after the project's */
25
+ function kitFamilies(bin: string): Family[] {
26
+ const builtins = async () => (await import('./builtins.ts')).builtinCommands(bin)
27
+ return [
28
+ { roots: ['commands'], load: async () => ({ commands: (await builtins()).docs }), all: true },
29
+ { roots: ['calls'], load: async () => ({ commands: (await builtins()).calls }) },
30
+ ]
31
+ }
32
+
33
+ /** what the kit adds to clipanion's context */
34
+ export type KitExtra = Omit<KitContext, keyof BaseContext>
35
+
36
+ /** the cli with the families `argv` needs, and the context its commands run in */
37
+ export async function projectCli(spec: ProjectCliSpec, argv: readonly string[], cwd = process.cwd()): Promise<{ cli: Cli<KitContext>; context: KitExtra }> {
38
+ const families = [...spec.families, ...kitFamilies(spec.name)]
39
+ const clash = rootClashes(families)
40
+ if (clash.length > 0) throw new Error(`${spec.name}: two families claim the first word ${clash.map((w) => `'${w}'`).join(', ')}, give each word one family`)
41
+ const cli = new Cli<KitContext>({ binaryLabel: spec.label ?? spec.name, binaryName: spec.name, binaryVersion: spec.version, enableCapture: true, enableColors: process.stdout.isTTY === true && (process.env.NO_COLOR ?? '') === '' })
42
+ cli.register(Builtins.HelpCommand)
43
+ if (spec.version != null) cli.register(Builtins.VersionCommand)
44
+ const need = familiesFor(argv, families)
45
+ const classes: CommandClass[] = []
46
+ for (const mod of await Promise.all(need.map((f) => f.load()))) for (const c of mod.commands) { cli.register(c); classes.push(c) }
47
+ return { cli, context: { root: spec.root, cwd, classes, askTool: spec.askTool } }
48
+ }
49
+
50
+ /** the path of the command a call runs, null when none matches */
51
+ function commandOf(cli: Cli<KitContext>, argv: string[], context: KitExtra): string | null {
52
+ try {
53
+ const path = (cli.process(argv, context).constructor as { paths?: string[][] }).paths?.[0]?.join(' ') ?? null
54
+ return path?.startsWith('-') === true ? 'help' : path
55
+ } catch {
56
+ return null
57
+ }
58
+ }
59
+
60
+ const closedPipe = (e: unknown): boolean => e instanceof Error && 'code' in e && e.code === 'EPIPE'
61
+
62
+ /** the whole entry of a project CLI: log, load, then list or run */
63
+ export async function runProjectCli(spec: ProjectCliSpec, argv: string[] = process.argv.slice(2)): Promise<void> {
64
+ // a reader that closes early (`<cli> | head`) ends the run quietly
65
+ process.stdout.on('error', (e) => (closedPipe(e) ? process.exit(0) : undefined))
66
+ const call = logCall(callsFile(spec.root, spec.name), spec.name, argv)
67
+ const t0 = performance.now()
68
+ const { cli, context } = await projectCli(spec, argv)
69
+ call.matched(commandOf(cli, argv, context), performance.now() - t0)
70
+ const word = listingOf(spec.name, argv, cli.definitions())
71
+ if (word !== null) { process.stdout.write(listing(spec.name, cli.definitions(), word, spec.label)); return }
72
+ await cli.runExit(argv, context)
73
+ }