omakit 0.1.5 → 0.1.6

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
@@ -105,9 +105,9 @@ owner's behalf. Zero dependencies, plain ESM, one entry point, no build step.
105
105
 
106
106
  ```bash
107
107
  omakit setup # the environment, the pin, tab completion, and what to try first
108
- omakit submit <plugin-repo> --category <c> --tags <a,b>
108
+ omakit submit <plugin-repo> # every check, the issue title and body; asks for a category and tags at a terminal
109
109
  omakit watch <issue-url> # the commit the marketplace validated, against the plugin's current HEAD
110
- omakit verify <plugin-repo> # the official security baseline over the local transport, reported verbatim
110
+ omakit verify <plugin-repo> # the official security baseline over the local transport; --json for the document
111
111
  omakit parity # the baseline over GitHub versus the local transport, on real listings; writes the evidence
112
112
  omakit doctor # what is installed, what is pinned, and what has moved
113
113
  omakit pin # what setup does for the pin, on its own
@@ -126,6 +126,25 @@ that commit. The completion script knows the subcommands and their flags,
126
126
  completes a directory for `<target>`, and offers the categories and tags the
127
127
  pin's submission form actually has.
128
128
 
129
+ `omakit verify` prints the official baseline result alone, with no Omakit
130
+ check around it: the subject, the pin, the transport and what the local
131
+ adapter assumes, then the marketplace's own outcome, each finding as a block
132
+ with its rule id, whether it blocks publication under the pinned policy, the
133
+ file and line, and the official text verbatim, then the marketplace's own
134
+ statement. `--json` prints the document itself, unchanged from earlier
135
+ releases, and `--out <file>` writes it; agents and the skills use those.
136
+
137
+ `omakit submit` reads the marketplace's registry first. A plugin that is
138
+ already listed is refused there and asked for nothing (measured on 0.1.5: it
139
+ exited 2 asking for `--category` and `--tags`, then would have said there was
140
+ nothing to submit). An unlisted plugin needs both, and they are an editorial
141
+ choice nobody else can make: at a terminal it asks, once each, with the form's
142
+ own lists numbered and the marketplace's own default for the manifest's kinds
143
+ offered where it is on the list; in a pipe, from an agent, or with `--json` it
144
+ is the usage error with the same lists, exit 2. Either way the report ends
145
+ with the command line that repeats the run without asking, and `--json`
146
+ carries it as `reproduce`.
147
+
129
148
  There is nothing to authenticate. If you have `gh auth login` done, omakit
130
149
  reads that credential for GET requests and stores nothing; a token in
131
150
  `GH_TOKEN` or `GITHUB_TOKEN` reaches it the same way, because `gh` honours
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omakit",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
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, posts nothing, zero dependencies.",
5
5
  "license": "MIT",
6
6
  "author": "Maarten Tolhuijs",
@@ -12,6 +12,19 @@ the issue is a separate, explicit act that needs the plugin owner's approval
12
12
  first, which is what the marketplace's own agent instructions require. Show the
13
13
  owner the title, the body and the verdicts, ask, and only then post it.
14
14
 
15
+ ## If omakit is not installed
16
+
17
+ ```bash
18
+ npm install --global omakit # Omarchy ships Node and npm through mise
19
+ omakit doctor # node, git, the pin, the credential source, and whether omakit is on PATH
20
+ ```
21
+
22
+ If `omakit` is not found after the install, run
23
+ `"$(npm prefix --global)/bin/omakit" setup`: it prints the one line that puts
24
+ npm's bin on PATH for the shell in `$SHELL`. Keep it current with
25
+ `omakit upgrade`, which updates the tool through npm at the exact version the
26
+ registry names and never moves the marketplace pin.
27
+
15
28
  ## Run it
16
29
 
17
30
  ```bash
@@ -19,14 +32,32 @@ omakit pin # once, and after any pin change: fetches the pinned marketplace
19
32
  omakit submit <path-to-the-plugin-repo> --category <category> --tags <a,b>
20
33
  ```
21
34
 
35
+ The pin is a sparse read-only checkout of one marketplace commit under
36
+ `$XDG_CACHE_HOME/omakit/marketplace` (or `~/.cache/omakit/marketplace`). Every
37
+ rule is read from it; nothing about the format is written in the tool.
38
+
22
39
  The plugin's name and id come from the root `manifest.json`. The repository URL
23
40
  comes from `origin`. You supply the category and the tags, because nobody else
24
41
  can: they are an editorial choice about where the plugin belongs.
25
42
 
26
43
  If you do not know which category and tags are allowed, run the command without
27
- them: that is a usage error (exit 2) whose message lists the controlled values,
28
- read from the pinned form, before any check runs. With `--json` the same lists
29
- come back under `usage`.
44
+ them: for you, in a pipe or with `--json`, that is a usage error (exit 2) whose
45
+ message lists the controlled values, read from the pinned form; with `--json`
46
+ the same lists come back under `usage`. A person at a terminal is asked
47
+ instead, once each, with the marketplace's own default for the manifest's
48
+ kinds. It is decided after the registry is read: a plugin that is already
49
+ listed is refused at `identity.available` and asked for nothing. Every report
50
+ ends with the command line that repeats the run without asking, and `--json`
51
+ carries it as `reproduce`.
52
+
53
+ To see the official baseline result alone, with no Omakit check around it:
54
+
55
+ ```bash
56
+ omakit verify <path-to-the-plugin-repo> --json
57
+ ```
58
+
59
+ `verify` without `--json` prints a report for a person; `--json` is the
60
+ document, unchanged between releases.
30
61
 
31
62
  Useful flags: `--notes` for the Maintainer notes field, `--suggest-tag` for the
32
63
  optional suggestion, `--name` when the manifest has no name, `--json` for a
@@ -29,6 +29,12 @@ push came after a review comment had also commented: engaged, and stuck.
29
29
  omakit watch https://github.com/omacom/omarchy-plugin-marketplace/issues/<number>
30
30
  ```
