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.
- package/CHANGELOG.md +18 -0
- package/package.json +2 -1
- package/src/assets/kits/identity-kit.ts +7 -3
- package/src/assets/run-assets.ts +18 -4
- package/src/checks/SK004-gitignore-policy.check.ts +11 -6
- package/src/checks/SK013-one-way-in-policy.check.ts +13 -8
- package/src/cli/cmd_CacheAudit.ts +3 -2
- package/src/cli/cmd_Calls.ts +53 -0
- package/src/cli/cmd_Ci.ts +4 -3
- package/src/cli/cmd_Commands.ts +158 -43
- package/src/cli/cmd_Fleet.ts +3 -1
- package/src/cli/cmd_GlobalProcesses.ts +7 -2
- package/src/cli/cmd_Shot.ts +63 -0
- package/src/cli/cmd_Start.ts +8 -4
- package/src/cli/cmd_Trailer.ts +108 -0
- package/src/cli/kit/builtins.ts +40 -0
- package/src/cli/kit/calls.ts +177 -0
- package/src/cli/kit/families.ts +67 -0
- package/src/cli/kit/index.ts +8 -0
- package/src/cli/kit/pages-check.ts +36 -0
- package/src/cli/kit/project-cli.ts +73 -0
- package/src/cli/kit/verb.ts +157 -0
- package/src/cli/list-flag.ts +3 -0
- package/src/cli.ts +13 -2
- package/src/core/layout.ts +10 -2
- package/src/core/migrate-layout.ts +31 -8
- package/src/core/one-way-in-policy.ts +12 -4
- package/src/mv/mv-md.ts +2 -1
- package/src/reflect/prompt-corpus.ts +5 -0
- package/src/scaffold/cli-skeleton.ts +149 -0
- package/src/scaffold/files/agent-index.md +2 -2
- package/src/scaffold/files/tsconfig.json +3 -0
- package/src/scaffold/files/verify-skill.md +1 -1
- package/src/scaffold/project-scaffold.ts +90 -12
- package/src/shipkit-files.ts +8 -4
- package/src/shot/client.ts +67 -0
- package/src/shot/clip-encoder.ts +146 -0
- package/src/shot/dom-clip.ts +124 -0
- package/src/shot/serve-shot.ts +54 -0
- package/src/shot/shooter.ts +343 -0
- package/src/shot/shot-ask.ts +189 -0
- package/src/shot/shot.shipkit.ts +14 -0
- package/src/smoke/run-smoke.ts +2 -2
- package/src/start-repo.ts +29 -14
- package/src/suite/judge-suites.ts +56 -10
- package/src/suite/select.ts +2 -5
- package/src/suite/suite-cli.ts +3 -2
- package/src/trailer/adapter.ts +23 -0
- package/src/trailer/build.ts +344 -0
- package/src/trailer/ffmpeg.ts +50 -0
- package/src/trailer/footage.ts +104 -0
- package/src/trailer/grammar.ts +915 -0
- package/src/trailer/graph.ts +323 -0
- package/src/trailer/load-unit.ts +14 -0
- package/src/trailer/look.ts +110 -0
- package/src/trailer/plan.ts +133 -0
- package/src/trailer/stitch.ts +150 -0
- package/src/trailer/titles.ts +274 -0
- package/src/trailer/url.ts +108 -0
- package/src/trailer/vertical-draw.ts +286 -0
- package/src/trailer/vertical.ts +148 -0
- package/src/trailer/vocab.ts +40 -0
- 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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/cli/cmd_Start.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|