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,283 @@
1
+ #!/usr/bin/env node
2
+ // The single entry point.
3
+ //
4
+ // omakit pin fetch or verify the pinned marketplace checkout
5
+ // omakit submit <target> ... everything knowable before submitting; prints, never posts
6
+ // omakit watch <issue-url> is this submission's validated commit still current?
7
+ // omakit verify <target> the official baseline over the local transport, verbatim
8
+ // omakit parity [--count n] prove the local transport equals the GitHub transport
9
+ //
10
+ // Nothing here writes to the marketplace. There is no POST, PATCH, PUT or
11
+ // DELETE anywhere in this repository, and `tests/unit/read-only.test.mjs`
12
+ // proves it.
13
+
14
+ import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
15
+ import { dirname, resolve } from "node:path"
16
+ import { fileURLToPath } from "node:url"
17
+ import { ensurePin, MARKETPLACE_PIN } from "./pin.mjs"
18
+ import { marketplaceBaselineSection } from "./verify.mjs"
19
+ import { resolveSubject, SubjectError } from "../subject/resolve.mjs"
20
+ import { submitPreflight } from "./submit.mjs"
21
+ import { validationWatch } from "./watch.mjs"
22
+ import { renderSubmit, renderWatch, renderDoctor } from "./report.mjs"
23
+ import { doctor } from "./doctor.mjs"
24
+ import { setup } from "./setup.mjs"
25
+ import { upgrade } from "./upgrade.mjs"
26
+ import { progress } from "./progress.mjs"
27
+ import { banner, bannerEnabled } from "./banner.mjs"
28
+ import { COMMANDS, renderSummary, renderUsage, TAGLINE } from "./usage.mjs"
29
+ import { action, colourEnabled, GUTTER, mark, styler, wrap } from "./style.mjs"
30
+ import { omakitCacheDir } from "./paths.mjs"
31
+ import { parityOutput } from "./parity-output.mjs"
32
+
33
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..")
34
+
35
+ /**
36
+ * The one action for each way a command can stop. A failure state says what
37
+ * happened (the code), what it means (the message the module raised) and the
38
+ * single command that fixes it, in that order, every time. A code with no
39
+ * entry here gets the first two and no arrow, which is the honest rendering of
40
+ * a state nobody has written a fix for yet.
41
+ */
42
+ const REMEDY = Object.freeze({
43
+ "usage": "omakit help",
44
+ "marketplace-unavailable": "omakit pin",
45
+ "dirty-worktree": "Commit the changes, or pass --allow-dirty to check the tree as it is.",
46
+ "subject-not-found": "Pass a local Git repository path, or <https url>@<40-char sha>.",
47
+ "not-a-git-repository": "Pass a local Git repository path, or <https url>@<40-char sha>.",
48
+ "commit-not-found": "Commit first; the checks read the tree at an exact commit, never the working copy.",
49
+ "network-unavailable": "Connect to the network, then run it again.",
50
+ "github-unavailable": "Wait for GitHub, then run it again. `gh auth login` raises the rate limit if that is what ran out.",
51
+ "not-found": "Check the issue URL: it has to be an existing issue on the marketplace repository.",
52
+ "head-unreadable": "Check that the plugin repository is public and its URL is right.",
53
+ })
54
+
55
+ /**
56
+ * Every failure, in one register, on stderr. `usage` errors carry the
57
+ * signature that was expected, so the remedy is the reference and not a
58
+ * restatement of the message.
59
+ */
60
+ function fail(code, message, exit = 1, remedy = REMEDY[code]) {
61
+ const c = styler(colourEnabled(process.stderr))
62
+ const lines = [`${mark("fail", c)}${c("name", code)}`, ...wrap(message, { indent: GUTTER }, c)]
63
+ if (remedy) lines.push(...action(remedy, c))
64
+ process.stderr.write(`${lines.join("\n")}\n`)
65
+ process.exit(exit)
66
+ }
67
+
68
+ /** A thrown error becomes a failure state when it carries a code; anything else is a bug and keeps its stack. */
69
+ function failFrom(error) {
70
+ if (error?.code && typeof error.code === "string") fail(error.code, error.message, error.code === "usage" ? 2 : 1, error.remedy || REMEDY[error.code])
71
+ throw error
72
+ }
73
+
74
+ function option(args, name) {
75
+ const index = args.indexOf(name)
76
+ return index >= 0 ? args[index + 1] : undefined
77
+ }
78
+
79
+ function positionals(args) {
80
+ const valued = new Set(["--profile", "--plugin", "--out", "--category", "--tags", "--notes", "--suggest-tag", "--name", "--count", "--offset"])
81
+ return args.filter((value, index) => !value.startsWith("--") && !valued.has(args[index - 1]))
82
+ }
83
+
84
+ function emit(args, text) {
85
+ const out = option(args, "--out")
86
+ if (out) {
87
+ mkdirSync(dirname(resolve(out)), { recursive: true })
88
+ writeFileSync(resolve(out), text.endsWith("\n") ? text : `${text}\n`)
89
+ const c = styler(colourEnabled())
90
+ process.stdout.write(`${mark("pass", c)}wrote ${resolve(out)}\n`)
91
+ } else {
92
+ process.stdout.write(text.endsWith("\n") ? text : `${text}\n`)
93
+ }
94
+ }
95
+
96
+ async function cmdSubmit(args) {
97
+ const target = positionals(args)[0]
98
+ if (!target) fail("usage", "submit needs a target: `omakit submit <target> --category <c> --tags <a,b>`", 2)
99
+ const spinner = args.includes("--json") ? { phase: () => {}, done: () => {} } : progress()
100
+ let result
101
+ try {
102
+ result = await submitPreflight({
103
+ repoRoot: ROOT,
104
+ target,
105
+ onPhase: spinner.phase,
106
+ category: option(args, "--category"),
107
+ tags: option(args, "--tags"),
108
+ notes: option(args, "--notes"),
109
+ suggestedTag: option(args, "--suggest-tag"),
110
+ pluginName: option(args, "--name"),
111
+ allowDirty: args.includes("--allow-dirty"),
112
+ offline: args.includes("--offline"),
113
+ })
114
+ } catch (error) {
115
+ spinner.done()
116
+ failFrom(error)
117
+ }
118
+ spinner.done()
119
+ emit(args, args.includes("--json") ? `${JSON.stringify(result, null, 2)}\n` : renderSubmit(result))
120
+ process.exit(result.ready ? 0 : 1)
121
+ }
122
+
123
+ async function cmdWatch(args) {
124
+ const issueUrl = positionals(args)[0]
125
+ if (!issueUrl) fail("usage", "watch needs an issue: `omakit watch <issue-url>`", 2)
126
+ const spinner = args.includes("--json") ? { phase: () => {}, done: () => {} } : progress()
127
+ let result
128
+ try {
129
+ result = await validationWatch({ repoRoot: ROOT, issueUrl, onPhase: spinner.phase })
130
+ } catch (error) {
131
+ spinner.done()
132
+ failFrom(error)
133
+ }
134
+ spinner.done()
135
+ emit(args, args.includes("--json") ? `${JSON.stringify(result, null, 2)}\n` : renderWatch(result))
136
+ process.exit(result.verdict.state === "unknown" ? 2 : 0)
137
+ }
138
+
139
+ async function cmdFrontDoor() {
140
+ // A bare `omakit` is the front door: the wordmark, through `ttfx` when it is
141
+ // there, then the short list, which fits under it on any screen. The banner
142
+ // already says the name and the tagline, so the heading would repeat it.
143
+ const drew = bannerEnabled()
144
+ await banner({ tagline: TAGLINE, effect: true })
145
+ process.stdout.write(renderSummary({ heading: !drew }))
146
+ }
147
+
148
+ async function cmdSetup() {
149
+ const result = await setup({ repoRoot: ROOT, entryPoint: resolve(ROOT, "bin/omakit") })
150
+ process.exit(result.ok ? 0 : 1)
151
+ }
152
+
153
+ async function cmdUpgrade(args) {
154
+ const result = await upgrade({ repoRoot: ROOT, dryRun: args.includes("--dry-run") })
155
+ process.exit(result.ok ? 0 : 1)
156
+ }
157
+
158
+ async function cmdDoctor(args) {
159
+ const spinner = args.includes("--json") ? { phase: () => {}, done: () => {} } : progress()
160
+ const result = await doctor({ repoRoot: ROOT, offline: args.includes("--offline"), onPhase: spinner.phase })
161
+ spinner.done()
162
+ emit(args, args.includes("--json") ? `${JSON.stringify(result, null, 2)}\n` : renderDoctor(result))
163
+ process.exit(result.problems ? 1 : 0)
164
+ }
165
+
166
+ async function cmdVerify(args) {
167
+ const target = positionals(args)[0]
168
+ if (!target) fail("usage", "verify needs a target: `omakit verify <path | https-url@sha>`", 2)
169
+ let subject
170
+ try {
171
+ subject = resolveSubject(target, { cacheRoot: omakitCacheDir(), allowDirty: args.includes("--allow-dirty") })
172
+ } catch (error) {
173
+ if (error instanceof SubjectError) fail(error.code, error.message, error.code === "usage" ? 2 : 1)
174
+ throw error
175
+ }
176
+ const spinner = progress()
177
+ let section
178
+ try {
179
+ spinner.phase("running the official security baseline over a local snapshot")
180
+ section = await marketplaceBaselineSection({ repoRoot: ROOT, subject })
181
+ } catch (error) {
182
+ spinner.done()
183
+ fail(error?.code === "marketplace-unavailable" ? error.code : "baseline-unavailable", error.message)
184
+ }
185
+ spinner.done()
186
+ emit(args, `${JSON.stringify({
187
+ subject: {
188
+ repository: subject.repository,
189
+ commit: subject.commit,
190
+ cleanTree: { clean: subject.clean, proof: "git-status-porcelain-empty" },
191
+ mode: subject.mode,
192
+ },
193
+ marketplaceBaseline: section,
194
+ }, null, 2)}\n`)
195
+ }
196
+
197
+ async function cmdParity(args) {
198
+ const count = option(args, "--count")
199
+ const offset = option(args, "--offset")
200
+ if (count) process.env.PARITY_COUNT = count
201
+ if (offset) process.env.PARITY_OFFSET = offset
202
+ try {
203
+ const out = option(args, "--out")
204
+ process.env.PARITY_OUT = parityOutput({ repoRoot: ROOT, out })
205
+ if (out) process.env.PARITY_OUT_EXPLICIT = "1"
206
+ } catch (error) {
207
+ failFrom(error)
208
+ }
209
+ process.env.OMAKIT_ROOT = ROOT
210
+ try {
211
+ await import("../../tests/parity/run.mjs")
212
+ } catch (error) {
213
+ failFrom(error)
214
+ }
215
+ }
216
+
217
+ const [command, ...rest] = process.argv.slice(2)
218
+ if (command === "setup") {
219
+ await cmdSetup()
220
+ } else if (command === "pin" || command === "marketplace-pin") {
221
+ const c = styler(colourEnabled())
222
+ const spinner = progress()
223
+ try {
224
+ ensurePin(ROOT, (line) => {
225
+ // ensurePin narrates: a state line to keep, then a fetch it is about to
226
+ // start. The fetch is the slow part, so it gets the progress line.
227
+ if (line.state === "fetching") spinner.phase(line.text)
228
+ else process.stdout.write(`${mark(line.state, c)}${wrap(line.text, { indent: GUTTER }, c).join("\n").trimStart()}\n`)
229
+ })
230
+ } catch (error) {
231
+ spinner.done()
232
+ fail(error?.code || "marketplace-unavailable", error.message, 1, error?.remedy || REMEDY[error?.code])
233
+ }
234
+ spinner.done()
235
+ } else if (command === "submit") {
236
+ await cmdSubmit(rest)
237
+ } else if (command === "upgrade") {
238
+ await cmdUpgrade(rest)
239
+ } else if (command === "doctor") {
240
+ await cmdDoctor(rest)
241
+ } else if (command === "watch") {
242
+ await cmdWatch(rest)
243
+ } else if (command === "verify") {
244
+ await cmdVerify(rest.filter((value, index) => value !== "marketplace" || rest[index - 1] !== "--profile"))
245
+ } else if (command === "parity") {
246
+ await cmdParity(rest)
247
+ } else if (command === "help" || command === "--help" || command === "-h" || command === undefined) {
248
+ if (rest.includes("--agent")) {
249
+ // The skills ship in the npm package, so this works from a global install
250
+ // with no repository checked out.
251
+ const dir = resolve(ROOT, "skills")
252
+ const parts = []
253
+ for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : 1))) {
254
+ const file = entry.isDirectory() ? resolve(dir, entry.name, "SKILL.md") : resolve(dir, entry.name)
255
+ if (!file.endsWith(".md")) continue
256
+ try {
257
+ parts.push(readFileSync(file, "utf8").trim())
258
+ } catch {
259
+ // A skill directory without a SKILL.md is not an error worth failing on.
260
+ }
261
+ }
262
+ process.stdout.write(`${parts.join("\n\n---\n\n")}\n`)
263
+ } else if (command === undefined) {
264
+ await cmdFrontDoor()
265
+ } else {
266
+ // `omakit help` is the reference and gets all of it, under the name and
267
+ // the tagline as one line of text: 53 lines scroll a wordmark off the top
268
+ // of the screen before anyone has read it, so it gets none.
269
+ process.stdout.write(renderUsage())
270
+ }
271
+ } else {
272
+ // The short list on a typo, not 53 lines of reference, in the same register
273
+ // as every other failure: what happened, what it means, what to run.
274
+ const c = styler(colourEnabled(process.stderr))
275
+ process.stderr.write([
276
+ `${mark("fail", c)}${c("name", "unknown command")}`,
277
+ ...wrap(`\`${command}\` is not something omakit does. The commands it has are listed below.`, { indent: GUTTER }, c),
278
+ ...action("omakit help", c),
279
+ "",
280
+ renderSummary({ colour: colourEnabled(process.stderr), heading: false }),
281
+ ].join("\n"))
282
+ process.exit(2)
283
+ }
@@ -0,0 +1,263 @@
1
+ // Tab completion for bash, zsh and fish, installed by `omakit setup` for the
2
+ // shell it runs from.
3
+ //
4
+ // Everything the script completes is derived, never retyped: the subcommands
5
+ // and their flags from COMMANDS in usage.mjs, so a command added there cannot
6
+ // be missing here, and the categories and tags from the submission form at the
7
+ // marketplace pin, the same source the checks read. Those controlled values are
8
+ // baked into the emitted script rather than fetched on every keypress: node's
9
+ // startup plus a pin read is not something to put behind a TAB. The script says
10
+ // in its own header which pin it came from and how to regenerate it.
11
+
12
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
13
+ import { basename, dirname, join } from "node:path"
14
+ import { tagSlug } from "./form.mjs"
15
+ import { COMMANDS, COMPLETION_SHELLS } from "./usage.mjs"
16
+
17
+ /**
18
+ * The completion model, read out of the help data. A subcommand is the word
19
+ * after `omakit` in its signature; a flag is every `--word` in it, valued when
20
+ * a `<placeholder>` follows it; `<target>` in the signature means the first
21
+ * positional is a directory.
22
+ *
23
+ * @param {ReadonlyArray<{ signature: string|string[], lines: string[] }>} commands
24
+ */
25
+ export function subcommandsOf(commands = COMMANDS) {
26
+ return commands.map((command) => {
27
+ const signature = [].concat(command.signature).join(" ")
28
+ const name = signature.match(/^omakit +([a-z][a-z-]*)/)?.[1]
29
+ if (!name) throw new Error(`completion: no subcommand in signature ${JSON.stringify(signature)}`)
30
+ const flags = [...signature.matchAll(/(--[a-z][a-z-]*)(?: <([^>]+)>)?/g)].map(([, flag, placeholder]) => ({
31
+ flag,
32
+ value: placeholder ? placeholderKind(placeholder) : null,
33
+ }))
34
+ const sentence = command.lines.join(" ").replace(/`/g, "").split(/(?<=\.)\s/)[0]
35
+ return { name, description: sentence, flags, target: /<target>/.test(signature) }
36
+ })
37
+ }
38
+
39
+ /** What a valued flag takes, by its placeholder: a controlled value, a file, or free text. */
40
+ function placeholderKind(placeholder) {
41
+ if (placeholder === "c") return "category"
42
+ if (placeholder === "a,b") return "tags"
43
+ if (placeholder === "file") return "file"
44
+ return "text"
45
+ }
46
+
47
+ /**
48
+ * @param {"bash"|"zsh"|"fish"} shell
49
+ * @param {{ contract: { categories: string[], tagLabels: string[] }, pin: string, commands?: typeof COMMANDS }} options
50
+ * @returns {string} the script
51
+ */
52
+ export function renderCompletion(shell, { contract, pin, commands = COMMANDS }) {
53
+ if (!COMPLETION_SHELLS.includes(shell)) throw new Error(`completion: no script for ${shell}`)
54
+ const model = {
55
+ subcommands: subcommandsOf(commands),
56
+ categories: [...contract.categories],
57
+ tags: contract.tagLabels.map(tagSlug),
58
+ pin,
59
+ }
60
+ return { bash, zsh, fish }[shell](model)
61
+ }
62
+
63
+ function header(comment, shell, pin) {
64
+ return [
65
+ `${comment} omakit completion for ${shell}. Generated by \`omakit setup\` from`,
66
+ `${comment} marketplace pin ${pin}; the categories and tags`,
67
+ `${comment} below are that pin's submission form. Regenerate it by running setup`,
68
+ `${comment} again.`,
69
+ ]
70
+ }
71
+
72
+ const single = (text) => `'${String(text).replace(/'/g, "'\\''")}'`
73
+
74
+ // --- bash ---------------------------------------------------------------------
75
+
76
+ function bash({ subcommands, categories, tags, pin }) {
77
+ const lines = [...header("#", "bash", pin), ""]
78
+ lines.push("_omakit() {")
79
+ lines.push(" local cur prev command")
80
+ lines.push(" cur=${COMP_WORDS[COMP_CWORD]}")
81
+ lines.push(" prev=${COMP_WORDS[COMP_CWORD-1]}")
82
+ lines.push(` local commands=${single(subcommands.map((c) => c.name).join(" "))}`)
83
+ lines.push(` local categories=${single(categories.join("\n"))}`)
84
+ lines.push(` local tags=${single(tags.join(" "))}`)
85
+ lines.push(" COMPREPLY=()")
86
+ lines.push(" if ((COMP_CWORD == 1)); then")
87
+ lines.push(' COMPREPLY=($(compgen -W "$commands" -- "$cur"))')
88
+ lines.push(" return")
89
+ lines.push(" fi")
90
+ lines.push(" command=${COMP_WORDS[1]}")
91
+ lines.push(' case "$command" in')
92
+ for (const sub of subcommands) {
93
+ lines.push(` ${sub.name})`)
94
+ const valued = sub.flags.filter((f) => f.value)
95
+ if (valued.length) {
96
+ lines.push(' case "$prev" in')
97
+ for (const { flag, value } of valued) {
98
+ if (value === "category") lines.push(` ${flag}) _omakit_values "$categories" "$cur"; return ;;`)
99
+ else if (value === "tags") lines.push(` ${flag}) _omakit_list "$tags" "$cur"; return ;;`)
100
+ else if (value === "file") lines.push(` ${flag}) COMPREPLY=($(compgen -f -- "$cur")); compopt -o filenames 2>/dev/null; return ;;`)
101
+ else lines.push(` ${flag}) return ;;`)
102
+ }
103
+ lines.push(" esac")
104
+ }
105
+ if (sub.flags.length) {
106
+ lines.push(' if [[ "$cur" == -* ]]; then')
107
+ lines.push(` COMPREPLY=($(compgen -W ${single(sub.flags.map((f) => f.flag).join(" "))} -- "$cur"))`)
108
+ lines.push(" return")
109
+ lines.push(" fi")
110
+ }
111
+ if (sub.target) {
112
+ lines.push(' COMPREPLY=($(compgen -d -- "$cur"))')
113
+ lines.push(" compopt -o filenames 2>/dev/null")
114
+ }
115
+ lines.push(" ;;")
116
+ }
117
+ lines.push(" esac")
118
+ lines.push("}")
119
+ lines.push("")
120
+ lines.push("# A controlled value may contain a space, so each match is one line and is")
121
+ lines.push("# escaped on the way out, the way the shell would have to type it.")
122
+ lines.push("_omakit_values() {")
123
+ lines.push(" local IFS=$'\\n' i")
124
+ lines.push(' COMPREPLY=($(compgen -W "$1" -- "${2//\\\\ / }"))')
125
+ lines.push(' for i in "${!COMPREPLY[@]}"; do COMPREPLY[i]=$(printf \'%q\' "${COMPREPLY[i]}"); done')
126
+ lines.push("}")
127
+ lines.push("")
128
+ lines.push("# A comma-separated list: complete the segment after the last comma and keep")
129
+ lines.push("# what came before it.")
130
+ lines.push("_omakit_list() {")
131
+ lines.push(' local prefix="" part="$2"')
132
+ lines.push(' if [[ "$2" == *,* ]]; then prefix="${2%,*},"; part="${2##*,}"; fi')
133
+ lines.push(' COMPREPLY=($(compgen -P "$prefix" -W "$1" -- "$part"))')
134
+ lines.push("}")
135
+ lines.push("")
136
+ lines.push("complete -F _omakit omakit")
137
+ return `${lines.join("\n")}\n`
138
+ }
139
+
140
+ // --- zsh ----------------------------------------------------------------------
141
+
142
+ const zshDescribe = (name, description) => single(`${name}:${description.replace(/:/g, "\\:")}`)
143
+
144
+ function zsh({ subcommands, categories, tags, pin }) {
145
+ const lines = ["#compdef omakit", ...header("#", "zsh", pin), ""]
146
+ lines.push("_omakit() {")
147
+ lines.push(" local curcontext=\"$curcontext\" state line")
148
+ lines.push(" typeset -A opt_args")
149
+ lines.push(" local -a commands categories tags shells")
150
+ lines.push(" commands=(")
151
+ for (const sub of subcommands) lines.push(` ${zshDescribe(sub.name, sub.description)}`)
152
+ lines.push(" )")
153
+ lines.push(` categories=(${categories.map(single).join(" ")})`)
154
+ lines.push(` tags=(${tags.map(single).join(" ")})`)
155
+ lines.push(" _arguments -C '1:command:->command' '*::arguments:->arguments'")
156
+ lines.push(' case "$state" in')
157
+ lines.push(" command)")
158
+ lines.push(" _describe -t commands 'omakit command' commands")
159
+ lines.push(" ;;")
160
+ lines.push(" arguments)")
161
+ lines.push(' case "$line[1]" in')
162
+ for (const sub of subcommands) {
163
+ lines.push(` ${sub.name})`)
164
+ const specs = sub.flags.map(({ flag, value }) => {
165
+ if (value === "category") return single(`${flag}:category:{compadd -a categories}`)
166
+ if (value === "tags") return single(`${flag}:tags:{_values -s , tag $tags}`)
167
+ if (value === "file") return single(`${flag}:file:_files`)
168
+ if (value === "text") return single(`${flag}:text:`)
169
+ return single(flag)
170
+ })
171
+ if (sub.target) specs.push(single("1:target:_directories"))
172
+ if (specs.length) lines.push(` _arguments ${specs.join(" ")}`)
173
+ lines.push(" ;;")
174
+ }
175
+ lines.push(" esac")
176
+ lines.push(" ;;")
177
+ lines.push(" esac")
178
+ lines.push("}")
179
+ lines.push("")
180
+ lines.push('_omakit "$@"')
181
+ return `${lines.join("\n")}\n`
182
+ }
183
+
184
+ // --- fish ---------------------------------------------------------------------
185
+
186
+ const fishWord = (text) => String(text).replace(/([\\'" ])/g, "\\$1")
187
+
188
+ function fish({ subcommands, categories, tags, pin }) {
189
+ const lines = [...header("#", "fish", pin), ""]
190
+ lines.push("complete -c omakit -f")
191
+ for (const sub of subcommands) {
192
+ lines.push(`complete -c omakit -n __fish_use_subcommand -a ${sub.name} -d ${single(sub.description)}`)
193
+ }
194
+ for (const sub of subcommands) {
195
+ const when = `-n ${single(`__fish_seen_subcommand_from ${sub.name}`)}`
196
+ if (sub.target) lines.push(`complete -c omakit ${when} -a '(__fish_complete_directories)'`)
197
+ for (const { flag, value } of sub.flags) {
198
+ const long = `-l ${flag.slice(2)}`
199
+ if (value === "category") lines.push(`complete -c omakit ${when} ${long} -x -a ${single(categories.map(fishWord).join(" "))}`)
200
+ else if (value === "tags") lines.push(`complete -c omakit ${when} ${long} -x -a ${single(tags.join(" "))}`)
201
+ else if (value === "file") lines.push(`complete -c omakit ${when} ${long} -r`)
202
+ else if (value === "text") lines.push(`complete -c omakit ${when} ${long} -x`)
203
+ else lines.push(`complete -c omakit ${when} ${long}`)
204
+ }
205
+ }
206
+ return `${lines.join("\n")}\n`
207
+ }
208
+
209
+ // --- where a shell loads it from --------------------------------------------
210
+
211
+ /**
212
+ * Where the user's shell would load the script from, so `omakit setup` can say
213
+ * how to install it and only when it is not there. bash-completion reads the
214
+ * XDG data directory; fish reads its config directory; zsh has no user
215
+ * directory of its own, and `~/.zfunc` is the convention, added to `fpath`.
216
+ *
217
+ * @param {NodeJS.ProcessEnv} env
218
+ * @returns {{ shell: string, path: string, display: string, note: string|null } | null}
219
+ */
220
+ export function completionInstall(env = process.env) {
221
+ const shell = basename(env.SHELL || "")
222
+ const home = env.HOME || ""
223
+ if (!home || !COMPLETION_SHELLS.includes(shell)) return null
224
+ const tilde = (dir) => (dir.startsWith(home) ? `~${dir.slice(home.length)}` : dir)
225
+ if (shell === "bash") {
226
+ const dir = join(env.XDG_DATA_HOME || join(home, ".local/share"), "bash-completion/completions")
227
+ return { shell, path: join(dir, "omakit"), display: `${tilde(dir)}/omakit`, note: null }
228
+ }
229
+ if (shell === "fish") {
230
+ const dir = join(env.XDG_CONFIG_HOME || join(home, ".config"), "fish/completions")
231
+ return { shell, path: join(dir, "omakit.fish"), display: `${tilde(dir)}/omakit.fish`, note: null }
232
+ }
233
+ const dir = join(env.ZDOTDIR || home, ".zfunc")
234
+ return { shell, path: join(dir, "_omakit"), display: `${tilde(dir)}/_omakit`, note: "with `fpath+=~/.zfunc` before `compinit` in your .zshrc" }
235
+ }
236
+
237
+ /**
238
+ * Install the script where the shell in $SHELL loads it from, so a person
239
+ * never has to know the path: `omakit setup` calls this. Idempotent: a script
240
+ * that is already there and names the current pin is left alone; a missing
241
+ * one, or one from another pin, is written. The only file this writes is
242
+ * the completion script at the path `completionInstall` names, and
243
+ * tests/unit/self-containment.test.mjs holds it to that.
244
+ *
245
+ * @param {{ contract: { categories: string[], tagLabels: string[] }, pin: string, env?: NodeJS.ProcessEnv }} options
246
+ * @returns {{ state: "installed"|"updated"|"current"|"unsupported", shell: string|null, display: string|null, note: string|null }}
247
+ */
248
+ export function installCompletion({ contract, pin, env = process.env }) {
249
+ const target = completionInstall(env)
250
+ if (!target) return { state: "unsupported", shell: basename(env.SHELL || "") || null, display: null, note: null }
251
+ const script = renderCompletion(target.shell, { contract, pin })
252
+ let existing = null
253
+ try {
254
+ existing = readFileSync(target.path, "utf8")
255
+ } catch {
256
+ existing = null
257
+ }
258
+ if (existing === script) return { state: "current", ...target }
259
+ mkdirSync(dirname(target.path), { recursive: true })
260
+ const completionFile = target.path
261
+ writeFileSync(completionFile, script)
262
+ return { state: existing === null ? "installed" : "updated", ...target }
263
+ }