31
31
 
32
+ If `omakit` is not installed: `npm install --global omakit` (Omarchy ships Node
33
+ and npm through mise), then `omakit doctor`; if the command is not found after
34
+ the install, `"$(npm prefix --global)/bin/omakit" setup` prints the one line
35
+ that puts npm's bin on PATH. `omakit upgrade` keeps it current through npm.
36
+ Add `--json` for a machine-readable result.
37
+
32
38
  Read-only. It does not comment, label, or edit anything, and it cannot. It reads
33
39
  the default branch through the REST API when a credential is available, taking
34
40
  it from the operator's `gh` login (which itself honours `GH_TOKEN` and
@@ -10,7 +10,7 @@ local commit through the transport seam the marketplace tests itself
10
10
  | `pin.mjs` | The pin identity (one home) and the reproducible setup: `omakit pin` fetches exactly that commit into `$XDG_CACHE_HOME/omakit/marketplace` and refuses a modified checkout. |
11
11
  | `local-transport.mjs` | Answers the four request shapes the official resolver makes, from a local clone at the exact commit. No network, no credentials, no writes. |
12
12
  | `run-baseline.mjs` | Runs the pinned official baseline over either transport and reports the pin identity beside the result. |
13
- | `verify.mjs` | Builds the `marketplaceBaseline` section: pin, transport, assumptions, the official result verbatim, the statement. |
13
+ | `verify.mjs` | Builds the `marketplaceBaseline` section: pin, transport, assumptions, the official result verbatim, the statement. `omakit verify` renders it for a person (`renderVerify` in `report.mjs`) and prints the document itself behind `--json` and `--out`. |
14
14
  | `preflight.mjs` | Translates that result into what it will cause on submission, using the pinned policy, and renders the marketplace's own report text with its attestation marker stripped and asserted absent. |
15
15
  | `yaml.mjs` | A deliberately small YAML reader for the pinned issue form. Accepts that subset and throws on anything else. |
16
16
  | `form.mjs` | The submission contract, read from the form and cross-checked against the marketplace's own constants. |
@@ -19,11 +19,12 @@ local commit through the transport seam the marketplace tests itself
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
21
  | `issue.mjs` | Renders the issue the way the form would, then has the marketplace's own parser judge it. |
22
- | `submit.mjs` | Assembles every check with its measured reason, and withholds the body when a blocking check fails. |
22
+ | `submit.mjs` | Assembles every check with its measured reason, and withholds the body when a blocking check fails. Decides the category and tags after the registry: a listed plugin needs 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. |
23
+ | `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. |
23
24
  | `watch.mjs` | The validation watch: validated commit versus current default-branch HEAD, and the one action that refreshes it. |
24
25
  | `github.mjs` | Read-only GitHub access. GET only. The credential is your `gh` login, read through one frozen `gh auth token` call, and is never written anywhere. |
25
26
  | `style.mjs` | The visual system, defined once: the palette, the status vocabulary, the block ramp, the columns, the motion budgets, and the composition helpers every command draws with. `docs/TUI.md` explains it. |
26
- | `report.mjs` | Text rendering of submit, watch and doctor for the agent that runs this tool, and the person reading over its shoulder. |
27
+ | `report.mjs` | Text rendering of submit, watch, doctor and verify for the agent that runs this tool, and the person reading over its shoulder. |
27
28
  | `path-hint.mjs` | Is `omakit` reachable as a bare command, and if not, the one line that makes it so for the install that is here: a symlink for a clone, the npm prefix's `bin` on PATH for a package, said for the shell in `$SHELL`. `setup` and `doctor` print it; nothing writes an rc file. |
28
29
  | `usage.mjs` | The help text, as data. |
29
30
  | `completion.mjs` | A completion script for bash, zsh or fish, derived from the help data and the pin's form: the subcommands and flags are read out of `COMMANDS`, the categories and tags out of the pinned submission form, and the script says which pin it came from. `setup` installs it for the shell in `$SHELL`, the one file this tool writes outside its own checkout. |
@@ -34,10 +35,11 @@ local commit through the transport seam the marketplace tests itself
34
35
 
35
36
  ```text
36
37
  omakit pin
38
+ omakit submit /path/to/plugin-repo # asks for the category and tags at a terminal
37
39
  omakit submit /path/to/plugin-repo --category Widgets --tags bar,quickshell
38
40
  omakit submit https://github.com/owner/repo@<40-char sha> --category System --tags system
39
41
  omakit watch https://github.com/omacom/omarchy-plugin-marketplace/issues/4829
40
- omakit verify /path/to/plugin-repo
42
+ omakit verify /path/to/plugin-repo # --json for the document
41
43
  omakit parity --count 30
