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
package/CHANGELOG.md CHANGED
@@ -1,3 +1,21 @@
1
+ ## 2.2.0
2
+
3
+ - **The project CLI kit, `rvlib-shipkit/cli-kit`, and a project CLI from `sk start`.** A repo's own CLI is `runProjectCli({ name, root, families })`: the first word loads only its family, the bare listing prints one line per command (0.027 s on a fresh repo), and every run is logged to `.shipkit/logs/<cli>-calls.jsonl`. A command over code that exists is a row, `verbs([{ path, category, description, examples, run: { bun } | { cmd } | { tool, cold } }])`, never a second implementation. A row marked `essential` lands on the everyday page, and a row marked `film: 'still' | 'clip'` takes `--out` (resolved against the caller's cwd), a clip also `--seconds`, and fails when it exits 0 without writing `--out`. The kit adds `<cli> commands` (`--out` and `--essentials` write the agent pages, `--check` fails on a stale page or a row with no category, a long description or no example starting with its words, `--json` is the registry for other tools) and `<cli> calls`. `sk start` writes the skeleton on day one: `src/cli.ts`, `src/cli/cmd_Dev.ts` with a dev row and a commented shot row, both agent pages generated, the agent index loading the essentials page, package.json `cli` and a `bin` of the repo name. `sk start <name> --cli` writes it into a repo with history, and refuses one that already has a CLI. The scaffold tsconfig names `types: ["bun"]`, which TypeScript 7 needs for Bun's globals.
4
+ - **`sk calls`: every sk run logged machine wide** (`~/.shipkit/logs/sk-calls.jsonl`, `SK_LOG=off` to stop), read back per command with the slowest runs, `--here` for one repo. It adds no measurable time to `sk --help`, and `sk start` now loads its scaffold only when it runs.
5
+ - **`sk trailer`: a `.trailer` file in any repo becomes an mp4 and its contact strip.** The trailer language, its core words: a header (`trailer`, `size`, `fps`, `music`, `duck`, `fade-in`), shots with one film line each, titles, cuts, `from`, `push`, `frame`, `lift`, `shake`, `flash`, sections in bars with their tempo and meter, `mark`, `starts on`, and music, sfx and voice from files. Every error is at its file and line, all at once, and a file with one error never builds. A film line is `clip <video>`, `image <picture>`, `card`, `run "<command {out}>"`, or a film row of the repo's own CLI: `film <words>` runs `<cli> <words> --out <file>`, with `--seconds <n>` for a clip, the rows read from `<cli> commands --json` only when a line needs them. A take is filmed once into `.shipkit/cache/trailer/footage/<key>`, keyed on what films (the key formula of pkm-multi's trailer, verbatim), and stamped with the repo's world: a stale take is reported and filmed again only on `--refilm`. The stitch renders one piece per section, cached on its ffmpeg arguments and its inputs, so a title edit renders one section and a build with nothing changed takes about 0.6 s. Five title styles (`logo`, `big`, `caption`, `corner`, `line`) and the card wear the repo's brand: the palette of `.shipkit/identity/brand.ts`, its new optional `trailer` section for the face, the logo and each colour, and a house look for a repo with no brand. A `trailer({ sound })` unit in any `<name>.shipkit.ts` resolves sound ids that are not files. Verbs `sk trailer [file]`, `sk trailer check`, `sk trailer shot <name>` (its nine frames on one png).
6
+ - **`sk trailer`: the vertical short from brand data, and `film url <route>`.** The header word `vertical [mark left|right] [outro card|none|<video>]` makes the cut a 9:16 short (1080x1920 unless `size` says otherwise, upright only) finished with the brand's `trailer.vertical` look: a lockup that pops in over every frame under the platforms' top tabs (a wordmark image, else the logo and the name, and a tag pill), a watermark in a bottom corner, an optional outro clip, and an end card whose image hops in, its lines with `*word*` in the accent. Each part takes its colours and face from the brand's look, each is `false` to leave it out, and a repo with no brand gets its logo and its folder name. The finish is cached on the pieces and the mix it reads: an unchanged build stays about 1 s, a title edit renders one section and the finish. `film url <route> [viewport WxH] [wait <time>] [still]` films a page of the repo's dev url (the `url` of its global process, `base <id>` when it has several, started through pm when down) through `shotUrl` and `clipUrl`, a portrait cut at a phone's width. Lifted from pkm-multi's `chi site vertical`; the footage key formula is unchanged.
7
+ - **`sk shot <url>`: a png of any url through one warm browser for the whole machine, `--video <s>` a clip.** The `shot` global process (declared in the lib, `src/shot/shot.shipkit.ts`) starts on the first `sk shot` and never at login. It holds one headless chromium and one booted page, one request at a time. A still of the url the page already shows is a ready check plus chromium's fast capture: 33 to 52 ms in the service, 0.11 to 0.23 s for the whole command, measured on a running vite app. A page with `<meta name="shot-ready" content="wait">` is shot once it sets `content="ready"`, any other page `--wait` ms (300) after its load. `--video` films the page's largest canvas through its own WebCodecs H.264 encoder on its animation frames and muxes the stream into an mp4 with no second encode. It stays out of the way of other work: the browser runs at nice 10, the page is frozen 10 s after a request and loaded again before the next still, and the browser closes after 60 s idle. Code calls `shotUrl` and `clipUrl` from `src/shot/client.ts`. Playwright resolves from the repo that runs the service through `loadPlaywright`, now generic over the slice its caller types.
8
+ - **`sk shot --video` films any web page, canvas or not.** A page that a canvas fills (half the viewport or more) still films that canvas through its WebCodecs encoder. Any other page, or any page with `--dom`, films as chromium paints it: CDP `Page.startScreencast` takes every painted frame, a frame holds on the clip's fixed grid until the next one paints (a still page fills its seconds from one frame), and ffmpeg encodes the jpegs to H.264 as they arrive. Measured on a 3 s animated page against Playwright's `recordVideo`: 90 of 90 distinct frames against 65 of 89, 3.29 s against 3.43 s from the start of filming to the closed file, and 1.8 s of CPU above an idle page against 2.7 s. A 3 s DOM clip takes 3.7 s for the whole command, the canvas path 3.6 s as before. A DOM clip is in css pixels whatever `--dpr`. `clipUrl` takes an optional `dom`, and a clip's request cap now adds its own seconds, so a `--long` clip past about 110 s no longer fails at the 120 s cap.
9
+ - **Plans get a tracked home, `.shipkit/plans/`.** SK004 re-includes `!.shipkit/plans/` in a private repo's managed block, so a dated plan, an arc's board and its lane files survive a cleanup of the scratch, and a lost machine once committed. A repo declared `visibility: 'public'` gets no re-include, through the same filter that keeps its comfy drafts local. The next `sk ci` fix pass rewrites the block, and nothing else changes for anyone. The old root layout migration files the dated plans at the top of `tmp/` (`YYYYMMDD-HHMMSS-<slug>.md`) under `.shipkit/plans/` and moves the rest of the scratch under `.shipkit/tmp/` as before. A plan already in `.shipkit/tmp/` is not moved: the scratch holds other files, and only a human or an agent knows which one is a plan.
10
+ - **`sk reflect` keeps only what the human typed.** A sub-agent hand-back (`<agent-message`) and a message from another Claude session (`<cross-session-message`, `Another Claude session sent a message`) are model output the harness parks in the queue, and the corpus counted them as queued prompts. They are dropped now, typed or queued. A real prompt that mentions `<agent-message` mid text stays.
11
+ - **A red test check names the file that threw outside a test.** bun can count a throw inside a describe callback as `0 fail` beside `1 error`, and the check said `FAIL: 0 test(s) failed`, a red line that read green. It now says `FAIL: 1 error(s) outside any test (tests/light/galerie.test.ts threw error: unknown room: galerie), Ran 1 tests`, and adds the failed test count only when there is one. The record behind the hub keeps that error too: the error inside bun's banner block, a `ReferenceError:` or any named error class included.
12
+
13
+ ## 2.1.1
14
+
15
+ - **`--only a,b` takes a comma list** in `sk ci`, `sk cache-audit` and `sk fleet`, the same as repeating the flag.
16
+ - **`sk gp start` waits for pm to act.** The state the switch leaves (down for a start, up for a stop) counts as pm not there yet for 30 s, so a process that takes a few seconds to launch no longer reads as down. A crash still fails at once.
17
+ - **`sk mv` rewrites relative markdown links on a folder move**, through the same pass the page move uses, and a page move no longer crashes on a repo with a git submodule.
18
+
1
19
  ## 2.1.0
