omakit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +229 -0
  3. package/bin/omakit +3 -0
  4. package/package.json +40 -0
  5. package/skills/omarchy-plugin-submit/SKILL.md +87 -0
  6. package/skills/omarchy-plugin-validation-watch/SKILL.md +62 -0
  7. package/tests/parity/corpus.mjs +62 -0
  8. package/tests/parity/run.mjs +194 -0
  9. package/tools/marketplace/README.md +64 -0
  10. package/tools/marketplace/agent-control.mjs +72 -0
  11. package/tools/marketplace/banner.mjs +307 -0
  12. package/tools/marketplace/cli.mjs +283 -0
  13. package/tools/marketplace/completion.mjs +263 -0
  14. package/tools/marketplace/doctor.mjs +156 -0
  15. package/tools/marketplace/effect.mjs +115 -0
  16. package/tools/marketplace/form.mjs +209 -0
  17. package/tools/marketplace/github.mjs +206 -0
  18. package/tools/marketplace/issue.mjs +80 -0
  19. package/tools/marketplace/local-transport.mjs +162 -0
  20. package/tools/marketplace/parity-output.mjs +26 -0
  21. package/tools/marketplace/paths.mjs +10 -0
  22. package/tools/marketplace/pin.mjs +203 -0
  23. package/tools/marketplace/plugin.mjs +64 -0
  24. package/tools/marketplace/preflight.mjs +164 -0
  25. package/tools/marketplace/progress.mjs +96 -0
  26. package/tools/marketplace/registry.mjs +216 -0
  27. package/tools/marketplace/report.mjs +192 -0
  28. package/tools/marketplace/run-baseline.mjs +72 -0
  29. package/tools/marketplace/setup.mjs +136 -0
  30. package/tools/marketplace/style.mjs +443 -0
  31. package/tools/marketplace/submit.mjs +341 -0
  32. package/tools/marketplace/tree.mjs +50 -0
  33. package/tools/marketplace/upgrade.mjs +179 -0
  34. package/tools/marketplace/usage.mjs +191 -0
  35. package/tools/marketplace/verify.mjs +70 -0
  36. package/tools/marketplace/watch.mjs +262 -0
  37. package/tools/marketplace/yaml.mjs +164 -0
  38. package/tools/subject/resolve.mjs +124 -0