42
44
  ```
43
45
 
@@ -0,0 +1,140 @@
1
+ // The two questions `omakit submit` asks a person at a terminal, and only
2
+ // there: the category and the tags, which are an editorial choice nobody else
3
+ // can make. Measured before this: `omakit submit <a listed plugin>` exited 2
4
+ // asking for --category and --tags, and would then have refused at
5
+ // identity.available with "nothing to submit"; and an unlisted plugin got the
6
+ // same exit 2 from a person sitting at a terminal who could simply have been
7
+ // asked. So the questions come after the registry, and only when stdin and
8
+ // stdout are both terminals and --json is absent. An agent, a pipe or --json
9
+ // gets the usage error, unchanged.
10
+ //
11
+ // The prompt goes to stderr, so stdout stays the report. The default offered
12
+ // is the marketplace's own presentation for the manifest's kinds, read from
13
+ // the pinned catalog builder (registry.mjs catalogPresentation), and offered
14
+ // only when it is on the form's list. Nothing typed here is written anywhere:
15
+ // the report ends with the command line that repeats the run without asking.
16
+
17
+ import { createInterface } from "node:readline"
18
+ import { colourEnabled, STEP, action, styler, wrap } from "./style.mjs"
19
+ import { resolveCategory, resolveTags } from "./form.mjs"
20
+
21
+ export class AskError extends Error {
22
+ constructor(message) {
23
+ super(message)
24
+ this.name = "AskError"
25
+ this.code = "usage"
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Lines from stdin, one at a time, whether they arrive as they are typed or
31
+ * were all there before the first question (a scripted run). readline emits
32
+ * every buffered line at once, so they are queued rather than dropped; the
33
+ * end of stdin answers null.
34
+ */
35
+ function reader(input) {
36
+ const rl = createInterface({ input, terminal: false })
37
+ const pending = []
38
+ const waiting = []
39
+ let closed = false
40
+ rl.on("line", (line) => (waiting.length ? waiting.shift()(line) : pending.push(line)))
41
+ rl.on("close", () => {
42
+ closed = true
43
+ while (waiting.length) waiting.shift()(null)
44
+ })
45
+ return {
46
+ next: () => (pending.length ? Promise.resolve(pending.shift()) : closed ? Promise.resolve(null) : new Promise((resolve) => waiting.push(resolve))),
47
+ close: () => rl.close(),
48
+ }
49
+ }
50
+
51
+ /** Ask one question until an answer resolves; `parse` returns { ok, value } or { ok: false, reason }. */
52
+ async function question(lines, output, c, { name, heading, options, defaultIndexes, parse }) {
53
+ const step = " ".repeat(STEP)
54
+ output.write(`${wrap(heading, {}, c).join("\n")}\n`)
55
+ for (const [index, option] of options.entries()) {
56
+ output.write(`${step}${c("typeable", String(index + 1).padStart(2))} ${option}\n`)
57
+ }
58
+ const fallback = defaultIndexes.length ? defaultIndexes.map((index) => index + 1).join(",") : null
59
+ const prompt = `${action(fallback ? `${name} [${fallback}]:` : `${name}:`, c, { indent: 0 })[0]} `
60
+ for (;;) {
61
+ output.write(prompt)
62
+ const raw = await lines.next()
63
+ if (raw === null) {
64
+ output.write("\n")
65
+ throw new AskError(`stdin ended before the ${name} was answered; pass --${name} on the command line`)
66
+ }
67
+ const text = raw.trim() || (fallback ?? "")
68
+ const parsed = parse(text)
69
+ if (parsed.ok) return parsed.value
70
+ output.write(`${wrap(parsed.reason, {}, c).join("\n")}\n`)
71
+ }
72
+ }
73
+
74
+ /** A comma-separated answer of numbers or names, resolved against a list. */
75
+ function pick(text, options) {
76
+ const parts = String(text).split(",").map((part) => part.trim()).filter(Boolean)
77
+ const chosen = []
78
+ for (const part of parts) {
79
+ const number = /^\d+$/.test(part) ? Number(part) : null
80
+ const option = number !== null
81
+ ? options[number - 1]
82
+ : options.find((candidate) => candidate.toLowerCase() === part.toLowerCase())
83
+ if (!option) return { ok: false, reason: `"${part}" is not a number from 1 to ${options.length}, nor one of the names.` }
84
+ if (!chosen.includes(option)) chosen.push(option)
85
+ }
86
+ return { ok: true, value: chosen }
87
+ }
88
+
89
+ /**
90
+ * @param {{ contract: object, defaults: { category: string|null, tags: string[]|null }, missing: string[],
91
+ * input?: NodeJS.ReadStream, output?: NodeJS.WriteStream, colour?: boolean }} options
92
+ * `defaults` are the marketplace's own presentation for the manifest, offered
93
+ * only where they are on the form's list. `missing` names which of
94
+ * `--category` and `--tags` to ask for.
95
+ * @returns {Promise<{ category?: string, tags?: string[] }>}
96
+ */
97
+ export async function askChoices({ contract, defaults, missing, input = process.stdin, output = process.stderr, colour = colourEnabled(output) }) {
98
+ const c = styler(colour)
99
+ const lines = reader(input)
100
+ const answers = {}
101
+ try {
102
+ if (missing.includes("--category")) {
103
+ const known = defaults.category ? resolveCategory(contract, defaults.category) : { ok: false }
104
+ const index = known.ok ? contract.categories.indexOf(known.value) : -1
105
+ answers.category = await question(lines, output, c, {
106
+ name: "category",
107
+ heading: `category: one of the form's ${contract.categories.length}${index >= 0 ? `; the marketplace's own choice for this manifest's kinds is ${known.value}` : ""}`,
108
+ options: contract.categories,
109
+ defaultIndexes: index >= 0 ? [index] : [],
110
+ parse: (text) => {
111
+ if (!text) return { ok: false, reason: `There is no default for this manifest; answer with a number from 1 to ${contract.categories.length}.` }
112
+ const picked = pick(text, contract.categories)
113
+ if (!picked.ok) return picked
114
+ if (picked.value.length !== 1) return { ok: false, reason: "Exactly one category." }
115
+ return { ok: true, value: picked.value[0] }
116
+ },
117
+ })
118
+ }
119
+ if (missing.includes("--tags")) {
120
+ const known = defaults.tags?.length ? resolveTags(contract, defaults.tags.filter((tag) => resolveTags(contract, [tag]).ok)) : { ok: false }
121
+ const indexes = known.ok ? known.value.map((label) => contract.tagLabels.indexOf(label)) : []
122
+ answers.tags = await question(lines, output, c, {
123
+ name: "tags",
124
+ heading: `tags: 1 to ${contract.maximumTags} of the form's ${contract.tagLabels.length}, comma-separated${indexes.length ? `; the marketplace's own choice for this manifest's kinds is ${known.value.join(", ")}` : ""}`,
125
+ options: contract.tagLabels,
126
+ defaultIndexes: indexes,
127
+ parse: (text) => {
128
+ if (!text) return { ok: false, reason: `There is no default for this manifest; answer with 1 to ${contract.maximumTags} numbers, comma-separated.` }
129
+ const picked = pick(text, contract.tagLabels)
130
+ if (!picked.ok) return picked
131
+ const resolved = resolveTags(contract, picked.value)
132
+ return resolved.ok ? { ok: true, value: resolved.value } : { ok: false, reason: `${resolved.reason}.` }
133
+ },
134
+ })
135
+ }
136
+ } finally {
137
+ lines.close()
138
+ }
139
+ return answers
140
+ }
@@ -14,13 +14,14 @@
14
14
  import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