2
20
 
3
21
  - **SK027 requires exhaustive agent/ indexes.** Every `agent/` page is listed by an index (a bullet, numbered item, table row, `→` pointer or `@` include names it or its folder), and every index is loaded from CLAUDE.md or listed by a loaded file. A page linked only from prose now fails, with the page that already names it as the fix. The template's house `[lint]` block makes `typescript/no-empty-object-type` an error.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "funding": "https://github.com/sponsors/rvion",
5
5
  "description": "All-in-one repo companion: ci gate (task DAG), auto-discovered health checks, release-contract fulfillness, asks queue, md-wiki tools, asset atelier, live tui",
6
6
  "license": "MIT",
@@ -16,6 +16,7 @@
16
16
  },
17
17
  "exports": {
18
18
  ".": "./src/shipkit.ts",
19
+ "./cli-kit": "./src/cli/kit/index.ts",
19
20
  "./contracts": "./src/contracts/contract-types.ts",
20
21
  "./vite": "./src/assets/vite.ts",
21
22
  "./src/*": "./src/*"
@@ -15,6 +15,7 @@ import { mkdirSync } from 'fs'
15
15
  import { dirname, join } from 'path'
16
16
  import sharp from 'sharp'
17
17
  import { defineKind, type CanvasGen, type ComfyGen, type Kind } from '../kit-config.ts'
18
+ import { HOUSE_FAMILIES, type TrailerLookInput } from '../../trailer/look.ts'
18
19
 
19
20
  export type Brand = {
20
21
  name: string
@@ -29,6 +30,9 @@ export type Brand = {
29
30
  /** og layout hook — replaces the WHOLE default card (bg + mark left +
30
31
  * name/tagline right); the hook owns compositing the mark if it wants one */
31
32
  drawOg?: (ctx: SKRSContext2D, brand: Brand, size: { w: number; h: number }) => void
33
+ /** the look every trailer of the repo wears (titles, card, face, logo);
34
+ * each value left out is derived from the palette (trailer/look.ts) */
35
+ trailer?: TrailerLookInput
32
36
  }
33
37
 
34
38
  export const FAVICON_SIZE = 512
@@ -52,9 +56,9 @@ export function identityKinds(brand: Brand): Kind[] {
52
56
  }
53
57
 
54
58
  // named faces first: the generic sans-serif can resolve to a face with no
55
- // Latin-1 accents (macOS). Each family below carries them, and an absent one
56
- // is skipped (macOS, Windows, then the common Linux faces)
57
- const OG_FAMILIES = 'Helvetica, Arial, "DejaVu Sans", "Liberation Sans", "Noto Sans", sans-serif'
59
+ // Latin-1 accents (macOS). Each family carries them, and an absent one is
60
+ // skipped (macOS, Windows, then the common Linux faces)
61
+ const OG_FAMILIES = HOUSE_FAMILIES
58
62
  export const ogFont = (px: number, bold = false): string => `${bold ? 'bold ' : ''}${px}px ${OG_FAMILIES}`
59
63
 
60
64
  // shrink px until text fits maxW (og cards must never clip the tagline)
@@ -122,14 +122,28 @@ async function repoWebRoot(root: string): Promise<string> {
122
122
 
123
123
  export type IdentityRender = { written: string[] } | { blocked: string }
124
124
 
125
- export async function renderIdentityNow(root: string): Promise<IdentityRender> {
125
+ /** the repo's brand, `.shipkit/identity/brand.ts`: null when the file is
126
+ * absent, a string saying why when it does not default-export one */
127
+ export async function loadBrand(root: string): Promise<Brand | null | string> {
126
128
  const brandPath = join(root, '.shipkit/identity/brand.ts')
127
- if (!existsSync(brandPath)) return { blocked: 'no .shipkit/identity/brand.ts — create one exporting a Brand (name, palette, mark)' }
129
+ if (!existsSync(brandPath)) return null
128
130
  // cache-bust: bun caches dynamic imports for the process lifetime — a
129
131
  // long-running hub must re-render the EDITED brand, not the cached one
130
132
  const mod = (await import(`${brandPath}?v=${await brandHash(root)}`)) as { default?: Brand }
131
- if (mod.default == null) return { blocked: '.shipkit/identity/brand.ts must default-export the Brand' }
132
- const brand = mod.default
133
+ return mod.default ?? '.shipkit/identity/brand.ts must default-export the Brand'
134
+ }
135
+
136
+ /** the rendered logo of the identity set, repo relative, when it exists */
137
+ export async function identityLogo(root: string): Promise<string | null> {
138
+ const web = await repoWebRoot(root)
139
+ const file = web === '.' ? 'logo.png' : `${web}/logo.png`
140
+ return existsSync(join(root, file)) ? file : null
141
+ }
142
+
143
+ export async function renderIdentityNow(root: string): Promise<IdentityRender> {
144
+ const brand = await loadBrand(root)
145
+ if (brand == null) return { blocked: 'no .shipkit/identity/brand.ts — create one exporting a Brand (name, palette, mark)' }
146
+ if (typeof brand === 'string') return { blocked: brand }
133
147
  let markFile: string | undefined
134
148
  if (brand.mark.backend === 'comfy') {
135
149
  // comfy marks render from the PICKED identity-logo roll — the kit
@@ -32,6 +32,10 @@ const CONTRIB = '# repo-contributed lines (repo.config.ts gitignore:)'
32
32
 
33
33
  const DRAFTS_BACK = '!.comfy-ts/drafts/'
34
34
 
35
+ /** re-includes a private repo carries and a declared public one drops: a
36
+ * draft names private loras, a plan or a board quotes the owner */
37
+ const PRIVATE_ONLY: ReadonlySet<string> = new Set([DRAFTS_BACK, `!${LAYOUT.plans}/`])
38
+
35
39
  export const POLICY_LINES = [
36
40
  // everything shipkit keeps lives under .shipkit/ (core/layout.ts). Local
37
41
  // by default: the private notes, drafts, venue listings and caches, and the
@@ -45,12 +49,13 @@ export const POLICY_LINES = [
45
49
  // asks git about it: a negation below the block that exposed the keyword
46
50
  // list would otherwise pass as a venue override
47
51
  `${LAYOUT.private}/`,
48
- // the scratch and the review files, spelled out for the same reason: a
49
- // review narrates agent process, and a plan names what nobody published
52
+ // the scratch and the review files, spelled out for the same reason:
53
+ // nothing durable lives in the scratch, and a review narrates agent process
50
54
  `${LAYOUT.tmp}/`,
51
55
  `${LAYOUT.reviews}/`,
52
56
  // a root scratch stays out of git too: the fix pass moves it under
53
- // .shipkit/, and until it runs, a plan there is still private
57
+ // .shipkit/, its dated plans into .shipkit/plans/, and until it runs
58
+ // nothing there is exposed
54
59
  `${LEGACY_TMP}/`,
55
60
  'CLAUDE.local.md',
56
61
  // atomicWrite lands every file via `.<name>.tmp-<pid>` next to its target,
@@ -74,9 +79,9 @@ export const POLICY_LINES = [
74
79
  ]
75
80
 
76
81
  /** the policy one repo carries. A declared public tree ships to strangers,
77
- * so the drafts stay ignored there: a draft holds the prompts and values
78
- * behind generated assets, authored state in a private repo */
79
- const policyLines = (config: RepoConfig): string[] => declaredPublic(config) ? POLICY_LINES.filter((l) => l !== DRAFTS_BACK) : POLICY_LINES
82
+ * so its drafts and plans stay ignored there: authored state in a private
83
+ * repo, private words in a public one */
84
+ const policyLines = (config: RepoConfig): string[] => declaredPublic(config) ? POLICY_LINES.filter((l) => !PRIVATE_ONLY.has(l)) : POLICY_LINES
80
85
 
81
86
  const LEGACY = '# the old root layout, ignored until the fix pass moves it under .shipkit/'
82
87
 
@@ -14,7 +14,7 @@
14
14
  // scaffold, so a policy written there would bind exactly one checkout.
15
15
  import { isAbsolute, join } from 'path'
16
16
  import { inferBin } from '../core/canonical-scripts.ts'
17
- import { denyRules, GIT_STASH_RULE, RAW_RUNNERS } from '../core/one-way-in-policy.ts'
17
+ import { denyRules, GIT_STASH_RULES, LEGACY_STASH_RULE, RAW_RUNNERS } from '../core/one-way-in-policy.ts'
18
18
  import { rootPublishesNpm } from '../core/repo-facts.ts'
19
19
  import type { RepoConfig } from '../repo-config.ts'
20
20
  import { repoOff } from '../core/when.ts'
@@ -139,9 +139,12 @@ export const oneWayInPolicy = check({
139
139
  return
140
140
  }
141
141
  const have = new Set(s.permissions?.deny ?? [])
142
- const missing = want.filter((r) => !have.has(r) && r !== GIT_STASH_RULE)
143
- if (!have.has(GIT_STASH_RULE))
144
- c.fail(`${SETTINGS_PATH}: no "${GIT_STASH_RULE}" deny row, so an agent can stash every other agent's uncommitted work in a shared tree`)
142
+ const missing = want.filter((r) => !have.has(r) && !GIT_STASH_RULES.includes(r))
143
+ const stashMissing = GIT_STASH_RULES.filter((r) => !have.has(r))
144
+ if (stashMissing.length > 0)
145
+ c.fail(`${SETTINGS_PATH}: ${stashMissing.length} stash deny row(s) missing (${stashMissing.slice(0, 2).join(', ')}${stashMissing.length > 2 ? ', …' : ''}), so an agent can stash every other agent's uncommitted work in a shared tree`)
146
+ if (have.has(LEGACY_STASH_RULE))
147
+ c.fail(`${SETTINGS_PATH}: the blanket "${LEGACY_STASH_RULE}" row also refuses the read only \`git stash list\` and \`git stash show\``)
145
148
  if (missing.length > 0)
146
149
  c.fail(`${SETTINGS_PATH}: ${missing.length} raw runner(s) still permitted (${missing.slice(0, 3).join(', ')}${missing.length > 3 ? ', …' : ''}) — those calls never reach shipkit, so nothing is cached and no run can be attached to`)
147
150
  const guard = guardHookCommand(s)
@@ -172,7 +175,7 @@ export const oneWayInPolicy = check({
172
175
  // replacement is named while the guard cannot run is the green that
173
176
  // measured nothing, one line under the WARN that says otherwise
174
177
  if (!c.failed)
175
- c.ok(`${SETTINGS_PATH} closes ${want.length - 1}/${RAW_RUNNERS.length} raw runner(s) and refuses git stash${guardRuns ? ', with the replacement named' : ' (the guard that names the replacement is declared, not yet runnable)'}`)
178
+ c.ok(`${SETTINGS_PATH} closes ${want.length - GIT_STASH_RULES.length}/${RAW_RUNNERS.length} raw runner(s) and refuses git stash${guardRuns ? ', with the replacement named' : ' (the guard that names the replacement is declared, not yet runnable)'}`)
176
179
  },
177
180
  // MERGES, never rewrites: this file also carries the attribution silencer,
178
181
  // a repo's own allow list and its own hooks, and clobbering those to fix a
@@ -185,11 +188,13 @@ export const oneWayInPolicy = check({
185
188
  if (existing == null && c.exists(SETTINGS_PATH)) return { changed: false, summary: `${SETTINGS_PATH} is unparseable — repair it by hand first, a merge would destroy it` }
186
189
  const s: Settings = existing ?? {}
187
190
  const changes: string[] = []
188
- const deny = s.permissions?.deny ?? []
191
+ const old = s.permissions?.deny ?? []
192
+ const deny = old.filter((r) => r !== LEGACY_STASH_RULE)
193
+ if (deny.length < old.length) changes.push(`the blanket ${LEGACY_STASH_RULE} row replaced by one row per stash write`)
189
194
  const added = want.filter((r) => !deny.includes(r))
190
- if (added.length > 0) {
195
+ if (added.length > 0 || deny.length < old.length) {
191
196
  s.permissions = { ...s.permissions, deny: [...deny, ...added] }
192
- changes.push(`${added.length} deny rule(s) added`)
197
+ if (added.length > 0) changes.push(`${added.length} deny rule(s) added`)
193
198
  }
194
199
  const wantGuard = await guardCommandFor(d)
195
200
  if (!hasGuardHook(s)) {
@@ -4,6 +4,7 @@
4
4
  import { Command, Option } from 'clipanion'
5
5
  import { loadRepoConfig } from '../repo-config.ts'
6
6
  import { auditCache } from '../core/cache-audit.ts'
7
+ import { listFlag } from './list-flag.ts'
7
8
 
8
9
  export class CacheAuditCommand extends Command {
9
10
  static override paths = [['cache-audit']]
@@ -28,9 +29,9 @@ export class CacheAuditCommand extends Command {
28
29
  const root = process.cwd()
29
30
  const n = Number(this.sample)
30
31
  if (!Number.isInteger(n) || n < 1) { this.context.stdout.write(`FAIL: --sample takes a whole number above 0, got '${this.sample}'\n`); return 1 }
31
- const r = await auditCache(root, await loadRepoConfig(root), { sample: n, only: this.only })
32
+ const r = await auditCache(root, await loadRepoConfig(root), { sample: n, only: this.only == null ? undefined : listFlag(this.only) })
32
33
  if (this.json) { this.context.stdout.write(`${JSON.stringify(r)}\n`); return r.stale.length > 0 ? 1 : 0 }
33
- if (r.rows.length === 0) { this.context.stdout.write(`skip: nothing to audit, the cache answers none of ${this.only?.join(', ') ?? 'the checks'} right now\n`); return 0 }
34
+ if (r.rows.length === 0) { this.context.stdout.write(`skip: nothing to audit, the cache answers none of ${this.only == null ? 'the checks' : listFlag(this.only).join(', ')} right now\n`); return 0 }
34
35
  for (const row of r.rows) {
35
36
  const same = row.fresh === row.cached || row.fresh === 'skip'
36
37
  this.context.stdout.write(same
@@ -0,0 +1,53 @@
1
+ // `<cli> calls`: the call log (cli/kit/calls.ts) read back. shipkit logs every
2
+ // run machine wide, because one sk serves every repo; a project CLI logs under
3
+ // its own root. One command class for both: only the log file differs.
4
+ import { Command, Option } from 'clipanion'
5
+ import { homedir } from 'os'
6
+ import { callsFile, callsReport, envPrefix, renderCallsReport, sinceOf } from './kit/calls.ts'
7
+
8
+ export class CallsCommand extends Command {
9
+ static override paths = [['calls']]
10
+
11
+ static override usage = Command.Usage({
12
+ category: 'Health',
13
+ description: 'every sk run logged with its time: per command how often, how long, and the slowest runs',
14
+ details: 'reads ~/.shipkit/logs/sk-calls.jsonl, where every sk run writes a start line and an end line (time, cpu, memory, exit code). A run with no end line was killed or still runs. Runs another sk spawned are left out unless --nested. SK_LOG=off writes nothing; SK_AGENT names who ran it.',
15
+ examples: [
16
+ ['what agents waited on today', 'sk calls --since 1d'],
17
+ ['one command family', 'sk calls --cmd ci'],
18
+ ['only the runs started in this repo', 'sk calls --here'],
19
+ ['as json', 'sk calls --json'],
20
+ ],
21
+ })
22
+
23
+ since = Option.String('--since', { description: '30m, 2h, 3d or a date: only the runs that started after' })
24
+ cmd = Option.String('--cmd', { description: 'a command path prefix' })
25
+ top = Option.String('--top', '10', { description: 'how many slowest runs to list' })
26
+ nested = Option.Boolean('--nested', false, { description: 'keep the runs another run spawned' })
27
+ here = Option.Boolean('--here', false, { description: 'only the runs started inside the current folder' })
28
+ json = Option.Boolean('--json', false, { description: 'the report as json' })
29
+
30
+ /** the log this command reads */
31
+ logFile(): string {
32
+ return callsFile(homedir(), 'sk')
33
+ }
34
+
35
+ async execute(): Promise<number> {
36
+ const bin = this.cli.binaryName
37
+ const file = this.logFile()
38
+ const since = this.since == null ? undefined : sinceOf(this.since, Date.now())
39
+ if (since === null) { this.context.stderr.write(`FAIL: --since ${this.since}: 30m, 2h, 3d or a date\n`); return 2 }
40
+ const { existsSync, readFileSync } = await import('fs')
41
+ if (!existsSync(file)) { this.context.stdout.write(`no call yet: ${file}\n`); return 0 }
42
+ const r = callsReport(readFileSync(file, 'utf8'), {
43
+ since,
44
+ cmd: this.cmd,
45
+ top: Number(this.top),
46
+ nested: this.nested,
47
+ under: this.here ? process.cwd() : undefined,
48
+ self: process.env[`${envPrefix(bin)}_CALL`],
49
+ })
50
+ this.context.stdout.write(this.json ? `${JSON.stringify({ file, ...r })}\n` : renderCallsReport(bin, r, file))
51
+ return 0
52
+ }
53
+ }
package/src/cli/cmd_Ci.ts CHANGED
@@ -7,6 +7,7 @@ import { runJob } from '../core/job-run.ts'
7
7
  import { runCi } from '../core/run-ci.ts'
8
8
  import { runViaHub } from '../home/via-hub.ts'
9
9
  import { maybeSelfUpdateAndRestart } from '../core/self-update.ts'
10
+ import { listFlag } from './list-flag.ts'
10
11
 
11
12
  export class CiCommand extends Command {
12
13
  static override paths = [['ci']]
@@ -37,7 +38,7 @@ export class CiCommand extends Command {
37
38
  frozen = Option.Boolean('--frozen', false, { description: 'report-only mode for self-maintaining checks: SK005 never touches package.json, and generated paths are never untracked. STATUS.md is still refreshed on disk, but it is gitignored, so a run leaves the tree clean' })
38
39
  skip = Option.Array('--skip', [], { description: 'skip a check group (repeatable)' })
39
40
  withIds = Option.Array('--with', [], { description: 'include a check the quick gate leaves out (repeatable)' })
40
- only = Option.Array('--only', [], { description: 'run only this check + its dep closure (repeatable). A group name runs every check declaring that group. No repair pass unless --fix is named' })
41
+ only = Option.Array('--only', [], { description: 'run only this check + its dep closure (repeatable, or comma separated). A group name runs every check declaring that group. No repair pass unless --fix is named' })
41
42
  fresh = Option.Boolean('--fresh', false, { description: 'run every check: no answer comes from the cache, and what this run finds is stored fresh' })
42
43
  full = Option.Boolean('--full', false, { description: `run the FULL test tier: every suite the repo declares, the slow ones it skips while you work included. What the release path always runs. Same as SHIPKIT_TEST_FULL=1` })
43
44
 
@@ -74,7 +75,7 @@ export class CiCommand extends Command {
74
75
  ...(this.frozen ? ['--frozen'] : []),
75
76
  ...this.skip.flatMap((v) => ['--skip', v]),
76
77
  ...this.withIds.flatMap((v) => ['--with', v]),
77
- ...this.only.flatMap((v) => ['--only', v]),
78
+ ...listFlag(this.only).flatMap((v) => ['--only', v]),
78
79
  ...(this.fresh ? ['--fresh'] : []),
79
80
  ...(this.full ? ['--full'] : []),
80
81
  ...(this.verbose ? ['--verbose'] : []),
@@ -95,7 +96,7 @@ export class CiCommand extends Command {
95
96
  frozen: this.frozen,
96
97
  skipGroups: this.skip,
97
98
  withIds: this.withIds,
98
- onlyIds: this.only,
99
+ onlyIds: listFlag(this.only),
99
100
  fresh: this.fresh,
100
101
  ...(this.full ? { tests: 'full' as const } : {}),
101
102
  ...(this.verbose ? { verbose: true } : {}),
@@ -1,8 +1,11 @@
1
1
  // the binary describing itself, in markdown. a hand-copied command list
2
2
  // drifts the moment a flag changes, and then teaches commands that do not
3
- // exist. rendering the live registry makes the doc a build artifact.
3
+ // exist. rendering the live registry makes the doc a build artifact. One
4
+ // renderer for every binary: shipkit's own page and each project CLI's pages
5
+ // (the kit in cli/kit/) come out of these functions.
4
6
  import { Command, Option } from 'clipanion'
5
7
  import { spliceMarkedBlock } from '../core/doc-block.ts'
8
+ import { type Essential, type Film, marksOf, ROW_OF, runText } from './kit/verb.ts'
6
9
 
7
10
  export type CommandOption = { definition: string; description: string }
8
11
  export type CommandRow = {
@@ -12,6 +15,12 @@ export type CommandRow = {
12
15
  usage: string
13
16
  options: CommandOption[]
14
17
  examples: [string, string][]
18
+ /** a filmable command (cli/kit/verb.ts): takes --out, a clip also --seconds */
19
+ film?: Film
20
+ /** listed on the essentials page */
21
+ essential?: Essential
22
+ /** what a kit row runs */
23
+ runs?: string
15
24
  }
16
25
 
17
26
  export type Definition = {
@@ -30,68 +39,146 @@ export const DESCRIPTION_MAX = 100
30
39
  // clipanion renders a value placeholder as `#0`, which nobody can type
31
40
  const clean = (s: string | null | undefined): string => (s ?? '').trim().replace(/\s+/g, ' ').replace(/ #\d+\b/g, ' <value>')
32
41
 
42
+ /** the names a binary answers to: shipkit is also `sk` */
43
+ const namesOf = (bin: string): string[] => (bin === 'sk' || bin === 'shipkit' ? ['shipkit', 'sk'] : [bin])
44
+
45
+ const stripName = (bin: string, path: string): string => {
46
+ for (const n of namesOf(bin)) if (path.startsWith(`${n} `)) return path.slice(n.length + 1)
47
+ return path
48
+ }
49
+
33
50
  /** rows from the live registry, builtins dropped, sorted by category then path.
34
51
  * clipanion splits what we need across two calls: the plain form carries the
35
52
  * examples, the detailed form carries the flags. Merge by path, or the page
36
53
  * ends up showing a command with no arguments, which is the state that sends
37
- * an agent to `--help` before it can act. */
38
- export function commandRows(plain: Definition[], detailed: Definition[] = []): CommandRow[] {
54
+ * an agent to `--help` before it can act. `classes` (the registered command
55
+ * classes) add each command's marks: film, essential, what a row runs */
56
+ export function commandRows(plain: Definition[], detailed: Definition[] = [], bin = 'sk', classes: readonly { paths?: string[][] }[] = []): CommandRow[] {
39
57
  const flagsByPath = new Map(detailed.map((d) => [d.path, d.options ?? []]))
58
+ const classByPath = new Map<string, object>()
59
+ for (const c of classes) for (const p of c.paths ?? []) classByPath.set(p.join(' '), c)
40
60
  return plain
41
- .filter((d) => !/^(shipkit|sk) (--help|-h|--version|-v)$/.test(d.path))
42
- .map((d) => ({
43
- path: d.path.replace(/^(shipkit|sk) /, ''),
44
- category: clean(d.category) || 'Other',
45
- // verbatim from the CLI so the doc cannot say something it does not
46
- description: clean(d.description),
47
- usage: clean(d.usage),
48
- options: (flagsByPath.get(d.path) ?? [])
49
- .map((o) => ({ definition: clean(o.definition), description: clean(o.description) }))
50
- .filter((o) => o.definition !== ''),
51
- examples: (d.examples ?? []).map(([label, cmd]) => [clean(label), clean(cmd)] as [string, string]),
52
- }))
61
+ .filter((d) => !/^(--help|-h|--version|-v)$/.test(stripName(bin, d.path)))
62
+ .map((d) => {
63
+ const path = stripName(bin, d.path)
64
+ const c = classByPath.get(path)
65
+ const row: CommandRow = {
66
+ path,
67
+ category: clean(d.category) || 'Other',
68
+ // verbatim from the CLI so the doc cannot say something it does not
69
+ description: clean(d.description),
70
+ usage: clean(d.usage),
71
+ options: (flagsByPath.get(d.path) ?? [])
72
+ .map((o) => ({ definition: clean(o.definition), description: clean(o.description) }))
73
+ .filter((o) => o.definition !== ''),
74
+ examples: (d.examples ?? []).map(([label, cmd]) => [clean(label), clean(cmd)] as [string, string]),
75
+ }
76
+ if (c != null) {
77
+ const m = marksOf(c)
78
+ if (m.film != null) row.film = m.film
79
+ if (m.essential != null) row.essential = m.essential
80
+ const v = ROW_OF.get(c)
81
+ if (v != null) row.runs = runText(v.run)
82
+ }
83
+ return row
84
+ })
53
85
  .sort((a, b) => a.category.localeCompare(b.category) || a.path.localeCompare(b.path))
54
86
  }
55
87
 
56
- /** the markdown block, loaded into every agent's context at start: per
57
- * command its invocation with every flag, one line of description, then its
58
- * examples. What a flag does and the long text are `sk <command> --help`. */
59
- export function renderMarkdown(rows: CommandRow[]): string {
88
+ /** a command's invocation as an agent types it */
89
+ const signature = (r: CommandRow, bin: string): string => r.usage.replace(/^\$0/, bin).trim() || `${bin} ${r.path}`
90
+
91
+ /** the markdown block: per command its invocation with every flag, one line
92
+ * of description, what a row runs, then its examples. What a flag does and
93
+ * the long text are `<bin> <command> --help`. */
94
+ export function renderMarkdown(rows: CommandRow[], bin = 'sk'): string {
60
95
  const out: string[] = []
61
96
  let cat = ''
62
97
  for (const r of rows) {
63
98
  if (r.category !== cat) { cat = r.category; out.push(`\n### ${cat}\n`) }
64
- const sig = r.usage.replace(/^\$0/, 'sk').trim() || `sk ${r.path}`
65
99
  const flags = r.options.map((o) => `[${o.definition}]`).join(' ')
100
+ const sig = signature(r, bin)
66
101
  // one line per bullet: a description on its own indented line reads as
67
102
  // hard-wrapped prose to the house lint
68
- out.push(`- \`${sig}${flags === '' ? '' : ` ${flags}`}\` — ${r.description}`)
103
+ out.push(`- \`${sig}${flags === '' ? '' : ` ${flags}`}\` — ${r.description}${r.runs != null ? ` → \`${r.runs}\`` : ''}${r.film != null ? ` (films a ${r.film})` : ''}`)
69
104
  for (const [label, cmd] of r.examples) out.push(` - ${label}: \`${cmd}\``)
70
105
  }
71
106
  return out.join('\n').trim()
72
107
  }
73
108
 
74
109
  /** the eager form: one line per command, its positional arguments and its
75
- * description. Flags and examples are `sk <command> --help`, so a page every
110
+ * description. Flags and examples are `<bin> <command> --help`, so a page every
76
111
  * session loads costs a line per command, not a screen */
77
- export function renderCompact(rows: CommandRow[]): string {
112
+ export function renderCompact(rows: CommandRow[], bin = 'sk'): string {
78
113
  const out: string[] = []
79
114
  let cat = ''
80
115
  for (const r of rows) {
81
116
  if (r.category !== cat) { cat = r.category; out.push(`\n### ${cat}\n`) }
82
- const sig = r.usage.replace(/^\$0/, 'sk').replace(/\s+\[(?:-|<\w+>)[^\]]*\]/g, '').trim() || `sk ${r.path}`
117
+ const sig = r.usage.replace(/^\$0/, bin).replace(/\s+\[(?:-|<\w+>)[^\]]*\]/g, '').trim() || `${bin} ${r.path}`
83
118
  out.push(`- \`${sig}\` — ${r.description}`)
84
119
  }
85
120
  return out.join('\n').trim()
86
121
  }
87
122
 
88
- export const BEGIN = '<!-- shipkit:commands -->'
89
- export const END = '<!-- /shipkit:commands -->'
123
+ /** the page every session loads: per category its everyday commands, a few
124
+ * examples, and how to list the rest. Generated whole */
125
+ export function renderEssentials(rows: CommandRow[], bin: string, command: string): string {
126
+ const out = [`# ${bin}: the everyday commands`, '', `Generated by \`${command}\` from the commands marked \`essential\`. Every command: \`${bin}\`, \`${bin} <family>\`, \`${bin} <command> --help\`. \`${bin} <words>\` runs as \`bun run cli <words>\` from the repo root, and as \`${bin}\` anywhere once \`bun link\` ran there.`]
127
+ const first = (r: CommandRow): string => r.path.split(' ')[0]!
128
+ const quiet: string[] = []
129
+ for (const category of [...new Set(rows.map((r) => r.category))]) {
130
+ const all = rows.filter((r) => r.category === category)
131
+ const rest = [...new Set(all.map(first))]
132
+ .map((w) => [w, all.filter((r) => first(r) === w && r.essential == null).length] as const)
133
+ .filter(([, n]) => n > 0)
134
+ .map(([w, n]) => `\`${bin} ${w}${all.some((r) => r.path === w) ? ' --help' : ''}\` (${n} command${n === 1 ? '' : 's'})`)
135
+ const mine = all.filter((r) => r.essential != null)
136
+ if (mine.length === 0) { quiet.push(...rest); continue }
137
+ out.push('', `## ${category}${rest.length > 0 ? ` (more: ${rest.join(', ')})` : ''}`, '')
138
+ for (const r of mine) {
139
+ const e = r.essential!
140
+ out.push(`- \`${bin} ${r.path}${e.args != null ? ` ${e.args}` : ''}\`: ${r.description}`)
141
+ for (const [label, cmd] of r.examples.slice(0, e.examples ?? 0)) out.push(` - \`${cmd}\`: ${label}`)
142
+ }
143
+ }
144
+ if (quiet.length > 0) out.push('', `## The other families: ${quiet.join(', ')}`)
145
+ return `${out.join('\n')}\n`
146
+ }
147
+
148
+ /** what a project CLI's registry must hold for its pages and its listing to
149
+ * teach right: a category, a description under the cap, and an example that
150
+ * starts with the command's own words. One line per breach */
151
+ export function shapeProblems(rows: CommandRow[], bin: string): string[] {
152
+ const out: string[] = []
153
+ for (const r of rows) {
154
+ if (r.category === 'Other') out.push(`${bin} ${r.path}: no category`)
155
+ if (r.description === '') out.push(`${bin} ${r.path}: no description`)
156
+ else if (r.description.length > DESCRIPTION_MAX) out.push(`${bin} ${r.path}: description over ${DESCRIPTION_MAX} characters (the rest goes in details)`)
157
+ if (!r.examples.some(([, cmd]) => cmd === `${bin} ${r.path}` || cmd.startsWith(`${bin} ${r.path} `))) out.push(`${bin} ${r.path}: no example starting with \`${bin} ${r.path}\``)
158
+ }
159
+ return out
160
+ }
161
+
162
+ /** the markers a page carries around the generated block: shipkit keeps its
163
+ * historical name, a project CLI uses its own */
164
+ export const markersFor = (bin: string): [string, string] => {
165
+ const n = namesOf(bin).includes('shipkit') ? 'shipkit' : bin
166
+ return [`<!-- ${n}:commands -->`, `<!-- /${n}:commands -->`]
167
+ }
168
+
169
+ export const [BEGIN, END] = markersFor('sk')
90
170
 
91
171
  /** replace the marked block in a host file. Returns null when the markers are
92
172
  * missing, so a renamed or hand-mangled host fails loud instead of silently
93
173
  * writing nothing. */
94
- export const spliceBlock = (host: string, block: string): string | null => spliceMarkedBlock(host, block, BEGIN, END)
174
+ export const spliceBlock = (host: string, block: string, bin = 'sk'): string | null => spliceMarkedBlock(host, block, ...markersFor(bin))
175
+
176
+ /** the page a project CLI's `--out` creates when it is missing: a title, how
177
+ * it is made, and the markers the block goes between */
178
+ export const newCommandsPage = (bin: string, path: string): string => {
179
+ const [begin, end] = markersFor(bin)
180
+ return `# ${bin}: every command\n\nGenerated between the markers by \`${bin} commands --out ${path}\` from the registry. \`${bin}\` lists every command, \`${bin} <command> --help\` prints one.\n\n${begin}\n${end}\n`
181
+ }
95
182
 
96
183
  export class CommandsCommand extends Command {
97
184
  static override paths = [['commands']]
@@ -109,33 +196,61 @@ export class CommandsCommand extends Command {
109
196
  ],
110
197
  })
111
198
 
112
- json = Option.Boolean('--json', false, { description: 'machine-readable [{path, category, description}]' })
113
- out = Option.String('--out', { description: 'host file carrying the shipkit:commands markers. The block between them is rewritten' })
114
- check = Option.Boolean('--check', false, { description: 'with --out: exit 1 if the block is stale, write nothing' })
115
- compact = Option.Boolean('--compact', false, { description: 'one line per command: no flags, no examples (they are `sk <command> --help`)' })
199
+ /** a project CLI holds its rows to shapeProblems (--out and --check fail on
200
+ * a breach), and its --out creates a missing page */
201
+ static shapeRule = false
202
+
203
+ json = Option.Boolean('--json', false, { description: 'machine-readable rows, with film and essential where a command carries them' })
204
+ out = Option.String('--out', { description: 'host file carrying the <bin>:commands markers. The block between them is rewritten' })
205
+ check = Option.Boolean('--check', false, { description: 'with --out or --essentials: exit 1 if a page is stale, write nothing' })
206
+ compact = Option.Boolean('--compact', false, { description: 'one line per command: no flags, no examples (they are `<bin> <command> --help`)' })
207
+ essentials = Option.String('--essentials', { description: 'the everyday commands page, rewritten whole from the commands marked essential' })
116
208
 
117
209
  async execute(): Promise<number> {
210
+ const bin = this.cli.binaryName
211
+ const classes = (this.context as { classes?: { paths?: string[][] }[] }).classes ?? []
118
212
  const rows = commandRows(
119
213
  this.cli.definitions() as Definition[],
120
214
  (this.cli.definitions as (o?: { detailed?: boolean }) => Definition[])({ detailed: true }),
215
+ bin,
216
+ classes,
121
217
  )
122
- if (this.out == null) {
123
- this.context.stdout.write(this.json ? `${JSON.stringify(rows)}\n` : `${(this.compact ? renderCompact : renderMarkdown)(rows)}\n`)
218
+ const write = (s: string): void => void this.context.stdout.write(s)
219
+ if (this.out == null && this.essentials == null) {
220
+ write(this.json ? `${JSON.stringify(rows)}\n` : `${(this.compact ? renderCompact : renderMarkdown)(rows, bin)}\n`)
124
221
  return 0
125
222
  }
126
- const { existsSync, readFileSync, writeFileSync } = await import('fs')
127
- if (!existsSync(this.out)) { this.context.stdout.write(`FAIL: ${this.out} does not exist\n`); return 1 }
128
- const host = readFileSync(this.out, 'utf8')
129
- const next = spliceBlock(host, (this.compact ? renderCompact : renderMarkdown)(rows))
130
- if (next == null) { this.context.stdout.write(`FAIL: ${this.out} has no ${BEGIN} / ${END} markers\n`); return 1 }
131
- if (next === host) { this.context.stdout.write(`ok: ${this.out} command index current (${rows.length} commands)\n`); return 0 }
223
+ if ((this.constructor as typeof CommandsCommand).shapeRule) {
224
+ const bad = shapeProblems(rows, bin)
225
+ for (const b of bad) write(`FAIL: ${b}\n`)
226
+ if (bad.length > 0) { write(' fix: give the row a category, a short description and an example that starts with its words\n'); return 1 }
227
+ }
228
+ const { existsSync, mkdirSync, readFileSync, writeFileSync: writeRaw } = await import('fs')
229
+ const { dirname } = await import('path')
230
+ const writeFileSync = (path: string, text: string): void => { mkdirSync(dirname(path), { recursive: true }); writeRaw(path, text) }
231
+ let code = 0
232
+ if (this.essentials != null) {
233
+ const cmd = `${bin} commands${this.out != null ? ` --out ${this.out}` : ''} --essentials ${this.essentials}`
234
+ const text = renderEssentials(rows, bin, cmd)
235
+ if (existsSync(this.essentials) && readFileSync(this.essentials, 'utf8') === text) write(`ok: ${this.essentials} current\n`)
236
+ else if (this.check) { write(`FAIL: ${this.essentials} is STALE — the essential commands moved\n fix: ${cmd}\n`); code = 1 }
237
+ else { writeFileSync(this.essentials, text); write(`ok: ${this.essentials} rewritten\n`) }
238
+ }
239
+ if (this.out == null) return code
240
+ const [begin, end] = markersFor(bin)
241
+ const project = (this.constructor as typeof CommandsCommand).shapeRule
242
+ if (!existsSync(this.out) && (!project || this.check)) { write(`FAIL: ${this.out} does not exist\n`); return 1 }
243
+ const host = existsSync(this.out) ? readFileSync(this.out, 'utf8') : newCommandsPage(bin, this.out)
244
+ const next = spliceBlock(host, (this.compact ? renderCompact : renderMarkdown)(rows, bin), bin)
245
+ if (next == null) { write(`FAIL: ${this.out} has no ${begin} / ${end} markers\n`); return 1 }
246
+ if (next === host) { write(`ok: ${this.out} command index current (${rows.length} commands)\n`); return code }
132
247
  if (this.check) {
133
- this.context.stdout.write(`FAIL: ${this.out} command index is STALE — the binary registers commands the page does not list\n`)
134
- this.context.stdout.write(` fix: shipkit commands${this.compact ? ' --compact' : ''} --out ${this.out}\n`)
248
+ write(`FAIL: ${this.out} command index is STALE — the binary registers commands the page does not list\n`)
249
+ write(` fix: ${bin === 'sk' ? 'shipkit' : bin} commands${this.compact ? ' --compact' : ''} --out ${this.out}\n`)
135
250
  return 1
136
251
  }
137
252
  writeFileSync(this.out, next)
138
- this.context.stdout.write(`ok: ${this.out} command index rewritten (${rows.length} commands)\n`)
139
- return 0
253
+ write(`ok: ${this.out} command index rewritten (${rows.length} commands)\n`)
254
+ return code
140
255
  }
141
256
  }
@@ -6,6 +6,7 @@ import { Command, Option } from 'clipanion'
6
6
  import { join } from 'path'
7
7
  import { npmLatest, resolveLatest, scanFleet, type FleetRepo } from '../fleet/scan-fleet.ts'
8
8
  import { short, updateFleet } from '../fleet/update-fleet.ts'
9
+ import { listFlag } from './list-flag.ts'
9
10
 
10
11
  export class FleetCommand extends Command {
11
12
  static override paths = [['fleet']]
@@ -32,7 +33,8 @@ export class FleetCommand extends Command {
32
33
  async execute(): Promise<number> {
33
34
  const root = this.root ?? join(process.cwd(), '..')
34
35
  let repos = scanFleet(root)
35
- if (this.only != null && this.only.length > 0) repos = repos.filter((r) => this.only!.includes(r.name))
36
+ const only = listFlag(this.only)
37
+ if (only.length > 0) repos = repos.filter((r) => only.includes(r.name))
36
38
  const w = (l: string) => this.context.stdout.write(`${l}\n`)
37
39
  if (repos.length === 0) {
38
40
  w(`skip: no repo under ${root} depends on an rvlib-* package`)