omakit 0.4.0 → 0.4.2

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/README.md CHANGED
@@ -1,122 +1,60 @@
1
1
  <p align="center">
2
- <img src="docs/media/banner.gif" alt="omakit" width="440">
2
+ <img src="https://raw.githubusercontent.com/mtolhuys/omakit/main/docs/media/banner.gif" alt="omakit" width="440">
3
3
  </p>
4
4
 
5
- The safe place to find out: everything knowable about an Omarchy Quattro plugin submission before you post it, on your own machine. Agent-first, read-only against the marketplace, posts nothing, zero dependencies.
5
+ The marketplace validates one exact commit of your plugin. Push a fix or comment "fixed", and nothing re-runs ([M6](docs/MEASUREMENTS.md#m6-the-validated-commit-falls-behind-silently-and-that-is-the-centre-of-this-tool)). omakit runs the marketplace's own checks locally, watches your submission and posts nothing.
6
6
 
7
7
  [![Built for Omarchy: App](https://raw.githubusercontent.com/tcballard/omarchy-badges/75975e5b5bf75e7ede3764bcd2950046f7abfe2c/badges/v1/omarchy-app.svg)](https://github.com/tcballard/omarchy-badges) [![npm version](https://img.shields.io/npm/v/omakit)](https://www.npmjs.com/package/omakit) [![CI status](https://img.shields.io/github/actions/workflow/status/mtolhuys/omakit/ci.yml?branch=main)](https://github.com/mtolhuys/omakit/actions/workflows/ci.yml) [![Socket](https://socket.dev/api/badge/npm/package/omakit)](https://socket.dev/npm/package/omakit)
8
8
 
9
- `omakit` is a zero-dependency Node CLI that checks an Omarchy Quattro plugin submission on your machine.
10
- It is for a coding agent or a person submitting a plugin.
11
- It never posts to the marketplace or writes into a plugin tree.
12
-
13
9
  ## Install
14
10
 
15
11
  ```bash
16
- npm install --global omakit
17
- omakit setup
18
- ```
19
-
20
- See [docs/INSTALL.md](docs/INSTALL.md) for the clone route, PATH, requirements and upgrading.
21
-
22
- ## Commands
23
-
24
- | Command | What it does |
25
- | --- | --- |
26
- | [`omakit setup`](docs/COMMANDS.md) | The environment, the pin, tab completion, and what to try first. |
27
- | [`omakit submit <plugin-repo>`](docs/SUBMIT.md) | Every check, the issue title and body; asks for a category and tags at a terminal. |
28
- | [`omakit watch [<issue-url>]`](docs/VALIDATION_WATCH.md) | Pick your marketplace issues, list them, or check all with `--all`. |
29
- | [`omakit verify <plugin-repo>`](docs/COMMANDS.md) | The official security baseline over the local transport; `--json` for the document. |
30
- | [`omakit parity`](docs/COMMANDS.md) | The baseline over GitHub versus the local transport, on real listings; writes the evidence. |
31
- | [`omakit audit [<plugin>]`](docs/AUDIT.md) | Installed third-party commits against the exact commits the marketplace validated. |
32
- | [`omakit weigh <plugin>`](docs/WEIGH.md) | What a plugin weighs on the shell, measured by restarting it without and with the plugin; asks first. |
33
- | [`omakit doctor`](docs/COMMANDS.md) | What is installed, what is pinned, and what has moved. |
34
- | [`omakit pin`](docs/COMMANDS.md) | What setup does for the pin, on its own. |
35
- | [`omakit upgrade`](docs/COMMANDS.md) | Updates omakit through its own installer: npm, or a fast-forward. |
36
- | [`omakit help --agent`](docs/COMMANDS.md) | The operating instructions, for the agent running this. |
37
-
38
- Normal terminal use also checks for a newer npm release at most once daily
39
- and shows the upgrade command. It installs nothing automatically; scripts
40
- and JSON stay quiet. `DISABLE_UPDATE_NOTIFIER=1` disables the notice, and
41
- `omakit doctor` checks explicitly. [Update behaviour](docs/INSTALL.md#updating).
42
-
43
- ### `submit`
44
-
45
- ```bash
46
- omakit submit <plugin-repo> --category Widgets --tags bar,quickshell
12
+ npm i -g omakit && omakit setup
13
+ npx skills add mtolhuys/omakit
47
14
  ```
48
15
 
49
- It decides whether the plugin is ready, refused, or already listed; [103 issues mention agent-control files that no automated check reports](docs/MEASUREMENTS.md).
16
+ Requirements: [Node >=22](package.json); Omarchy Quattro.
50
17
 
51
- ![omakit submit refusing a plugin with no license, a README that never says how to uninstall, and a reserved plugin id](docs/media/submit.gif)
18
+ Licence: [MIT](LICENSE).
52
19
 
53
- Read more: [docs/SUBMIT.md](docs/SUBMIT.md).
20
+ ## `omakit submit <plugin-repo>`
54
21
 
55
- ### `watch`
22
+ ![submit refusing a fixture plugin before any issue is posted](https://raw.githubusercontent.com/mtolhuys/omakit/main/docs/media/submit.gif)
56
23
 
57
- ```bash
58
- omakit watch <submission-issue-url>
59
- omakit watch --all
60
- omakit watch --list
61
- omakit watch # choose one or several issues at a terminal
62
- ```
24
+ Checks the exact commit with the marketplace's own baseline and, when ready, prints the exact issue title and body for you to paste. The GIF shows a refusal with three blocking checks and their fixes.
63
25
 
64
- It decides whether the marketplace validated the plugin's current commit; [73% of parked submissions have a HEAD the marketplace never saw](docs/MEASUREMENTS.md).
26
+ ## `omakit watch --all`
65
27
 
66
- Account-wide discovery uses your signed-in `gh` account and reads your open marketplace issues. `--user <login>` reads another public account. Batch output includes baseline results, labels and the latest human discussion; `current` compares commits and does not imply approval or publication. Each command takes one snapshot and posts nothing.
28
+ ![watch counts and two current issues, including human discussion](https://raw.githubusercontent.com/mtolhuys/omakit/main/docs/media/watch-all.gif)
67
29
 
68
- ![omakit watch reporting that a validated commit has fallen behind](docs/media/watch.gif)
30
+ Checks your submission commits and names the action that re-runs stale validation: edit the issue body. The GIF shows five CURRENT issues in the counts and the first two issues with a discussion; CURRENT means matching commits, not approval.
69
31
 
70
- Read more: [docs/VALIDATION_WATCH.md](docs/VALIDATION_WATCH.md).
32
+ ## `omakit audit`
71
33
 
72
- ### `weigh`
73
-
74
- ```bash
75
- omakit weigh <plugin-id-or-dir>
76
- ```
34
+ ![audit keeping drift rows and the DRIFT summary visible together](https://raw.githubusercontent.com/mtolhuys/omakit/main/docs/media/audit.gif)
77
35
 
78
- It measures what a plugin weighs on the shell, CPU and child processes, against a baseline taken the same minute; [in the lab, a 180 ms timer fixture measured 2.73% CPU above a 0.13% floor](docs/MEASUREMENTS.md).
36
+ Compares your installed plugin commits with the marketplace's validated commits. The GIF shows drift rows first: on the author's desktop, 9 of 18 audited plugins ran commits the marketplace never validated.
79
37
 
80
- ```text
81
- Weighs no CPU above the floor (0.13%) and runs 2 child processes using 8.2 MB and 0.1% CPU, on Omarchy 4.0.0.alpha, measured with omakit weigh on 2026-09-14
82
- ```
38
+ ## `omakit weigh <plugin>`
83
39
 
84
- It restarts your shell and asks first. Memory is a shell fact; CPU and child processes are the weight.
40
+ ![completed three-run desktop weighing with baseline samples and the noise floor](https://raw.githubusercontent.com/mtolhuys/omakit/main/docs/media/weigh.gif)
85
41
 
86
- Read more: [docs/WEIGH.md](docs/WEIGH.md).
42
+ Measures the shell with and without your plugin, reading Pss and CPU. The GIF shows three completed runs on the author's desktop, with baseline and plugin samples and a 0.33% CPU floor ([method](docs/WEIGH.md)).
87
43
 
88
44
  ## Evidence, not claims
89
45
 
90
- | Claim | Proof |
46
+ | Measurement | Evidence |
91
47
  | --- | --- |
92
- | The local transport produces the marketplace's own result | 30 of 30 identical, [docs/evidence/parity/](docs/evidence/parity/) |
93
- | A local run touches no network | run inside `unshare -rn`, [docs/evidence/offline/](docs/evidence/offline/) |
94
- | The generated body is well formed | the marketplace's own parser, `tests/unit/issue.test.mjs` |
95
- | Nothing writes to the marketplace | `tests/unit/read-only.test.mjs`, over every source file |
96
- | No agent-control file can reach a plugin | `tests/unit/self-containment.test.mjs` |
97
- | `weigh` restores `shell.json` on every exit path, and runs a frozen list of Omarchy commands | `tests/unit/weigh.test.mjs` against a fake `/proc` and stub commands, `tests/unit/read-only.test.mjs` |
98
- | The GIFs above are real output | captures and renderer in [docs/media/](docs/media/) |
99
-
100
- Committed evidence records a digest of each side rather than the results themselves, because findings about a specific third-party plugin are not this project's to publish.
48
+ | Baseline parity | 30/30 identical results, [recorded corpus](docs/evidence/parity/2026-09-12-local-vs-github-2.json), 2026-09-12 |
49
+ | Stale validated commit | 326/519 readable comparisons stale (62.8%); 64/583 unknown, [2026-09-15 data](docs/evidence/staleness/2026-09-15.json) |
50
+ | Registry churn | 4,201/4,293 registry-only commits in 30 days, 2026-09-13, [M7](docs/MEASUREMENTS.md#m7-the-registry-moves-by-the-hour-the-code-and-the-rules-move-by-the-week) |
51
+ | GIFs are recorded output | 5 GIFs with [captures and scenes](docs/media/README.md) |
52
+ | Posts nothing | 0 marketplace writes, [M10](docs/MEASUREMENTS.md#m10-readme-evidence-and-command-captures) |
53
+ | Zero dependencies | 0 runtime and 0 development dependencies, counted in [package.json](package.json) |
101
54
 
102
55
  ## Documentation
103
56
 
104
- | Document | For |
105
- | --- | --- |
106
- | [docs/INSTALL.md](docs/INSTALL.md) | install details, PATH, requirements, upgrading, and what Socket reports and why |
107
- | [docs/HOW.md](docs/HOW.md) | what omakit is doing, why it uses Node, the baseline and check labels |
108
- | [docs/COMMANDS.md](docs/COMMANDS.md) | command details, authentication and network behaviour |
109
- | [docs/AUDIT.md](docs/AUDIT.md) | installed plugin drift against marketplace-validated commits, with JSON origins |
110
- | [docs/SUBMIT.md](docs/SUBMIT.md) | every check and what it decides |
111
- | [docs/WEIGH.md](docs/WEIGH.md) | what `weigh` measures, the noise floor, the `shell.json` mutation and its restore, and the JSON contract |
112
- | [docs/VALIDATION_WATCH.md](docs/VALIDATION_WATCH.md) | the validation watch: what the marketplace validated, and what moves it |
113
- | [docs/MEASUREMENTS.md](docs/MEASUREMENTS.md) | every number, its method and its limits |
114
- | [docs/UPSTREAM_CONTRACT.md](docs/UPSTREAM_CONTRACT.md) | the seam, the pin, the boundaries |
115
- | [docs/MARKETPLACE.md](docs/MARKETPLACE.md) | who this actually helps |
116
- | [docs/PALETTE.md](docs/PALETTE.md) | every installed Omarchy theme measured, and which palette index each role gets |
117
- | [docs/TUI.md](docs/TUI.md) | what the terminal shows, and why it looks that way |
118
- | [AGENTS.md](AGENTS.md) | changing this repository |
119
-
120
- MIT. Derived work built on public data from
121
- `omacom/omarchy-plugin-marketplace`; not affiliated with or endorsed by that
122
- project.
57
+ - Using: [install](docs/INSTALL.md), [commands](docs/COMMANDS.md), [audience](docs/MARKETPLACE.md).
58
+ - Checks and measurements: [submit](docs/SUBMIT.md), [watch](docs/VALIDATION_WATCH.md), [audit](docs/AUDIT.md), [evidence](docs/MEASUREMENTS.md).
59
+ - Method docs: [how](docs/HOW.md), [weigh](docs/WEIGH.md), [upstream contract](docs/UPSTREAM_CONTRACT.md), [palette](docs/PALETTE.md), [terminal](docs/TUI.md).
60
+ - Contributing: [repository rules](AGENTS.md), [releasing](docs/RELEASING.md), [media](docs/media/README.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omakit",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "The safe place to find out: everything knowable about an Omarchy Quattro plugin submission before you post it, on your own machine. Agent-first, read-only against the marketplace, posts nothing, zero dependencies.",
5
5
  "license": "MIT",
6
6
  "author": "Maarten Tolhuijs",
@@ -92,8 +92,8 @@ own words, and so should you.
92
92
 
93
93
  The marketplace validates the pushed default-branch HEAD, not whatever is
94
94
  checked out locally. Run the check on the commit that will be pushed, then
95
- commit and push before the real submission. Of the 464 submissions parked in
96
- their author's court, 73% have a HEAD the marketplace never saw; that is the
95
+ commit and push before the real submission. On 2026-09-15, 326/519 readable author-fixes
96
+ comparisons were stale, with 64 of 583 issues unknown; that is the
97
97
  round this loop is meant to prevent.
98
98
 
99
99
  ## When the plugin is ready
@@ -133,8 +133,8 @@ strip files from their tree on your own.
133
133
 
134
134
  **`submission.validation-commit`.** The marketplace validates the commit it
135
135
  resolves when the issue is opened or edited, which is the pushed default-branch
136
- HEAD, not whatever is checked out locally. Push first, then submit. 73% of submissions
137
- parked in their author's court have a HEAD the marketplace never saw.
136
+ HEAD, not whatever is checked out locally. Push first, then submit. On 2026-09-15, 326/519 readable author-fixes comparisons were stale
137
+ (62.8%), with 64 of 583 issues unknown.
138
138
 
139
139
  ## What the baseline result means
140
140
 
@@ -18,10 +18,10 @@ the issue body**.
18
18
  submissions.
19
19
 
20
20
  So pushing a fix does nothing, and commenting "fixed in `abc123`" does nothing.
21
- Both feel like progress. Neither is. This is the single most common reason a
22
- submission sits still: of the 464 submissions parked in their author's court, 73%
23
- have a default-branch HEAD the marketplace never saw, and 82% of the authors whose
24
- push came after a review comment had also commented: engaged, and stuck.
21
+ Both feel like progress. Neither is. The full author-fixes queue was measured on 2026-09-15: 326/519 readable
22
+ comparisons were stale (62.8%), with 64 of 583 issues unknown. The older
23
+ 2026-09-12 sample found 68/93 readable issues stale (73.1%); that rate was
24
+ not a measurement of all 464 issues in the queue.
25
25
 
26
26
  ## Check it
27
27
 
@@ -18,6 +18,8 @@ local commit through the transport seam the marketplace tests itself
18
18
  | `tree.mjs` | The installable tree of a subject at one exact commit, from the Git object database. |
19
19
  | `plugin.mjs` | The root files the submission contract needs, and the declared plugin identity. |
20
20
  | `agent-control.mjs` | The recursive agent-control warning, and its remedy. |
21
+ | `review-cost.mjs` | The advisory review-cost verdict, shared account discovery, and path classification between dated validated snapshots. M4 and M9 carry its evidence. |
22
+ | `measure-review-cost.mjs` | Reproduces M9 across the open update population at live marketplace HEAD, with compare sources and explicit skipped reasons in JSON. |
21
23
  | `issue.mjs` | Renders the issue the way the form would, then has the marketplace's own parser judge it. |
22
24
  | `submit.mjs` | Assembles every check with its measured reason, and withholds the body when a blocking check fails. Three outcomes: `ready` (the body), `refused` (a blocking check failed) and `listed` (the plugin is already listed by its own repository: `identity.available` passes with the listing's record, the five body checks are omitted rather than drawn as waiting, no body exists on purpose, and `listing` carries the listed commit against the local one and the form to use for a newer commit). Decides the category and tags after the registry: a listed plugin, own or taken, is asked for neither; an unlisted one without them is asked through `ask.mjs` at a terminal, and is a usage error otherwise. Ends with `reproduce`, the command line that repeats the run without asking. Under `--offline` the validation-commit check is `skipped`, not passed: verdict `skipped`, listed under `skipped` and not `unknown`, never blocking, and the READY line says "1 check skipped (--offline)". |
23
25
  | `ask.mjs` | The two questions `submit` asks a person at a terminal, and only there: category and tags, numbered from the pinned form, with the marketplace's own presentation for the manifest's kinds (read from the pinned catalog builder) as the default where it is on the list. Prompts on stderr, nothing persisted. |
@@ -81,7 +83,8 @@ side rather than the findings themselves. The GitHub side uses whatever
81
83
  credential `github.mjs` resolves (a `gh` login, and only that), read-only.
82
84
 
83
85
  Two rules about the output, stated as rules because each is an exception to
84
- a wider one. Everything omakit writes itself stays within 80 columns; text
86
+ a wider one. Pipes use 80 columns; terminals use their available width up to
87
+ 120 columns. Text files compose at 80 columns without colour escapes. Text
85
88
  that will be posted verbatim, the marketplace's own baseline report and the
86
89
  issue body rendered from the pinned form, is never wrapped and may exceed 80,
87
90
  because a wrapped body would not be the body. And stdout is the whole result;
@@ -97,7 +100,9 @@ sequences and nothing else.
97
100
  built-in `fetch`, which does not read proxy environment variables by default.
98
101
  Behind a proxy, run them with `NODE_USE_ENV_PROXY=1`. `submit` reads two things
99
102
  online, the subject's default-branch HEAD and the marketplace's current
100
- registry, and `--offline` turns both off; `verify` needs no network at all
103
+ registry. A manual-review baseline also reads the account's open issue
104
+ discovery and issue bodies for batching advice; `--offline` turns these
105
+ reads off. `verify` needs no network at all
101
106
  beyond fetching a reviewer-mode subject, and `tests/parity/offline.mjs` proves
102
107
  it.
103
108
 
@@ -15,7 +15,7 @@
15
15
  // the report ends with the command line that repeats the run without asking.
16
16
 
17
17
  import { createInterface } from "node:readline"
18
- import { colourEnabled, STEP, action, styler, wrap } from "./style.mjs"
18
+ import { colourEnabled, outputColumns, STEP, action, styler, wrap } from "./style.mjs"
19
19
  import { resolveCategory, resolveTags } from "./form.mjs"
20
20
  import { watchIssueTitle } from "./watch.mjs"
21
21
 
@@ -52,10 +52,10 @@ function reader(input) {
52
52
  /** Ask one question until an answer resolves; `parse` returns { ok, value } or { ok: false, reason }. */
53
53
  async function question(lines, output, c, { name, heading, options, defaultIndexes, parse, wrapOptions = false, eofRemedy }) {
54
54
  const step = " ".repeat(STEP)
55
- output.write(`${wrap(heading, {}, c).join("\n")}\n`)
55
+ output.write(`${wrap(heading, { width: outputColumns(output) }, c).join("\n")}\n`)
56
56
  for (const [index, option] of options.entries()) {
57
57
  output.write(wrapOptions
58
- ? `${wrap(`${String(index + 1).padStart(2)} ${option}`, { indent: STEP }, c).join("\n")}\n`
58
+ ? `${wrap(`${String(index + 1).padStart(2)} ${option}`, { indent: STEP, width: outputColumns(output) }, c).join("\n")}\n`
59
59
  : `${step}${c("typeable", String(index + 1).padStart(2))} ${option}\n`)
60
60
  }
61
61
  const fallback = defaultIndexes.length ? defaultIndexes.map((index) => index + 1).join(",") : null
@@ -70,7 +70,7 @@ async function question(lines, output, c, { name, heading, options, defaultIndex
70
70
  const text = raw.trim() || (fallback ?? "")
71
71
  const parsed = parse(text)
72
72
  if (parsed.ok) return parsed.value
73
- output.write(`${wrap(parsed.reason, {}, c).join("\n")}\n`)
73
+ output.write(`${wrap(parsed.reason, { width: outputColumns(output) }, c).join("\n")}\n`)
74
74
  }
75
75
  }
76
76
 
@@ -49,7 +49,7 @@
49
49
  // letters come from the small font below, so renaming the tool is a change to
50
50
  // one string and not a redrawing job.
51
51
 
52
- import { code, colourEnabled, DENSITY, motionEnabled, MOTION, rule as floorRule } from "./style.mjs"
52
+ import { code, colourEnabled, DENSITY, motionEnabled, MOTION, outputColumns, rule as floorRule, wrap } from "./style.mjs"
53
53
  import { effectAvailable, playEffect } from "./effect.mjs"
54
54
 
55
55
  const ESC = "\u001b["
@@ -227,6 +227,15 @@ export async function banner(options = {}) {
227
227
  // and a piped run should differ from a watched one only in decoration.
228
228
  if (!enabled) return
229
229
 
230
+ // Never animate artwork across physical terminal rows: cursor-up would
231
+ // redraw the wrong row. A narrow terminal gets a compact wordmark instead.
232
+ if (stream.isTTY && Number.isFinite(stream.columns) && stream.columns > 0 && stream.columns < width) {
233
+ stream.write(`${c(code("name"), word.toUpperCase())}\n`)
234
+ if (options.tagline) stream.write(`${wrap(options.tagline, { width: outputColumns(stream) }, (name, text) => c(code(name), text)).join("\n")}\n`)
235
+ stream.write("\n")
236
+ return
237
+ }
238
+
230
239
  // A five-row animation redrawn with cursor-up needs five rows that stay put.
231
240
  // In a terminal with no room the screen scrolls under the animation, the
232
241
  // cursor-up lands a line off, and a row from an earlier frame is left stranded
@@ -34,7 +34,7 @@ import { updateCheckEnabled, updateNotice } from "./update-check.mjs"
34
34
  import { progress } from "./progress.mjs"
35
35
  import { banner, bannerEnabled } from "./banner.mjs"
36
36
  import { COMMANDS, renderSummary, renderUsage, TAGLINE } from "./usage.mjs"
37
- import { action, AUDIT_VERDICTS, colourEnabled, GUTTER, labelled, mark, styler, verdict, wrap } from "./style.mjs"
37
+ import { action, AUDIT_VERDICTS, colourEnabled, GUTTER, labelled, mark, outputColumns, styler, verdict, withOutputStream, wrap } from "./style.mjs"
38
38
  import { omakitCacheDir, withHomeAbbreviated } from "./paths.mjs"
39
39
  import { DEFAULTS as WEIGH_DEFAULTS, measureWeigh, planWeigh } from "../weigh/audit.mjs"
40
40
  import { confirmationQuestion, renderList, renderWeigh, renderPlan } from "../weigh/report.mjs"
@@ -68,6 +68,16 @@ const REMEDY = Object.freeze({
68
68
  "interrupted": "shell.json was restored; run it again when the desktop is yours to restart.",
69
69
  })
70
70
 
71
+ /*
72
+ * A command that has written its result leaves through `process.exitCode`,
73
+ * never `process.exit()`: stdout is an API, and on a pipe whose reader has
74
+ * not started reading yet the exit cuts the output. Measured on 0.4.1:
75
+ * `omakit submit <listed plugin> --json | (sleep 2; cat)` delivered 8,192 of
76
+ * 14,033 bytes, and a parser downstream saw invalid JSON. The failure
77
+ * states below still exit at once: they write one short block to stderr,
78
+ * and their callers use them the way a throw is used.
79
+ */
80
+
71
81
  /**
72
82
  * Every failure, in one register, on stderr. `usage` errors carry the
73
83
  * signature that was expected, so the remedy is the reference and not a
@@ -76,8 +86,10 @@ const REMEDY = Object.freeze({
76
86
  */
77
87
  function fail(code, message, exit = 1, remedy = REMEDY[code], body = () => []) {
78
88
  const c = styler(colourEnabled(process.stderr))
79
- const lines = [`${mark("fail", c)}${c("name", code)}`, ...wrap(message, { indent: GUTTER }, c), ...body(c)]
80
- if (remedy) lines.push(...action(remedy, c))
89
+ const lines = withOutputStream(process.stderr, () => [
90
+ `${mark("fail", c)}${c("name", code)}`, ...wrap(message, { indent: GUTTER }, c), ...body(c),
91
+ ...(remedy ? action(remedy, c) : []),
92
+ ])
81
93
  process.stderr.write(`${lines.join("\n")}\n`)
82
94
  process.exit(exit)
83
95
  }
@@ -88,9 +100,21 @@ function failFrom(error) {
88
100
  throw error
89
101
  }
90
102
 
103
+ /**
104
+ * The value of a valued option, written either way the table accepts,
105
+ * `--name value` or `--name=value`, the last occurrence winning as it does
106
+ * in options.mjs. Measured on 0.4.1: the table accepted `--out=FILE` and the
107
+ * value was looked up as the token after `--out`, so `doctor --out=x` wrote
108
+ * nothing and exited 0, and `submit --category=Widgets` said the flag was
109
+ * missing.
110
+ */
91
111
  function option(args, name) {
92
- const index = args.indexOf(name)
93
- return index >= 0 ? args[index + 1] : undefined
112
+ let value
113
+ for (let index = 0; index < args.length; index += 1) {
114
+ if (args[index] === name) value = args[index + 1]
115
+ else if (args[index].startsWith(`${name}=`)) value = args[index].slice(name.length + 1)
116
+ }
117
+ return value
94
118
  }
95
119
 
96
120
  /** The bare arguments, with every valued option's value (options.mjs, one table) left out. */
@@ -123,6 +147,12 @@ function emit(args, text) {
123
147
  }
124
148
  }
125
149
 
150
+ function reportText(args, render, result, options = {}) {
151
+ const out = option(args, "--out")
152
+ return withOutputStream(out ? { isTTY: false } : process.stdout,
153
+ () => render(result, { ...options, ...(out ? { colour: false } : {}) }))
154
+ }
155
+
126
156
  async function cmdSubmit(args) {
127
157
  const target = positionals(args)[0]
128
158
  if (!target) fail("usage", "submit needs a target: `omakit submit <target> --category <c> --tags <a,b>`", 2)
@@ -161,7 +191,8 @@ async function cmdSubmit(args) {
161
191
  const usage = error.usage
162
192
  if (json) {
163
193
  process.stdout.write(`${JSON.stringify({ usage }, null, 2)}\n`)
164
- process.exit(2)
194
+ process.exitCode = 2
195
+ return
165
196
  }
166
197
  const flags = usage.missing.join(" and ")
167
198
  fail("usage", `submit needs ${flags}: ${usage.missing.length === 1 ? "it is" : "they are"} an editorial choice nobody else can make, from the pinned form's own lists.`, 2,
@@ -174,10 +205,10 @@ async function cmdSubmit(args) {
174
205
  failFrom(error)
175
206
  }
176
207
  spinner.done()
177
- emit(args, args.includes("--json") ? `${JSON.stringify(result, null, 2)}\n` : renderSubmit(result))
208
+ emit(args, args.includes("--json") ? `${JSON.stringify(result, null, 2)}\n` : reportText(args, renderSubmit, result))
178
209
  // Three outcomes, two exit codes: `ready` and `listed` are both healthy
179
210
  // states, and only a refusal is a 1.
180
- process.exit(result.outcome === "refused" ? 1 : 0)
211
+ process.exitCode = result.outcome === "refused" ? 1 : 0
181
212
  }
182
213
 
183
214
  async function cmdWatch(args) {
@@ -215,8 +246,8 @@ async function cmdWatch(args) {
215
246
  }
216
247
  spinner.done()
217
248
  const render = result.mode === "list" ? renderWatchList : result.mode === "all" ? renderWatchAll : renderWatch
218
- emit(args, args.includes("--json") ? `${JSON.stringify(result, null, 2)}\n` : render(result))
219
- process.exit(result.verdict?.state === "unknown" || result.summary?.unknown > 0 ? 2 : 0)
249
+ emit(args, args.includes("--json") ? `${JSON.stringify(result, null, 2)}\n` : reportText(args, render, result))
250
+ process.exitCode = result.verdict?.state === "unknown" || result.summary?.unknown > 0 ? 2 : 0
220
251
  }
221
252
 
222
253
  async function cmdFrontDoor() {
@@ -235,23 +266,24 @@ async function cmdSetup(args) {
235
266
  if (args.includes("--completion")) {
236
267
  const identity = requirePin(ROOT).identity
237
268
  const result = await completionStep({ repoRoot: ROOT, pin: identity.commit, version: VERSION, askRc: false })
238
- process.exit(result.state === "ok" ? 0 : 1)
269
+ process.exitCode = result.state === "ok" ? 0 : 1
270
+ return
239
271
  }
240
272
  const result = await setup({ repoRoot: ROOT, entryPoint: resolve(ROOT, "bin/omakit"), yes: args.includes("--yes") })
241
- process.exit(result.ok ? 0 : 1)
273
+ process.exitCode = result.ok ? 0 : 1
242
274
  }
243
275
 
244
276
  async function cmdUpgrade(args) {
245
277
  const result = await upgrade({ repoRoot: ROOT, dryRun: args.includes("--dry-run") })
246
- process.exit(result.ok ? 0 : 1)
278
+ process.exitCode = result.ok ? 0 : 1
247
279
  }
248
280
 
249
281
  async function cmdDoctor(args) {
250
282
  const spinner = spinnerFor(args)
251
283
  const result = await doctor({ repoRoot: ROOT, offline: args.includes("--offline"), onPhase: spinner.phase })
252
284
  spinner.done()
253
- emit(args, args.includes("--json") ? `${JSON.stringify(result, null, 2)}\n` : renderDoctor(result))
254
- process.exit(result.problems ? 1 : 0)
285
+ emit(args, args.includes("--json") ? `${JSON.stringify(result, null, 2)}\n` : reportText(args, renderDoctor, result))
286
+ process.exitCode = result.problems ? 1 : 0
255
287
  }
256
288
 
257
289
  async function cmdVerify(args) {
@@ -293,7 +325,7 @@ async function cmdVerify(args) {
293
325
  const blockingRules = section.invoked && section.official && !section.official.error
294
326
  ? (await consequence(requirePin(ROOT).dir, section.official)).selectivelyBlockingRules
295
327
  : []
296
- emit(args, renderVerify(document, { blockingRules }))
328
+ emit(args, reportText(args, renderVerify, document, { blockingRules }))
297
329
  }
298
330
 
299
331
  async function cmdParity(args) {
@@ -317,7 +349,7 @@ async function cmdParity(args) {
317
349
  } catch (error) {
318
350
  failFrom(error)
319
351
  }
320
- process.exit(ok ? 0 : 1)
352
+ process.exitCode = ok ? 0 : 1
321
353
  }
322
354
 
323
355
  function notAudited(message, remedy = null, exit = 1) {
@@ -351,7 +383,7 @@ async function cmdAudit(args) {
351
383
  }
352
384
  if (parsed.options.has("--json")) process.stdout.write(json)
353
385
  else process.stdout.write(`${renderAudit(document)}\n`)
354
- process.exit(document.ok ? 0 : 1)
386
+ process.exitCode = document.ok ? 0 : 1
355
387
  }
356
388
 
357
389
  /**
@@ -391,7 +423,7 @@ async function cmdWeigh(args) {
391
423
  throw error
392
424
  }
393
425
  process.stdout.write(json ? `${JSON.stringify(list.rows, null, 2)}\n` : `${renderList(list)}\n`)
394
- process.exit(0)
426
+ return
395
427
  }
396
428
  if (!target && !all) notWeighed("usage", "weigh needs a plugin: `omakit weigh <plugin-id-or-dir>`, or `omakit weigh --all` for every enabled third-party plugin.", "omakit weigh <plugin-id-or-dir>", 2)
397
429
  if (target && all) notWeighed("usage", `--all weighs every enabled third-party plugin, so ${JSON.stringify(target)} is one argument more than it takes.`, "omakit weigh --all, or omakit weigh <plugin-id-or-dir>", 2)
@@ -420,7 +452,7 @@ async function cmdWeigh(args) {
420
452
  // The confirmation. The plan is printed either way, so the record says
421
453
  // what was agreed to; the question is asked only at a terminal on both
422
454
  // ends, and --yes is the only other way past it.
423
- narrate.write(`${renderPlan(plan, { colour: colourEnabled(narrate) }).join("\n")}\n`)
455
+ narrate.write(`${withOutputStream(narrate, () => renderPlan(plan, { colour: colourEnabled(narrate) })).join("\n")}\n`)
424
456
  if (!parsed.options.has("--yes")) {
425
457
  const interactive = !json && Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY)
426
458
  if (!interactive) notWeighed("not-confirmed", `this restarts the shell ${plan.restarts} times and edits shell.json for the duration; a pipe, an agent or --json cannot answer for the person whose shell it is.`, REMEDY["not-confirmed"], 2)
@@ -458,7 +490,6 @@ async function cmdWeigh(args) {
458
490
  } else {
459
491
  process.stdout.write(`\n${renderWeigh(document)}\n`)
460
492
  }
461
- process.exit(0)
462
493
  }
463
494
 
464
495
  const VERSION = JSON.parse(readFileSync(resolve(ROOT, "package.json"), "utf8")).version
@@ -487,12 +518,15 @@ const [command, ...rest] = process.argv.slice(2)
487
518
  // stays empty on success, and never for setup, which is the fix.
488
519
  if (updateCheckEnabled({ command, args: rest, stdinTTY: process.stdin.isTTY, stdoutTTY: process.stdout.isTTY, stderrTTY: process.stderr.isTTY })) {
489
520
  const notice = await updateNotice({ repoRoot: ROOT, version: VERSION })
490
- if (notice) process.stderr.write(`${wrap(notice, {}, styler(colourEnabled(process.stderr))).join("\n")}\n`)
521
+ if (notice) process.stderr.write(`${wrap(notice, { width: outputColumns(process.stderr) }, styler(colourEnabled(process.stderr))).join("\n")}\n`)
491
522
  }
492
523
 
493
524
  if (command !== "setup" && process.stderr.isTTY) {
494
525
  const notice = staleCompletionNotice({ version: VERSION })
495
- if (notice) process.stderr.write(`${styler(colourEnabled(process.stderr))("label", notice)}\n`)
526
+ if (notice) {
527
+ const c = styler(colourEnabled(process.stderr))
528
+ process.stderr.write(`${wrap(notice, { width: outputColumns(process.stderr) }).map((line) => c("label", line)).join("\n")}\n`)
529
+ }
496
530
  }
497
531
 
498
532
  if (command === "setup") {
@@ -556,12 +590,12 @@ if (command === "setup") {
556
590
  // The short list on a typo, not 53 lines of reference, in the same register
557
591
  // as every other failure: what happened, what it means, what to run.
558
592
  const c = styler(colourEnabled(process.stderr))
559
- process.stderr.write([
593
+ process.stderr.write(withOutputStream(process.stderr, () => [
560
594
  `${mark("fail", c)}${c("name", "unknown command")}`,
561
595
  ...wrap(`\`${command}\` is not something omakit does. The commands it has are listed below.`, { indent: GUTTER }, c),
562
596
  ...action("omakit help", c),
563
597
  "",
564
- renderSummary({ colour: colourEnabled(process.stderr), heading: false }),
565
- ].join("\n"))
598
+ renderSummary({ stream: process.stderr, colour: colourEnabled(process.stderr), heading: false }),
599
+ ].join("\n")))
566
600
  process.exit(2)
567
601
  }
@@ -180,10 +180,10 @@ export async function authenticatedUser() {
180
180
  }
181
181
 
182
182
  /** Repository issues by their creator. PRs are excluded; pagination never silently truncates. */
183
- export async function repositoryIssues(owner, repository, creator, { readJson = getJson, maxPages = 100 } = {}) {
183
+ export async function repositoryIssues(owner, repository, creator, { readJson = getJson, maxPages = 100, labels } = {}) {
184
184
  const all = []
185
185
  for (let page = 1; page <= maxPages; page += 1) {
186
- const query = new URLSearchParams({ creator, state: "open", sort: "updated", direction: "desc", per_page: "100", page: String(page) })
186
+ const query = new URLSearchParams({ ...(creator ? { creator } : {}), ...(labels ? { labels } : {}), state: "open", sort: "updated", direction: "desc", per_page: "100", page: String(page) })
187
187
  const batch = await readJson(`https://api.github.com/repos/${owner}/${repository}/issues?${query}`)
188
188
  if (!Array.isArray(batch)) throw new GitHubError("github-unavailable", "GitHub did not return an issue list")
189
189
  all.push(...batch.filter((item) => !item.pull_request))
@@ -205,6 +205,17 @@ export async function issueComments(owner, repository, number, maxPages = 10, re
205
205
  throw new GitHubError("comments-incomplete", `Issue #${number} exceeded ${maxPages} comment pages; its latest baseline cannot be determined`)
206
206
  }
207
207
 
208
+ /** Compare exact validated snapshots. The API caps its file list at 300: never classify a truncated diff. */
209
+ export async function compareCommits(repositoryUrl, previous, validated, { readJson = getJson } = {}) {
210
+ const match = String(repositoryUrl).match(/^https:\/\/github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?\/?$/)
211
+ if (!match || ![previous, validated].every((commit) => /^[a-f0-9]{40}$/i.test(commit))) throw new GitHubError("usage", "compare needs a repository and two full commit identifiers")
212
+ const url = `https://api.github.com/repos/${match[1]}/${match[2]}/compare/${previous}...${validated}?per_page=1`
213
+ const result = await readJson(url)
214
+ if (!["ahead", "identical"].includes(result?.status)) throw new GitHubError("compare-not-forward", "validated snapshots are not a forward comparison")
215
+ if (!Array.isArray(result.files) || result.files.length >= 300) throw new GitHubError("compare-incomplete", "compare file list unavailable or at the 300-file API limit")
216
+ return { url, files: result.files }
217
+ }
218
+
208
219
  /**
209
220
  * The current default-branch HEAD of a repository.
210
221
  *
@@ -0,0 +1,39 @@
1
+ // Reproduce M9 with GET-only issue discovery and compare reads. No sampling.
2
+ import { resolve } from "node:path"
3
+ import { pathToFileURL } from "node:url"
4
+ import { MARKETPLACE_PIN } from "./pin.mjs"
5
+ import { repositoryIssues } from "./github.mjs"
6
+ import { liveRegistry } from "./registry.mjs"
7
+ import { reviewPolicy } from "./review-cost.mjs"
8
+ import { validationWatchAll } from "./watch.mjs"
9
+
10
+ export async function measureReviewCost(repoRoot) {
11
+ const policy = await reviewPolicy(repoRoot)
12
+ const registry = await liveRegistry({ repoRoot })
13
+ if (registry.source !== "head") throw new Error(`cannot measure at HEAD: ${registry.reason}`)
14
+ const [owner, repository] = new URL(MARKETPLACE_PIN.repository).pathname.slice(1).split("/")
15
+ const openedAt = new Date().toISOString()
16
+ const subjects = await repositoryIssues(owner, repository, undefined, { labels: policy.updateLabel })
17
+ const issues = subjects.filter((subject) => subject.state === "open" && !subject.pull_request).map((subject) => ({
18
+ number: subject.number, url: `${MARKETPLACE_PIN.repository}/issues/${subject.number}`, title: subject.title,
19
+ state: subject.state, labels: subject.labels.map((label) => typeof label === "string" ? label : label.name),
20
+ }))
21
+ // Only the manual-review subset needs comment reads and comparisons. No plugin HEAD reads.
22
+ const manual = issues.filter((subject) => subject.labels.includes(policy.reviewLabel))
23
+ const batch = await validationWatchAll({ repoRoot, discovery: { account: null, marketplace: MARKETPLACE_PIN.repository, issues: manual },
24
+ readRegistry: async () => registry, github: { defaultBranchHead: async () => null } })
25
+ const counts = batch.reviewCostSummary
26
+ return {
27
+ measurement: "M9", openedAt, completedAt: new Date().toISOString(), marketplace: MARKETPLACE_PIN.repository,
28
+ marketplaceHead: registry.commit, command: "node tools/marketplace/measure-review-cost.mjs", sample: false,
29
+ pluginUpdates: issues.length, manualQueue: manual.length, manualQueueShare: issues.length ? manual.length / issues.length : null,
30
+ docsOnly: counts.docsOnly, compared: counts.compared, skipped: counts.skipped.length,
31
+ docsOnlyShareOfCompared: counts.compared ? counts.docsOnly / counts.compared : null,
32
+ docsOnlyShareOfManualQueue: counts.skipped.length || !manual.length ? null : counts.docsOnly / manual.length,
33
+ rows: batch.issues.map((row) => ({ issue: row.issue.number, ...row.documentationDiff })),
34
+ }
35
+ }
36
+
37
+ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
38
+ process.stdout.write(`${JSON.stringify(await measureReviewCost(resolve(process.cwd())), null, 2)}\n`)
39
+ }