15
15
  import { dirname, resolve } from "node:path"
16
16
  import { fileURLToPath } from "node:url"
17
- import { ensurePin, MARKETPLACE_PIN } from "./pin.mjs"
17
+ import { ensurePin, MARKETPLACE_PIN, requirePin } from "./pin.mjs"
18
18
  import { marketplaceBaselineSection } from "./verify.mjs"
19
19
  import { resolveSubject, SubjectError } from "../subject/resolve.mjs"
20
- import { missingSubmitFlags, submitPreflight } from "./submit.mjs"
21
- import { submissionContract } from "./form.mjs"
20
+ import { submitPreflight } from "./submit.mjs"
21
+ import { askChoices } from "./ask.mjs"
22
22
  import { validationWatch } from "./watch.mjs"
23
- import { renderSubmit, renderWatch, renderDoctor } from "./report.mjs"
23
+ import { renderSubmit, renderWatch, renderDoctor, renderVerify } from "./report.mjs"
24
+ import { consequence } from "./preflight.mjs"
24
25
  import { doctor } from "./doctor.mjs"
25
26
  import { setup } from "./setup.mjs"
26
27
  import { upgrade } from "./upgrade.mjs"
@@ -98,29 +99,20 @@ function emit(args, text) {
98
99
  async function cmdSubmit(args) {
99
100
  const target = positionals(args)[0]
100
101
  if (!target) fail("usage", "submit needs a target: `omakit submit <target> --category <c> --tags <a,b>`", 2)
101
- // A missing category or tag list is a usage error, decided before any check
102
- // runs and before the spinner starts: nothing can be rendered without them,
103
- // and the values the form accepts are the answer, read from the pin.
104
- let usage = null
105
- try {
106
- usage = missingSubmitFlags(await submissionContract({ repoRoot: ROOT }), { category: option(args, "--category"), tags: option(args, "--tags") })
107
- } catch (error) {
108
- failFrom(error)
109
- }
110
- if (usage) {
111
- if (args.includes("--json")) {
112
- process.stdout.write(`${JSON.stringify({ usage }, null, 2)}\n`)
113
- process.exit(2)
102
+ const json = args.includes("--json")
103
+ const spinner = json ? { phase: () => {}, done: () => {} } : progress()
104
+ // A missing --category or --tags on an unlisted plugin is asked for, once
105
+ // each, when a person is at a terminal on both ends and no machine is
106
+ // reading the result. Anything else, a pipe, an agent, --json, gets the
107
+ // usage error with the form's lists, exit 2. Decided after the registry:
108
+ // a listed plugin is never asked for a choice that does not matter.
109
+ const interactive = !json && Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY)
110
+ const chooser = interactive
111
+ ? async (asked) => {
112
+ spinner.done()
113
+ return askChoices({ ...asked, input: process.stdin, output: process.stderr })
114
114
  }
115
- const flags = usage.missing.join(" and ")
116
- 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,
117
- `omakit submit ${target} --category <c> --tags <a,b>`,
118
- (c) => [
119
- ...labelled("categories", usage.categories.join(", "), c),
120
- ...labelled(`tags, 1 to ${usage.maximumTags}`, usage.tags.join(", "), c),
121
- ])
122
- }
123
- const spinner = args.includes("--json") ? { phase: () => {}, done: () => {} } : progress()
115
+ : undefined
124
116
  let result
125
117
  try {
126
118
  result = await submitPreflight({
@@ -134,9 +126,24 @@ async function cmdSubmit(args) {
134
126
  pluginName: option(args, "--name"),
135
127
  allowDirty: args.includes("--allow-dirty"),
136
128
  offline: args.includes("--offline"),
129
+ chooser,
137
130
  })
138
131
  } catch (error) {
139
132
  spinner.done()
133
+ if (error?.code === "usage" && error.usage) {
134
+ const usage = error.usage
135
+ if (json) {
136
+ process.stdout.write(`${JSON.stringify({ usage }, null, 2)}\n`)
137
+ process.exit(2)
138
+ }
139
+ const flags = usage.missing.join(" and ")
140
+ 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,
141
+ `omakit submit ${target} --category <c> --tags <a,b>`,
142
+ (c) => [
143
+ ...labelled("categories", usage.categories.join(", "), c),
144
+ ...labelled(`tags, 1 to ${usage.maximumTags}`, usage.tags.join(", "), c),
145
+ ])
146
+ }
140
147
  failFrom(error)
141
148
  }
142
149
  spinner.done()
@@ -207,7 +214,7 @@ async function cmdVerify(args) {
207
214
  fail(error?.code === "marketplace-unavailable" ? error.code : "baseline-unavailable", error.message)
208
215
  }
209
216
  spinner.done()
210
- emit(args, `${JSON.stringify({
217
+ const document = {
211
218
  subject: {
212
219
  repository: subject.repository,
213
220
  commit: subject.commit,
@@ -215,7 +222,18 @@ async function cmdVerify(args) {
215
222
  mode: subject.mode,
216
223
  },
217
224
  marketplaceBaseline: section,
218
- }, null, 2)}\n`)
225
+ }
226
+ // The JSON is the document itself, byte for byte what verify always
227
+ // printed, for --json and for --out; a person at the terminal gets the
228
+ // report, in the register submit uses for its checks.
229
+ if (args.includes("--json") || option(args, "--out")) {
230
+ emit(args, `${JSON.stringify(document, null, 2)}\n`)
231
+ return
232
+ }
233
+ const blockingRules = section.invoked && section.official && !section.official.error
234
+ ? (await consequence(requirePin(ROOT).dir, section.official)).selectivelyBlockingRules
235
+ : []
236
+ emit(args, renderVerify(document, { blockingRules }))
219
237
  }