@@ -0,0 +1,443 @@
1
+ // The visual system, defined once. Every command draws with what is here and
2
+ // with nothing of its own; tests/unit/style.test.mjs reads every source file
3
+ // and fails if a status mark, a glyph or a column width is typed anywhere else.
4
+ // The decisions and their trade-offs are written up in docs/TUI.md.
5
+ //
6
+ // The palette, and why it is only sixteen colours.
7
+ //
8
+ // Every colour here is an ANSI palette index, never a 24-bit or 256-colour
9
+ // escape. That is not a limitation, it is the point: the terminal decides what
10
+ // "cyan" looks like, so an Omarchy theme, or any other theme, recolours this
11
+ // tool by changing the terminal and nothing else. A hard-coded `38;2;57;197;207`
12
+ // would look the same on every theme, which means looking wrong on most of them.
13
+ // tests/unit/style.test.mjs fails if a truecolor or 256-colour escape appears
14
+ // anywhere in the sources.
15
+ //
16
+ // Colour is named by role, never by hue, at every call site: `c("typeable",
17
+ // text)`, not `c("cyan", text)`. ROLES below is the one table that says which
18
+ // palette index a role gets, and tests/unit/style.test.mjs fails on a hue
19
+ // name used anywhere else. The index for each role was chosen by measuring
20
+ // every theme installed on an Omarchy machine (32 of them, 5 light; the
21
+ // method, the table and the reasoning are in docs/PALETTE.md), against the
22
+ // worst case rather than the average:
23
+ //
24
+ // typeable blue (34). What you could type: a command, a flag, the remedy
25
+ // arrow, the `omakit` word. The one role a reader most needs to
26
+ // spot. Cyan was pixel-identical to the foreground on Matte
27
+ // Black and unreadable in three themes; blue is at least 21
28
+ // CIELAB units from the foreground in every chromatic theme,
29
+ // and on Matte Black it is the theme's own amber accent.
30
+ // placeholder yellow (33). What you replace, in <angle brackets>, and the
31
+ // advisory advisory and unknown marks. Unreadable in four themes, and
32
+ // unknown still the least bad slot: every alternative fails more
33
+ // themes or collapses into blue or green. The brackets and the
34
+ // block glyph carry it where the hue does not.
35
+ // pass green (32). Also an environment variable name, a setting in
36
+ // variable the same register.
37
+ // fail red (31), bold. At least 28 from the foreground everywhere.
38
+ // label dim (2). A label beside a value, punctuation, grouping, a
39
+ // punctuation rule, the info mark. Grey (90) was under 3:1 in 23 of 32
40
+ // info themes, 1.5:1 on Matte Black; dim is the foreground scaled
41
+ // by the terminal (measured: 0.66 in Alacritty, foot and
42
+ // kitty, 0.74 in Ghostty) and is readable in all 32.
43
+ // heading bold. A heading, a name: a check id, a commit you should read.
44
+ // name
45
+ // prose the foreground (39). The sentence itself.
46
+ //
47
+ // Hue is never the only carrier. Omarchy's Matte Black theme resolves every
48
+ // ANSI hue to nearly the same grey (measured: green reads orange, yellow reads
49
+ // red, cyan reads grey), and five installed themes are monochrome by design,
50
+ // so anything said by colour alone is not said there. Every distinction is
51
+ // therefore also carried by something a monochrome terminal has to honour:
52
+ // the density of a block glyph, the case of a word, the column a line starts
53
+ // in, or the blank line around a block.
54
+ //
55
+ // A sentence a person has to read is never grey and never dim. On a
56
+ // low-contrast theme grey on near-black is a line nobody can see, and the fix
57
+ // is not a brighter grey, it is not dimming prose in the first place. A label
58
+ // is one word, and it is the only thing that is ever dim. Emphasis inside a
59
+ // sentence comes from bold, or from tinting the one word you could type.
60
+ //
61
+ // Colour is applied only when stdout is a terminal, and never under NO_COLOR or
62
+ // a dumb TERM. The words never change: a piped run and a watched run say the
63
+ // same thing, and a test asserts that stripping the colour from one gives the
64
+ // other back character for character.
65
+
66
+ const SGR = {
67
+ reset: 0,
68
+ bold: 1,
69
+ dim: 2,
70
+ red: 31,
71
+ green: 32,
72
+ yellow: 33,
73
+ blue: 34,
74
+ magenta: 35,
75
+ cyan: 36,
76
+ white: 37,
77
+ default: 39,
78
+ grey: 90,
79
+ }
80
+
81
+ /**
82
+ * The roles, and the SGR each resolves to. This is the only place a hue is
83
+ * named; every call site names a role. `styler` accepts a dotted chain of
84
+ * roles and SGR names, so "fail" is "red.bold" and "typeable.bold" is a bold
85
+ * blue, but a source file outside this one may only use the role names.
86
+ */
87
+ export const ROLES = Object.freeze({
88
+ typeable: "blue",
89
+ placeholder: "yellow",
90
+ advisory: "yellow",
91
+ unknown: "yellow",
92
+ pass: "green",
93
+ variable: "green",
94
+ fail: "red.bold",
95
+ label: "dim",
96
+ punctuation: "dim",
97
+ info: "dim",
98
+ heading: "bold",
99
+ name: "bold",
100
+ prose: "default",
101
+ })
102
+
103
+ export const PALETTE = Object.freeze({ ...SGR })
104
+
105
+ /** The SGR parameter string for a role, for the two places that write an escape by hand (the wordmark, the progress line). */
106
+ export function code(role) {
107
+ return String(role)
108
+ .split(".")
109
+ .flatMap((part) => (ROLES[part] || part).split("."))
110
+ .map((part) => SGR[part])
111
+ .filter((n) => n !== undefined)
112
+ .join(";")
113
+ }
114
+
115
+ // --- geometry ---------------------------------------------------------------
116
+
117
+ /**
118
+ * The width everything is composed for. Eighty columns is the contract: a
119
+ * check's why-paragraph, a remedy, a refusal and the help all wrap inside it,
120
+ * and tests/unit/style.test.mjs renders every report and measures. The one
121
+ * thing exempt is the marketplace's own baseline report, which is printed
122
+ * verbatim because rewrapping somebody else's attestation would be editing it.
123
+ *
124
+ * The rule is over what omakit composes, not over every word it is handed.
125
+ * `overflows` below is the measurement, and it is the one both test files use.
126
+ */
127
+ export const COLUMNS = 80
128
+
129
+ /**
130
+ * The indent scale, in columns. Three stops, and every line in the tool starts
131
+ * at one of them:
132
+ *
133
+ * 0 a status line, a key, a heading, a verdict
134
+ * GUTTER the body of a check: its detail, its paths, its remedy, its reason
135
+ * LABEL the value beside a key, and a continuation of that value
136
+ *
137
+ * GUTTER is the width of a status mark plus two spaces, so a check body sits
138
+ * exactly under the check's name. LABEL is the width of the longest key the
139
+ * tool prints ("current HEAD") plus two spaces, so every key/value line in
140
+ * every command aligns to the same column. Both are asserted, not assumed.
141
+ */
142
+ export const GUTTER = 8
143
+ export const LABEL = 14
144
+
145
+ /** The indent unit under a heading: a command listed in the help, a note under a step. */
146
+ export const STEP = 2
147
+
148
+ // --- the block ramp -----------------------------------------------------------
149
+
150
+ /**
151
+ * One family of glyphs, used for everything that is not a letter. The wordmark,
152
+ * the progress track, the rule under a heading and the status marks all come
153
+ * from this ramp, so the tool has one motif rather than a collection of icons.
154
+ *
155
+ * Density is the carrier that survives a monochrome theme. A full block next to
156
+ * a floor line is a difference in ink, not in hue, and ink is the one thing
157
+ * every terminal draws.
158
+ */
159
+ export const DENSITY = Object.freeze({
160
+ full: "█", // █ the heaviest mark: a blocking failure, the scanner's head, the tool's own name
161
+ dark: "▓", // ▓ an advisory: heavy, but not solid
162
+ medium: "▒", // ▒ unknown: neither here nor there
163
+ light: "░", // ░ information: present, weightless
164
+ floor: "▁", // ▁ settled: a pass, a rule, the track the scanner runs on
165
+ })
166
+
167
+ /** The glyph that starts the one line that fixes things. */
168
+ export const ARROW = "→"
169
+
170
+ // --- the status vocabulary ---------------------------------------------------
171
+
172
+ /**
173
+ * Five states, and only five. Each has one glyph, one word and one tint, and
174
+ * every command prints them through `mark()` below. `pass` and `fail` are
175
+ * verdicts; `advisory` is a failure that does not block; `info` is a fact with
176
+ * no verdict; `unknown` is a check that could not be made.
177
+ *
178
+ * The glyph is the density ramp read as severity: the more ink, the more it
179
+ * matters. The word is the same thing in letters, and its case carries it too:
180
+ * FAIL is the only upper-case mark, so it is the one the eye lands on in a
181
+ * column of lower-case ones, with or without colour.
182
+ */
183
+ export const STATUS = Object.freeze({
184
+ pass: Object.freeze({ glyph: DENSITY.floor, word: "ok", tint: "pass" }),
185
+ fail: Object.freeze({ glyph: DENSITY.full, word: "FAIL", tint: "fail" }),
186
+ advisory: Object.freeze({ glyph: DENSITY.dark, word: "note", tint: "advisory" }),
187
+ info: Object.freeze({ glyph: DENSITY.light, word: "info", tint: "info" }),
188
+ unknown: Object.freeze({ glyph: DENSITY.medium, word: "?", tint: "unknown" }),
189
+ })
190
+
191
+ /** The width of the widest mark, "█ FAIL"; every mark is padded to it so the names beside them align. */
192
+ export const MARK_WIDTH = Math.max(...Object.values(STATUS).map((s) => `${s.glyph} ${s.word}`.length))
193
+
194
+ // --- motion ------------------------------------------------------------------
195
+
196
+ /**
197
+ * Every animation has a stated budget. The wordmark scan is over in one glance,
198
+ * because what follows it is the point and the scan has to be finished before
199
+ * it is in the way; the first version took 1.4 seconds. The
200
+ * progress line has no duration of its own, it lasts as long as the work does,
201
+ * so its budget is a frame rate: one redraw per 70ms is smooth on a terminal
202
+ * and cheap on a pty.
203
+ *
204
+ * The one text effect, `ttfx` over the wordmark, has a frame
205
+ * rate the arguments are frozen to and a budget that is a hard timeout, after
206
+ * which the process is killed and the wordmark is drawn at once. Measured on
207
+ * this machine, the pinned effect at 60 frames a second: 42 frames, 713ms on a
208
+ * pipe and 720ms on a pty, so the budget is twice that. It plays where the
209
+ * wordmark is drawn, a bare `omakit` and `setup`; without `ttfx` the 220ms
210
+ * scan runs there instead.
211
+ */
212
+ export const MOTION = Object.freeze({
213
+ bannerBudgetMs: 220,
214
+ progressFrameMs: 70,
215
+ effectFrameRate: 60,
216
+ effectBudgetMs: 1500,
217
+ })
218
+
219
+ // --- enabling -----------------------------------------------------------------
220
+
221
+ export function colourEnabled(stream = process.stdout, env = process.env) {
222
+ if (env.FORCE_COLOR && env.FORCE_COLOR !== "0") return true
223
+ if (env.NO_COLOR !== undefined && env.NO_COLOR !== "") return false
224
+ if (env.TERM === "dumb") return false
225
+ return Boolean(stream && stream.isTTY)
226
+ }
227
+
228
+ /**
229
+ * Can this stream hold something that redraws in place? A terminal can. A pipe
230
+ * cannot, and a dumb terminal cannot move its cursor. NO_COLOR is deliberately
231
+ * not consulted: it turns colour off, and a wordmark drawn in the terminal's
232
+ * own foreground or a progress line without a tint is exactly what it asks
233
+ * for. There is no opt-out of omakit's own: the tool reads no environment
234
+ * variable, and a pipe or TERM=dumb is how a person who wants no motion
235
+ * says so.
236
+ */
237
+ export function motionEnabled(stream, env = process.env) {
238
+ if (env.TERM === "dumb") return false
239
+ return Boolean(stream && stream.isTTY)
240
+ }
241
+
242
+ /**
243
+ * @param {boolean} enabled
244
+ * @returns {(name: string, text: string) => string}
245
+ */
246
+ export function styler(enabled) {
247
+ if (!enabled) return (_name, text) => String(text)
248
+ return (name, text) => {
249
+ const codes = code(name)
250
+ if (!codes) return String(text)
251
+ return `[${codes}m${text}`
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Prose is prose: it keeps the terminal's own foreground colour, because a
257
+ * sentence a person has to read is not context to be dimmed. Only what they
258
+ * could type is tinted, and a name in `backticks` is exactly that.
259
+ *
260
+ * The backticks themselves are dropped: they are markup for a reader of the
261
+ * source, and a terminal that can colour the word does not need them.
262
+ */
263
+ export function paintProse(line, c) {
264
+ // A line with nothing to type in it is written as it is: the terminal's
265
+ // foreground needs no escape to be the foreground.
266
+ if (!String(line).includes("`")) return String(line)
267
+ return String(line)
268
+ .split(/`([^`]+)`/)
269
+ .map((part, index) => (index % 2 ? c("typeable", part) : c("prose", part)))
270
+ .join("")
271
+ }
272
+
273
+ /** Strip every SGR sequence, so a width calculation counts characters a person sees. */
274
+ export function plain(text) {
275
+ return String(text).replace(/\[[0-9;]*m/g, "")
276
+ }
277
+
278
+ /** Visible width of a line, escapes excluded. */
279
+ export function width(text) {
280
+ return plain(text).length
281
+ }
282
+
283
+ /**
284
+ * Whether a finished line breaks the eighty-column rule. This is the
285
+ * measurement the tests make over the binary's own output, so it is defined
286
+ * here, next to the rule, rather than in each test.
287
+ *
288
+ * A line is over width when it is wider than COLUMNS and omakit had a choice
289
+ * about it. A line whose whole content, after its indent, is one word is a line
290
+ * `wrap()` was handed a word wider than the room and put on a line of its own,
291
+ * which is the rule: a path, a URL or a sha is never broken and never elided.
292
+ * Measured before this was decided: `doctor` prints the pinned checkout's
293
+ * absolute path, and from a checkout at a 91-column path the suite was red
294
+ * while from a 60-column one it was green, so the rule depended on where the
295
+ * repository was cloned. Eliding the path instead (`~/`, or a middle ellipsis)
296
+ * was the other option and was rejected because stdout is an API: an agent
297
+ * reads that line for the path, `~` is not a path it can open, and an
298
+ * ellipsis is not a path at all. The same
299
+ * holds for the missing-pin message, the `pin.size` remedy and the upgrade
300
+ * refusal, all of which name a directory.
301
+ *
302
+ * @param {string} line one line, escapes allowed
303
+ * @param {number} [total]
304
+ */
305
+ export function overflows(line, total = COLUMNS) {
306
+ const text = plain(line)
307
+ if (text.length <= total) return false
308
+ return /\s/.test(text.trim())
309
+ }
310
+
311
+ // --- composition ------------------------------------------------------------
312
+
313
+ /**
314
+ * Wrap prose to the contract width, and paint it.
315
+ *
316
+ * `indent` is the column the text starts in and is part of the width, which is
317
+ * the bug the earlier version had: it wrapped at 78 and then indented by 7, and
318
+ * a check's why-paragraph reached column 85. `first` is the indent of the first
319
+ * line when it differs, for a line whose label is already on it. A word longer
320
+ * than the room it has (a URL, a sha) is left whole on a line of its own rather
321
+ * than broken, because a broken URL is worse than a long one.
322
+ *
323
+ * A `backticked span` is one word: it is the thing you could type, it is never
324
+ * split across lines, and `paintProse` tints it and drops the backticks. The
325
+ * width is measured on what a person will see, so escapes and backticks cost
326
+ * nothing.
327
+ *
328
+ * @param {string} text
329
+ * @param {{ indent?: number, width?: number, first?: number }} [options]
330
+ * @param {(name: string, text: string) => string} [c]
331
+ * @returns {string[]} lines, indented and painted
332
+ */
333
+ export function wrap(text, { indent = 0, width: total = COLUMNS, first = indent } = {}, c = styler(false)) {
334
+ // A word is a run of non-spaces, or a backticked span with whatever
335
+ // punctuation clings to it: "(`omakit pin`)." is one word.
336
+ const words = String(text).match(/[^\s`]*`[^`]*`[^\s`]*|\S+/g) || []
337
+ const visible = (word) => width(word.replace(/`/g, ""))
338
+ const lines = []
339
+ let line = ""
340
+ let used = 0
341
+ let room = total - first
342
+ for (const word of words) {
343
+ const cost = visible(word)
344
+ if (line && used + 1 + cost > room) {
345
+ lines.push(line)
346
+ line = word
347
+ used = cost
348
+ room = total - indent
349
+ } else {
350
+ line = line ? `${line} ${word}` : word
351
+ used += line === word ? cost : 1 + cost
352
+ }
353
+ }
354
+ if (line) lines.push(line)
355
+ return lines.map((entry, index) => `${" ".repeat(index === 0 ? first : indent)}${paintProse(entry, c)}`)
356
+ }
357
+
358
+ /**
359
+ * A status mark, padded to MARK_WIDTH and followed by two spaces, so what comes
360
+ * after it starts at GUTTER. This is the only place a status is turned into
361
+ * characters.
362
+ *
363
+ * @param {"pass"|"fail"|"advisory"|"info"|"unknown"} state
364
+ * @param {(name: string, text: string) => string} c
365
+ */
366
+ export function mark(state, c) {
367
+ const status = STATUS[state]
368
+ if (!status) throw new Error(`style: no status named ${state}`)
369
+ const text = `${status.glyph} ${status.word}`
370
+ return `${c(status.tint, text)}${" ".repeat(GUTTER - text.length)}`
371
+ }
372
+
373
+ /**
374
+ * A verdict line: the closing word of a command, in the register of the state
375
+ * it reports. "█ REFUSED", "▁ Ready", "█ VALIDATION STALE". The word takes the tint
376
+ * and the weight; what follows it is a sentence and keeps the foreground.
377
+ */
378
+ export function verdict(state, word, text, c) {
379
+ const status = STATUS[state]
380
+ if (!status) throw new Error(`style: no status named ${state}`)
381
+ const head = `${status.glyph} ${word}`
382
+ const tint = (ROLES[status.tint] || status.tint).includes("bold") ? status.tint : `${status.tint}.bold`
383
+ const lines = text ? wrap(text, { indent: head.length + 2 }, c) : []
384
+ return lines.length ? [`${c(tint, head)} ${lines[0].trimStart()}`, ...lines.slice(1)] : [c(tint, head)]
385
+ }
386
+
387
+ /**
388
+ * A key/value line. The key is a label, so it is grey; the value starts at
389
+ * LABEL and wraps under itself. A key longer than the column would break the
390
+ * alignment, so it throws rather than shift everything below it.
391
+ */
392
+ export function field(key, value, c, { wrapValue = true } = {}) {
393
+ if (key.length > LABEL - 2) throw new Error(`style: key "${key}" is wider than the label column`)
394
+ const label = `${c("label", key)}${" ".repeat(LABEL - key.length)}`
395
+ const lines = wrapValue ? wrap(value, { indent: LABEL }, c) : [String(value)]
396
+ return [`${label}${lines[0].trimStart()}`, ...lines.slice(1)]
397
+ }
398
+
399
+ /** A continuation of the last field's value, at the same column. */
400
+ export function continuation(value, c) {
401
+ return wrap(value, { indent: LABEL }, c)
402
+ }
403
+
404
+ /**
405
+ * The one line that fixes things: an arrow in the gutter, cyan text, wrapped
406
+ * under itself. There is exactly one of these under any failure, and it is the
407
+ * only line in the tool that starts with an arrow, so it can be found by shape.
408
+ */
409
+ export function action(text, c, { indent = GUTTER } = {}) {
410
+ // Painted after wrapping, and all of it cyan: the whole line is the thing
411
+ // to do, so a backticked word inside it has nothing to stand out from.
412
+ const lines = wrap(text, { indent: indent + 2 })
413
+ return lines.map((line, index) => (index === 0
414
+ ? `${" ".repeat(indent)}${c("typeable.bold", ARROW)} ${c("typeable", line.trimStart())}`
415
+ : `${" ".repeat(indent + 2)}${c("typeable", line.trimStart())}`))
416
+ }
417
+
418
+ /**
419
+ * A labelled body line: a short label in grey ("why"), two spaces, then prose
420
+ * in the foreground, wrapped under the prose so the label stands alone in its
421
+ * own column and can be skipped by a reader who has already read it once.
422
+ */
423
+ export function labelled(label, text, c, { indent = GUTTER } = {}) {
424
+ const gap = label.length + 2
425
+ const lines = wrap(text, { indent: indent + gap }, c)
426
+ return lines.map((line, index) => (index === 0
427
+ ? `${" ".repeat(indent)}${c("label", label)} ${line.trimStart()}`
428
+ : line))
429
+ }
430
+
431
+ /**
432
+ * The one separator: a heading on its own line, then a floor rule under it in
433
+ * grey. It is the same shape the wordmark uses (the name, then its rule), so a
434
+ * section of a report and the front door of the tool are drawn by one idea.
435
+ */
436
+ export function section(title, c, { width: total = COLUMNS } = {}) {
437
+ return [c("heading", title), c("punctuation", DENSITY.floor.repeat(total))]
438
+ }
439
+
440
+ /** A rule with no heading, the same floor, for a wordmark or a block that names itself. */
441
+ export function rule(c, { width: total = COLUMNS, tint = "punctuation" } = {}) {
442
+ return c(tint, DENSITY.floor.repeat(total))
443
+ }