220
238
 
221
239
  async function cmdParity(args) {
@@ -71,6 +71,42 @@ export function reservedNamespace(pinDir) {
71
71
  return match[1].endsWith(".") ? match[1] : `${match[1]}.`
72
72
  }
73
73
 
74
+ /**
75
+ * How the marketplace itself presents a plugin it lists, read out of the
76
+ * pinned `build-catalog.mjs` rather than copied: `categoryFor(kinds)` maps the
77
+ * manifest's kinds to a category in order of its `if` lines, with a fallback,
78
+ * and the tags are the first three kinds lowercased. `omakit submit` offers
79
+ * these as the default answer when it has to ask for a category and tags, so
80
+ * the default is the marketplace's own choice; a mapping that cannot be read
81
+ * offers no default. tests/unit/registry-figures.test.mjs pins what the
82
+ * mapping is at this commit, so a marketplace that changes it fails the suite
83
+ * until the docs follow.
84
+ *
85
+ * @returns {{ rules: Array<{ kinds: string[], category: string }>, fallback: string|null, tagsFromKinds: boolean }}
86
+ */
87
+ export function catalogPresentation(pinDir) {
88
+ const source = readFileSync(join(pinDir, CATALOG_BUILDER_PATH), "utf8")
89
+ const body = source.match(/function categoryFor\([^)]*\)\s*\{([\s\S]*?)\n\}/)?.[1] || ""
90
+ const rules = [...body.matchAll(/if \(((?:kinds\.includes\("[^"]+"\)(?:\s*\|\|\s*)?)+)\) return "([^"]+)";/g)]
91
+ .map((match) => ({ kinds: [...match[1].matchAll(/"([^"]+)"/g)].map((kind) => kind[1]), category: match[2] }))
92
+ const fallback = body.match(/\n\s*return "([^"]+)";\s*$/)?.[1] || null
93
+ const tagsFromKinds = /tags:\s*kinds\.slice\(0,\s*3\)\.map\(\(kind\) => kind\.toLowerCase\(\)\)/.test(source)
94
+ return { rules, fallback, tagsFromKinds }
95
+ }
96
+
97
+ /**
98
+ * The marketplace's own presentation for a manifest's kinds: the category its
99
+ * rules pick and the tags it would derive. Null where the mapping was not
100
+ * readable, so nothing is offered rather than something guessed.
101
+ */
102
+ export function defaultPresentation(presentation, kinds = []) {
103
+ const list = Array.isArray(kinds) ? kinds.filter((kind) => typeof kind === "string") : []
104
+ const rule = presentation.rules.find((candidate) => candidate.kinds.some((kind) => list.includes(kind)))
105
+ const category = presentation.rules.length && presentation.fallback ? (rule ? rule.category : presentation.fallback) : null
106
+ const tags = presentation.tagsFromKinds ? list.slice(0, 3).map((kind) => kind.toLowerCase()) : null
107
+ return { category, tags }
108
+ }
109
+
74
110
  function repositorySlug(value) {
75
111
  try {
76
112
  return new URL(String(value)).pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "").toLowerCase()
@@ -48,12 +48,7 @@ function checkBlock(check, c) {
48
48
  if (check.detail) out.push(...wrap(check.detail, { indent: GUTTER }, c))
49
49
  if (check.verdict === "fail") {
50
50
  // A path is the thing at fault, so it is yellow; its reason wraps under it.
51
- for (const path of check.paths) {
52
- out.push(...wrap(`- ${path}`, { indent: GUTTER + STEP, first: GUTTER })
53
- .map((line, index) => (index === 0
54
- ? `${body}${c("fail", "-")} ${c("placeholder", line.trimStart().slice(2))}`
55
- : `${" ".repeat(GUTTER + STEP)}${c("placeholder", line.trimStart())}`)))
56
- }
51
+ out.push(...pathLines(check.paths, c))
57
52
  for (const remedy of [].concat(check.remedy || [])) out.push(...action(remedy, c))
58
53
  // The measured reason is the point of the check, so it is not dimmed: only
59
54
  // its label is grey, and on a low-contrast theme the number still reads.
@@ -62,6 +57,42 @@ function checkBlock(check, c) {
62
57
  return out
63
58
  }
64
59
 
60
+ /**
61
+ * A shell command as an arrow, broken before a flag when it would not fit:
62
+ * a command is not prose, and a line break inside it is only valid with a
63
+ * backslash, the way the issue-creating example at the end of the report
64
+ * is printed.
65
+ */
66
+ function commandLines(command, c) {
67
+ // A flag and its value travel together; a quoted value is one word.
68
+ const words = String(command).match(/"(?:[^"\\]|\\.)*"|\S+/g) || []
69
+ const units = []
70
+ for (const word of words) {
71
+ if (units.length && units[units.length - 1].startsWith("--") && !units[units.length - 1].includes(" ") && !word.startsWith("--")) {
72
+ units[units.length - 1] += ` ${word}`
73
+ } else {
74
+ units.push(word)
75
+ }
76
+ }
77
+ const room = COLUMNS - STEP - " \\".length
78
+ const lines = []
79
+ let line = ""
80
+ for (const unit of units) {
81
+ const next = line ? `${line} ${unit}` : unit
82
+ if (line && next.length > room) {
83
+ lines.push(line)
84
+ line = unit
85
+ } else {
86
+ line = next
87
+ }
88
+ }
89
+ lines.push(line)
90
+ const step = " ".repeat(STEP)
91
+ return lines.flatMap((text, index) => (index === 0
92
+ ? action(`${text}${lines.length > 1 ? " \\" : ""}`, c, { indent: 0 })
93
+ : [`${step}${c("typeable", `${text}${index < lines.length - 1 ? " \\" : ""}`)}`]))
94
+ }
95
+
65
96
  export function renderSubmit(result, { colour = colourEnabled() } = {}) {
66
97
  const c = styler(colour)
67
98
  const out = []
@@ -106,7 +137,12 @@ export function renderSubmit(result, { colour = colourEnabled() } = {}) {
106
137
  else out.push(...wrap(check.detail, { indent: GUTTER }, c))
107
138
  out.push("")
108
139
  }
109
- out.push(failed.length === 1 ? "Fix it, then run submit again." : "Fix them, then run submit again.")
140
+ if (result.reproduce) {
141
+ out.push(failed.length === 1 ? "Fix it, then run submit again:" : "Fix them, then run submit again:")
142
+ out.push(...commandLines(result.reproduce, c))
143
+ } else {
144
+ out.push(failed.length === 1 ? "Fix it, then run submit again." : "Fix them, then run submit again.")
145
+ }
110
146
  return out.join("\n")
111
147
  }
112
148
 
@@ -131,6 +167,11 @@ export function renderSubmit(result, { colour = colourEnabled() } = {}) {
131
167
  out.push(`${step}${c("typeable", "--body-file <the body above>")}`)
132
168
  out.push("")
133
169
  out.push(...wrap(`After it is created: ${result.afterSubmitting}`, {}, c))
170
+ if (result.reproduce) {
171
+ out.push("")
172
+ out.push("The same run, without prompting:")
173
+ out.push(...commandLines(result.reproduce, c))
174
+ }
134
175
  return out.join("\n")
135
176
  }
136
177
 
@@ -176,6 +217,79 @@ export function renderWatch(result, { colour = colourEnabled() } = {}) {
176
217
  return out.join("\n")
177
218
  }
178
219
 
220
+ /** A list of paths at fault, the way a failing check prints them: a tinted dash, the path in the placeholder tint. */
221
+ function pathLines(paths, c, dash = "fail") {
222
+ return paths.flatMap((path) => wrap(`- ${path}`, { indent: GUTTER + STEP, first: GUTTER })
223
+ .map((line, index) => (index === 0
224
+ ? `${body}${c(dash, "-")} ${c("placeholder", line.trimStart().slice(2))}`
225
+ : `${" ".repeat(GUTTER + STEP)}${c("placeholder", line.trimStart())}`)))
226
+ }
227
+
228
+ /**
229
+ * `omakit verify` for a person: the subject, the pin, the transport and its
230
+ * assumptions, then the official result in the register submit uses for its
231
+ * checks, and the statement last. Nothing here is Omakit's judgement: the
232
+ * outcome, the disposition, each finding's title, reason and actions, and a
233
+ * refusal's message are the marketplace's own text verbatim; the one thing
234
+ * added is the tag on each finding, blocks publication or review-required,
235
+ * which is read from the pinned policy's selectively blocking rules
236
+ * (`blockingRules`). `--json` prints the document itself.
237
+ */
238
+ export function renderVerify(document, { colour = colourEnabled(), blockingRules = [] } = {}) {
239
+ const c = styler(colour)
240
+ const out = []
241
+ const { subject, marketplaceBaseline: section } = document
242
+ const tree = subject.cleanTree?.clean
243
+ ? `clean tree, proof ${subject.cleanTree.proof}`
244
+ : c("advisory", "dirty worktree")
245
+ out.push(...field("subject", subject.repository?.url || "no declared GitHub repository URL", c, { wrapValue: false }))
246
+ out.push(...field("commit", subject.commit, c, { wrapValue: false }))
247
+ out.push(...continuation(`${tree}, ${subject.mode} mode`, c))
248
+ out.push(...field("marketplace", `${section.pin.commit}, baseline ${section.pin.baselineVersion}, ${section.pin.enforcementMode}`, c))
249
+ out.push(...field("transport", section.transport, c))
250
+ if (section.assumedByAdapter?.length) out.push(...field("assumed", section.assumedByAdapter.join(", "), c))
251
+ out.push("")
252
+
253
+ const official = section.official
254
+ if (!section.invoked) {
255
+ out.push(`${head("unknown", "not run", "marketplace-pin", c)}`)
256
+ out.push(...wrap(section.skipReason || "the official baseline was not invoked", { indent: GUTTER }, c))
257
+ } else if (official?.error) {
258
+ // The official code refused the snapshot: that refusal is the result.
259
+ out.push(`${head("fail", official.error.code, "marketplace-pin", c)}`)
260
+ out.push(...wrap(official.error.message, { indent: GUTTER }, c))
261
+ for (const [key, value] of Object.entries(official.error)) {
262
+ if (key === "code" || key === "message") continue
263
+ out.push(...wrap(`- ${key}: ${typeof value === "string" ? value : JSON.stringify(value)}`, { indent: GUTTER + STEP, first: GUTTER }, c))
264
+ }
265
+ } else {
266
+ const findings = official.findings || []
267
+ const capabilities = official.capabilities || []
268
+ const state = official.outcome === "passed" ? "pass" : official.blocksApproval ? "fail" : "advisory"
269
+ out.push(`${head(state, official.outcome, "marketplace-pin", c)}`)
270
+ out.push(...wrap(official.outcome === "passed"
271
+ ? "no findings and no capabilities"
272
+ : `disposition ${official.disposition}, enforcement ${official.enforcementMode}, blocksApproval ${official.blocksApproval}`, { indent: GUTTER }, c))
273
+ for (const finding of findings) {
274
+ const blocks = blockingRules.includes(finding.ruleId)
275
+ out.push("")
276
+ out.push(head(blocks ? "fail" : "advisory", finding.ruleId, blocks ? "blocks publication" : "review-required", c))
277
+ out.push(...wrap([finding.title, finding.why].filter(Boolean).join(". ").replace(/\.\.\s/g, ". "), { indent: GUTTER }, c))
278
+ out.push(...pathLines((finding.evidence || []).map((entry) => `${entry.path}:${entry.line}`), c, blocks ? "fail" : "advisory"))
279
+ for (const remedy of finding.actions || []) out.push(...action(remedy, c))
280
+ }
281
+ for (const capability of capabilities) {
282
+ out.push("")
283
+ out.push(head("info", capability.id, "capability", c))
284
+ out.push(...wrap([capability.title, capability.why].filter(Boolean).join(". ").replace(/\.\.\s/g, ". "), { indent: GUTTER }, c))
285
+ out.push(...pathLines((capability.evidence || []).map((entry) => `${entry.path}:${entry.line}`), c, "info"))
286
+ }
287
+ }
288
+ out.push("")
289
+ out.push(...wrap(section.statement, {}, c))
290
+ return out.join("\n")
291
+ }
292
+
179
293
  const DOCTOR_STATE = { ok: "pass", advice: "advisory", problem: "fail", info: "info", unknown: "unknown" }
180
294
 
181
295
  export function renderDoctor(result, { colour = colourEnabled() } = {}) {
@@ -13,8 +13,8 @@
13
13
 
14
14
  import { resolveSubject, SubjectError } from "../subject/resolve.mjs"
15
15
  import { requirePin } from "./pin.mjs"
16
- import { submissionContract, resolveCategory, resolveTags } from "./form.mjs"
17
- import { idUniverse, checkIdentity, baselineFigures, figure, liveRegistry, registrySourceDetail } from "./registry.mjs"
16
+ import { submissionContract, resolveCategory, resolveTags, tagSlug } from "./form.mjs"
17
+ import { idUniverse, checkIdentity, baselineFigures, figure, liveRegistry, registrySourceDetail, catalogPresentation, defaultPresentation } from "./registry.mjs"
18
18
  import { readTree } from "./tree.mjs"
19
19
  import { inspectTree } from "./plugin.mjs"
20
20
  import { findAgentControl, REMEDY as AGENT_CONTROL_REMEDY } from "./agent-control.mjs"
@@ -108,13 +108,42 @@ export function missingSubmitFlags(contract, { category, tags } = {}) {
108
108
  return { missing, categories: [...contract.categories], tags: [...contract.tagLabels], maximumTags: contract.maximumTags }
109
109
  }
110
110
 
111
+ /** A shell word: quoted when it holds anything a shell would read. */
112
+ function shellWord(value) {
113
+ const text = String(value)
114
+ return /^[A-Za-z0-9_@%+=:,./~-]+$/.test(text) ? text : `"${text.replace(/(["\\$`])/g, "\\$1")}"`
115
+ }
116
+
117
+ /**
118
+ * The command line that repeats this run without asking anything: the target
119
+ * as given, the category and tags the run used wherever they came from, and
120
+ * every other flag that was given. Printed at the end of the report and
121
+ * carried in --json as `reproduce`, so the next run needs no prompt.
122
+ */
123
+ export function reproduceCommand({ target, category, tags, pluginName, notes, suggestedTag, allowDirty, offline }) {
124
+ const parts = ["omakit", "submit", shellWord(target)]
125
+ if (category) parts.push("--category", shellWord(category))
126
+ if (tags?.length) parts.push("--tags", shellWord(tags.map(tagSlug).join(",")))
127
+ if (pluginName) parts.push("--name", shellWord(pluginName))
128
+ if (notes) parts.push("--notes", shellWord(notes))
129
+ if (suggestedTag) parts.push("--suggest-tag", shellWord(suggestedTag))
130
+ if (allowDirty) parts.push("--allow-dirty")
131
+ if (offline) parts.push("--offline")
132
+ return parts.join(" ")
133
+ }
134
+
111
135
  /**
112
136
  * @param {{ repoRoot: string, target: string, category?: string, tags?: string|string[],
113
137
  * notes?: string, suggestedTag?: string, pluginName?: string,
114
138
  * allowDirty?: boolean, offline?: boolean,
115
- * readRegistry?: typeof liveRegistry }} options
139
+ * readRegistry?: typeof liveRegistry,
140
+ * chooser?: (question: { contract: object, defaults: object, missing: string[] }) => Promise<{ category?: string, tags?: string[] }> }} options
116
141
  * `readRegistry` is injectable for tests; the default reads the marketplace's
117
- * current HEAD, or the pin with `offline`.
142
+ * current HEAD, or the pin with `offline`. `chooser` answers for a missing
143
+ * --category or --tags on an unlisted plugin; without one, that is a usage
144
+ * error (SubmitError "usage", with the form's lists under `usage`), decided
145
+ * after the registry so a listed plugin is never asked for a choice that
146
+ * does not matter.
118
147
  */
119
148
  export async function submitPreflight(options) {
120
149
  const { repoRoot } = options
@@ -213,6 +242,30 @@ export async function submitPreflight(options) {
213
242
 
214
243
  const identity = checkIdentity(universe, { id: tree.pluginId, repositoryUrl: subject.repository.url })
215
244
  const identityRemedy = identity.ok ? null : identityRemedies(identity, universe, await newerCommitAction(pinDir))
245
+ const listed = identity.problems.some((problem) => problem.code === "plugin-id-listed" || problem.code === "submission-repository-listed")
246
+
247
+ // The category and the tags are decided here, after the registry. Measured
248
+ // before this: a listed plugin was asked for both and then told there was
249
+ // nothing to submit. A listed plugin needs neither; an unlisted plugin with
250
+ // either missing is asked through `chooser` when there is one, and is a
251
+ // usage error otherwise. The default offered is the marketplace's own
252
+ // presentation for the manifest's kinds, read from the pinned catalog
253
+ // builder.
254
+ let chosenCategory = options.category
255
+ let chosenTags = options.tags
256
+ const missing = missingSubmitFlags(contract, { category: chosenCategory, tags: chosenTags })
257
+ const moot = Boolean(missing) && listed
258
+ if (missing && !listed) {
259
+ if (!options.chooser) {
260
+ const error = new SubmitError("usage", `submit needs ${missing.missing.join(" and ")}`)
261
+ error.usage = missing
262
+ throw error
263
+ }
264
+ const defaults = defaultPresentation(catalogPresentation(pinDir), tree.manifest?.kinds)
265
+ const answers = await options.chooser({ contract, defaults, missing: missing.missing })
266
+ if (answers?.category) chosenCategory = answers.category
267
+ if (answers?.tags) chosenTags = answers.tags
268
+ }
216
269
  checks.push(check("identity.available", {
217
270
  source: "marketplace-pin",
218
271
  why: `The marketplace refuses \`plugin-id-listed\`, \`plugin-id-retired\`, \`reserved-plugin-id\` and \`submission-repository-listed\`. Checked here against ${figure(universe.counts.listedIds)} listed ids, ${figure(universe.counts.retiredIds)} retired ids and ${figure(universe.counts.listedRepositories)} listed repositories from the registry and catalog at the commit the detail names, and the reserved namespace from the pinned catalog builder. The registry is read from the marketplace's current HEAD when the network is there because the pin's copy is stale within hours: registry.json changed in 4,201 of the marketplace's 4,293 commits in the 30 days to 2026-09-13, about 140 a day (docs/MEASUREMENTS.md M7). Code and the form are only ever read from the pin.`,
@@ -226,8 +279,11 @@ export async function submitPreflight(options) {
226
279
  // --- the submission itself ------------------------------------------------
227
280
 
228
281
  const pluginName = String(options.pluginName || tree.pluginName || "").trim()
229
- const category = resolveCategory(contract, options.category)
230
- const tags = resolveTags(contract, options.tags)
282
+ const category = resolveCategory(contract, chosenCategory)
283
+ const tags = resolveTags(contract, chosenTags)
284
+ // For a listed plugin the editorial choice is moot: these wait on the
285
+ // identity check rather than failing for a flag nobody needed to give.
286
+ const mootWaitsOn = moot ? ["identity.available"] : []
231
287
 
232
288
  checks.push(check("submission.title", {
233
289
  source: "marketplace-pin",
@@ -245,6 +301,7 @@ export async function submitPreflight(options) {
245
301
  verdict: category.ok,
246
302
  detail: category.ok ? `category: ${category.value}` : `${category.reason}. Choose one of: ${contract.categories.join(", ")}`,
247
303
  remedy: category.ok ? null : "Pass --category with one of the listed values.",
304
+ waitedOn: mootWaitsOn,
248
305
  }))
249
306
 
250
307
  checks.push(check("submission.tags", {
@@ -253,11 +310,12 @@ export async function submitPreflight(options) {
253
310
  verdict: tags.ok,
254
311
  detail: tags.ok ? `tags: ${tags.value.join(", ")}` : `${tags.reason}. Choose from: ${contract.tagLabels.join(", ")}`,
255
312
  remedy: tags.ok ? null : "Pass --tags with 1 to 3 comma-separated values from the list.",
313
+ waitedOn: mootWaitsOn,
256
314
  }))
257
315
 
258
316
  // The body needs every field above and the repository URL below; the three
259
317
  // checks that read it wait on whichever of those failed.
260
- const bodyWaitsOn = [
318
+ const bodyWaitsOn = moot ? mootWaitsOn : [
261
319
  !pluginName && "submission.title",
262
320
  !category.ok && "submission.category",
263
321
  !tags.ok && "submission.tags",
@@ -409,6 +467,16 @@ export async function submitPreflight(options) {
409
467
  note: "The marketplace validates the default-branch HEAD it resolves when the issue is opened or edited. After submitting, use `omakit watch <issue-url>` to see whether that validated commit has fallen behind.",
410
468
  },
411
469
  plugin: { id: tree.pluginId, name: pluginName },
470
+ reproduce: reproduceCommand({
471
+ target: options.target,
472
+ category: category.ok ? category.value : null,
473
+ tags: tags.ok ? tags.value : null,
474
+ pluginName: options.pluginName,
475
+ notes: options.notes,
476
+ suggestedTag: options.suggestedTag,
477
+ allowDirty: options.allowDirty === true,
478
+ offline: options.offline === true,
479
+ }),
412
480
  checks,
413
481
  ready,
414
482
  blocking: blocking.map((entry) => entry.id),
@@ -45,6 +45,10 @@ export const COMMANDS = Object.freeze([
45
45
  lines: [
46
46
  "Every check that is knowable before submitting, the resolved commit, and",
47
47
  "the exact issue title and body. Prints them. Never posts anything.",
48
+ "A listed plugin is refused at identity and needs no category or tags.",
49
+ "An unlisted one without them is asked at a terminal, with the form's",
50
+ "lists numbered; in a pipe or with --json that is a usage error, exit 2.",
51
+ "The report ends with the command line that repeats the run unasked.",
48
52
  ],
49
53
  },
50
54
  {
@@ -56,10 +60,11 @@ export const COMMANDS = Object.freeze([
56
60
  ],
57
61
  },
58
62
  {
59
- signature: "omakit verify <target> [--allow-dirty] [--out <file>]",
63
+ signature: "omakit verify <target> [--allow-dirty] [--json] [--out <file>]",
60
64
  lines: [
61
65
  "The official marketplace security baseline over the local Git transport,",
62
- "reported verbatim beside the pin identity.",
66
+ "reported verbatim beside the pin identity. A report for a person; --json",
67
+ "prints the document itself, and --out writes it to a file.",
63
68
  ],
64
69
  },
65
70
  {