omakit 0.1.9 → 0.2.1

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.
@@ -18,8 +18,8 @@ import { withHomeAbbreviated } from "./paths.mjs"
18
18
  /**
19
19
  * The completion model, read out of the help data. A subcommand is the word
20
20
  * after `omakit` in its signature; a flag is every `--word` in it, valued when
21
- * a `<placeholder>` follows it; `<target>` in the signature means the first
22
- * positional is a directory.
21
+ * a `<placeholder>` follows it; `<target>` or `<plugin-id-or-dir>` in the
22
+ * signature means the first positional can be a directory.
23
23
  *
24
24
  * @param {ReadonlyArray<{ signature: string|string[], lines: string[] }>} commands
25
25
  */
@@ -28,15 +28,36 @@ export function subcommandsOf(commands = COMMANDS) {
28
28
  const signature = [].concat(command.signature).join(" ")
29
29
  const name = signature.match(/^omakit +([a-z][a-z-]*)/)?.[1]
30
30
  if (!name) throw new Error(`completion: no subcommand in signature ${JSON.stringify(signature)}`)
31
- const flags = [...signature.matchAll(/(--[a-z][a-z-]*)(?: <([^>]+)>)?/g)].map(([, flag, placeholder]) => ({
32
- flag,
33
- value: placeholder ? placeholderKind(placeholder) : null,
34
- }))
31
+ // A flag named on two signature lines (`--json` on weigh's main line and
32
+ // on its `--list` line) is one flag.
33
+ const seen = new Set()
34
+ const flags = [...signature.matchAll(/(--[a-z][a-z-]*)(?: <([^>]+)>)?/g)]
35
+ .filter(([, flag]) => !seen.has(flag) && seen.add(flag))
36
+ .map(([, flag, placeholder]) => ({
37
+ flag,
38
+ value: placeholder ? placeholderKind(placeholder) : null,
39
+ }))
35
40
  const sentence = command.lines.join(" ").replace(/`/g, "").split(/(?<=\.)\s/)[0]
36
- return { name, description: sentence, flags, target: /<target>/.test(signature) }
41
+ // `<target>` completes as a directory; `<plugin-id-or-dir>` as the ids
42
+ // the running shell has installed, read at TAB time, with a directory
43
+ // as the fallback.
44
+ const target = /<plugin-id-or-dir>/.test(signature) ? "plugin" : /<target>/.test(signature) ? "directory" : false
45
+ return { name, description: sentence, flags, target }
37
46
  })
38
47
  }
39
48
 
49
+ /**
50
+ * The plugin ids a TAB offers for `omakit weigh <TAB>`: what the running
51
+ * shell reports through `omarchy-shell shell listPlugins`, filtered with
52
+ * `jq` at TAB time, enabled ids first and whole bars left out, since a bar
53
+ * cannot be weighed. No node process behind the TAB: a shell that does not
54
+ * answer within a second yields nothing, and the script falls back to a
55
+ * directory. The pipeline is the same in every shell's script and the jq
56
+ * expression is exported so a test can run it through jq.
57
+ */
58
+ export const PLUGIN_IDS_JQ = "[.[] | select(((.kinds // []) | index(\"bar\")) | not)] | sort_by(.enabled | not) | .[].id"
59
+ export const PLUGIN_IDS_COMMAND = `timeout 1 omarchy-shell shell listPlugins 2>/dev/null | jq -r '${PLUGIN_IDS_JQ}' 2>/dev/null`
60
+
40
61
  /** What a valued flag takes, by its placeholder: a controlled value, a file, or free text. */
41
62
  function placeholderKind(placeholder) {
42
63
  if (placeholder === "c") return "category"
@@ -50,32 +71,59 @@ function placeholderKind(placeholder) {
50
71
  * @param {{ contract: { categories: string[], tagLabels: string[] }, pin: string, commands?: typeof COMMANDS }} options
51
72
  * @returns {string} the script
52
73
  */
53
- export function renderCompletion(shell, { contract, pin, commands = COMMANDS }) {
74
+ export function renderCompletion(shell, { contract, pin, version = "unknown", commands = COMMANDS }) {
54
75
  if (!COMPLETION_SHELLS.includes(shell)) throw new Error(`completion: no script for ${shell}`)
55
76
  const model = {
56
77
  subcommands: subcommandsOf(commands),
57
78
  categories: [...contract.categories],
58
79
  tags: contract.tagLabels.map(tagSlug),
59
80
  pin,
81
+ version,
60
82
  }
61
83
  return { bash, zsh, fish }[shell](model)
62
84
  }
63
85
 
64
- function header(comment, shell, pin) {
86
+ /**
87
+ * The first line of every script names the omakit version and the pin it
88
+ * was rendered from, in one line, so the run-time staleness check
89
+ * (completion-check.mjs) reads one line and nothing else. The pin is what
90
+ * decides the categories and tags; the version is what decides the
91
+ * subcommands and flags, and a script from 0.1.9 knows no `weigh`.
92
+ */
93
+ function header(comment, shell, pin, version) {
65
94
  return [
66
- `${comment} omakit completion for ${shell}. Generated by \`omakit setup\` from`,
67
- `${comment} marketplace pin ${pin}; the categories and tags`,
68
- `${comment} below are that pin's submission form. Regenerate it by running setup`,
69
- `${comment} again.`,
95
+ `${comment} omakit completion for ${shell}, omakit ${version}, marketplace pin ${pin}.`,
96
+ `${comment} Generated by \`omakit setup\`; the categories and tags below are that pin's`,
97
+ `${comment} submission form and the commands are that version's. Regenerate it by`,
98
+ `${comment} running setup again.`,
70
99
  ]
71
100
  }
72
101
 
102
+ /**
103
+ * The version and the pin a script names, from its first line (its second
104
+ * for zsh, under `#compdef`), or null when
105
+ * the line is not one omakit wrote (a script from before the version was
106
+ * recorded reads as version null and its pin from the old header).
107
+ *
108
+ * @param {string} text the first two lines are enough
109
+ * @returns {{ shell: string|null, version: string|null, pin: string|null }}
110
+ */
111
+ export function parseCompletionHeader(text) {
112
+ // zsh's script starts with its `#compdef` line; the header is the next.
113
+ const lines = String(text).split("\n", 2)
114
+ const line = lines[0].startsWith("#compdef") ? lines[1] || "" : lines[0]
115
+ const current = line.match(/^# omakit completion for (\w+), omakit (\S+), marketplace pin ([0-9a-f]{40})\.$/)
116
+ if (current) return { shell: current[1], version: current[2], pin: current[3] }
117
+ const older = line.match(/^#\s*omakit completion for (\w+)\./)
118
+ return { shell: older ? older[1] : null, version: null, pin: null }
119
+ }
120
+
73
121
  const single = (text) => `'${String(text).replace(/'/g, "'\\''")}'`
74
122
 
75
123
  // --- bash ---------------------------------------------------------------------
76
124
 
77
- function bash({ subcommands, categories, tags, pin }) {
78
- const lines = [...header("#", "bash", pin), ""]
125
+ function bash({ subcommands, categories, tags, pin, version }) {
126
+ const lines = [...header("#", "bash", pin, version), ""]
79
127
  lines.push("_omakit() {")
80
128
  lines.push(" local cur prev command")
81
129
  lines.push(" cur=${COMP_WORDS[COMP_CWORD]}")
@@ -109,7 +157,12 @@ function bash({ subcommands, categories, tags, pin }) {
109
157
  lines.push(" return")
110
158
  lines.push(" fi")
111
159
  }
112
- if (sub.target) {
160
+ if (sub.target === "plugin") {
161
+ lines.push(' if ((COMP_CWORD == 2)); then')
162
+ lines.push(' COMPREPLY=($(compgen -W "$(_omakit_plugin_ids)" -- "$cur"))')
163
+ lines.push(' if ((${#COMPREPLY[@]} == 0)); then COMPREPLY=($(compgen -d -- "$cur")); compopt -o filenames 2>/dev/null; fi')
164
+ lines.push(" fi")
165
+ } else if (sub.target) {
113
166
  lines.push(' COMPREPLY=($(compgen -d -- "$cur"))')
114
167
  lines.push(" compopt -o filenames 2>/dev/null")
115
168
  }
@@ -118,6 +171,13 @@ function bash({ subcommands, categories, tags, pin }) {
118
171
  lines.push(" esac")
119
172
  lines.push("}")
120
173
  lines.push("")
174
+ lines.push("# The plugin ids the running shell has installed, enabled first, whole bars")
175
+ lines.push("# left out, read at TAB time; nothing when the shell does not answer in a")
176
+ lines.push("# second, and the caller falls back to a directory.")
177
+ lines.push("_omakit_plugin_ids() {")
178
+ lines.push(` ${PLUGIN_IDS_COMMAND}`)
179
+ lines.push("}")
180
+ lines.push("")
121
181
  lines.push("# A controlled value may contain a space, so each match is one line and is")
122
182
  lines.push("# escaped on the way out, the way the shell would have to type it.")
123
183
  lines.push("_omakit_values() {")
@@ -142,8 +202,8 @@ function bash({ subcommands, categories, tags, pin }) {
142
202
 
143
203
  const zshDescribe = (name, description) => single(`${name}:${description.replace(/:/g, "\\:")}`)
144
204
 
145
- function zsh({ subcommands, categories, tags, pin }) {
146
- const lines = ["#compdef omakit", ...header("#", "zsh", pin), ""]
205
+ function zsh({ subcommands, categories, tags, pin, version }) {
206
+ const lines = ["#compdef omakit", ...header("#", "zsh", pin, version), ""]
147
207
  lines.push("_omakit() {")
148
208
  lines.push(" local curcontext=\"$curcontext\" state line")
149
209
  lines.push(" typeset -A opt_args")
@@ -169,7 +229,8 @@ function zsh({ subcommands, categories, tags, pin }) {
169
229
  if (value === "text") return single(`${flag}:text:`)
170
230
  return single(flag)
171
231
  })
172
- if (sub.target) specs.push(single("1:target:_directories"))
232
+ if (sub.target === "plugin") specs.push(single("1:plugin:_omakit_plugins"))
233
+ else if (sub.target) specs.push(single("1:target:_directories"))
173
234
  if (specs.length) lines.push(` _arguments ${specs.join(" ")}`)
174
235
  lines.push(" ;;")
175
236
  }
@@ -178,6 +239,14 @@ function zsh({ subcommands, categories, tags, pin }) {
178
239
  lines.push(" esac")
179
240
  lines.push("}")
180
241
  lines.push("")
242
+ lines.push("# The plugin ids the running shell has installed, enabled first, whole bars")
243
+ lines.push("# left out, read at TAB time; a directory when the shell does not answer.")
244
+ lines.push("_omakit_plugins() {")
245
+ lines.push(" local -a ids")
246
+ lines.push(` ids=(\${(f)"$(${PLUGIN_IDS_COMMAND})"})`)
247
+ lines.push(" if (( ${#ids} )); then compadd -a ids; else _directories; fi")
248
+ lines.push("}")
249
+ lines.push("")
181
250
  lines.push('_omakit "$@"')
182
251
  return `${lines.join("\n")}\n`
183
252
  }
@@ -186,14 +255,23 @@ function zsh({ subcommands, categories, tags, pin }) {
186
255
 
187
256
  const fishWord = (text) => String(text).replace(/([\\'" ])/g, "\\$1")
188
257
 
189
- function fish({ subcommands, categories, tags, pin }) {
190
- const lines = [...header("#", "fish", pin), ""]
258
+ function fish({ subcommands, categories, tags, pin, version }) {
259
+ const lines = [...header("#", "fish", pin, version), ""]
191
260
  lines.push("complete -c omakit -f")
261
+ lines.push("")
262
+ lines.push("# The plugin ids the running shell has installed, enabled first, whole bars")
263
+ lines.push("# left out, read at TAB time; nothing when the shell does not answer, and")
264
+ lines.push("# the directories offered beside them stand.")
265
+ lines.push("function __omakit_plugin_ids")
266
+ lines.push(` ${PLUGIN_IDS_COMMAND}`)
267
+ lines.push("end")
268
+ lines.push("")
192
269
  for (const sub of subcommands) {
193
270
  lines.push(`complete -c omakit -n __fish_use_subcommand -a ${sub.name} -d ${single(sub.description)}`)
194
271
  }
195
272
  for (const sub of subcommands) {
196
273
  const when = `-n ${single(`__fish_seen_subcommand_from ${sub.name}`)}`
274
+ if (sub.target === "plugin") lines.push(`complete -c omakit ${when} -a '(__omakit_plugin_ids)'`)
197
275
  if (sub.target) lines.push(`complete -c omakit ${when} -a '(__fish_complete_directories)'`)
198
276
  for (const { flag, value } of sub.flags) {
199
277
  const long = `-l ${flag.slice(2)}`
@@ -243,13 +321,13 @@ export function completionInstall(env = process.env) {
243
321
  * the completion script at the path `completionInstall` names, and
244
322
  * tests/unit/self-containment.test.mjs holds it to that.
245
323
  *
246
- * @param {{ contract: { categories: string[], tagLabels: string[] }, pin: string, env?: NodeJS.ProcessEnv }} options
247
- * @returns {{ state: "installed"|"updated"|"current"|"unsupported", shell: string|null, display: string|null, note: string|null }}
324
+ * @param {{ contract: { categories: string[], tagLabels: string[] }, pin: string, version?: string, env?: NodeJS.ProcessEnv }} options
325
+ * @returns {{ state: "installed"|"updated"|"current"|"unsupported", shell: string|null, display: string|null, note: string|null, path?: string }}
248
326
  */
249
- export function installCompletion({ contract, pin, env = process.env }) {
327
+ export function installCompletion({ contract, pin, version = "unknown", env = process.env }) {
250
328
  const target = completionInstall(env)
251
329
  if (!target) return { state: "unsupported", shell: basename(env.SHELL || "") || null, display: null, note: null }
252
- const script = renderCompletion(target.shell, { contract, pin })
330
+ const script = renderCompletion(target.shell, { contract, pin, version })
253
331
  let existing = null
254
332
  try {
255
333
  existing = readFileSync(target.path, "utf8")
@@ -44,6 +44,7 @@ import { LIVE_PATHS } from "./registry.mjs"
44
44
  import { credential, defaultBranchHead, getJson, UNAUTHENTICATED_LIMIT, GitHubError } from "./github.mjs"
45
45
  import { NPM_REGISTRY, registryLatest, upgradeCommand } from "./upgrade.mjs"
46
46
  import { pathHint } from "./path-hint.mjs"
47
+ import { completionStatus } from "./completion-check.mjs"
47
48
 
48
49
  /** "git+https://github.com/owner/name.git" in package.json -> "https://github.com/owner/name", or null. */
49
50
  function repositoryPage(repository) {
@@ -52,7 +53,7 @@ function repositoryPage(repository) {
52
53
  return match ? `https://github.com/${match[1]}/${match[2]}` : null
53
54
  }
54
55
 
55
- function tool(repoRoot) {
56
+ export function tool(repoRoot) {
56
57
  try {
57
58
  const pkg = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8"))
58
59
  return { name: pkg.name, version: pkg.version, engines: pkg.engines?.node || null, repository: repositoryPage(pkg.repository) }
@@ -224,6 +225,13 @@ export async function doctor({ repoRoot, offline = false, onPhase, env = process
224
225
  : `${reach.reason}${reach.where ? ` Keep the line below in ${reach.where}.` : ""}`,
225
226
  reach.reachable ? null : reach.line)
226
227
 
228
+ // Tab completion, as it is and not as it was written: the script, its
229
+ // omakit version and pin against this one's, and a new shell asked
230
+ // whether it loads (completion-check.mjs). Measured before this (M8):
231
+ // setup reported success for a script no shell was ever asked about.
232
+ const completion = completionStatus({ version: self.version, pin: MARKETPLACE_PIN.commit, env })
233
+ add("omakit.completion", completion.state, completion.detail, completion.action, completion.evidence)
234
+
227
235
  const node = process.versions.node
228
236
  const major = Number(node.split(".")[0])
229
237
  add("node", major >= 22 ? "ok" : "problem", `node ${node}${self.engines ? ` (needs ${self.engines})` : ""}`,
@@ -0,0 +1,72 @@
1
+ // Every option every command accepts, in one table, and the one parser
2
+ // that reads a command line against it.
3
+ //
4
+ // The table is the source the help signatures and the completion scripts
5
+ // are held to (tests/unit/options.test.mjs): a flag a command reads must
6
+ // be in its signature, and a flag in a signature must be one the command
7
+ // reads, so `omakit help`, tab completion and the code cannot disagree. A
8
+ // token the command does not know, an option without its value, or one
9
+ // positional more than the command takes is refused before anything runs.
10
+ // Measured before this: `omakit weigh <plugin> -n 1` ran three runs as if
11
+ // nothing had been passed, and the other commands read their flags one by
12
+ // one and ignored the rest.
13
+
14
+ /**
15
+ * What each command accepts. `valued` options take the next token (or
16
+ * `--name=value`); `flags` stand alone; `positionals` is how many bare
17
+ * arguments the command takes. `--help` and `-h` are handled before any
18
+ * command runs and are not options of one.
19
+ */
20
+ export const ACCEPTED = Object.freeze({
21
+ setup: Object.freeze({ valued: [], flags: ["--yes", "--completion"], positionals: 0 }),
22
+ pin: Object.freeze({ valued: [], flags: [], positionals: 0 }),
23
+ submit: Object.freeze({ valued: ["--category", "--tags", "--notes", "--suggest-tag", "--name", "--out"], flags: ["--offline", "--allow-dirty", "--json"], positionals: 1 }),
24
+ watch: Object.freeze({ valued: ["--out"], flags: ["--json"], positionals: 1 }),
25
+ verify: Object.freeze({ valued: ["--out"], flags: ["--allow-dirty", "--json"], positionals: 1 }),
26
+ help: Object.freeze({ valued: [], flags: ["--agent"], positionals: 0 }),
27
+ upgrade: Object.freeze({ valued: [], flags: ["--dry-run"], positionals: 0 }),
28
+ doctor: Object.freeze({ valued: ["--out"], flags: ["--offline", "--json"], positionals: 0 }),
29
+ parity: Object.freeze({ valued: ["--count", "--offset", "--out"], flags: [], positionals: 0 }),
30
+ weigh: Object.freeze({ valued: ["--runs", "--window", "--settle", "--out"], flags: ["--all", "--list", "--json", "--yes"], positionals: 1 }),
31
+ })
32
+
33
+ /** The accepted options of a command in the words a refusal prints: `--runs N, --all, ...`. */
34
+ export function acceptedWords(name) {
35
+ const spec = ACCEPTED[name]
36
+ const value = (option) => ({ "--out": "FILE", "--runs": "N", "--count": "N", "--offset": "N", "--window": "S", "--settle": "S", "--category": "C", "--tags": "A,B" }[option] || "TEXT")
37
+ return [...spec.valued.map((option) => `${option} ${value(option)}`), ...spec.flags].join(", ")
38
+ }
39
+
40
+ /**
41
+ * Read a command line against a command's table. Every token is a known
42
+ * option (valued or not), the value of a valued option, or a positional up
43
+ * to the allowed count; the first token that is none of those is returned
44
+ * as `offending` with a reason, so the command refuses before any preflight.
45
+ *
46
+ * @param {string[]} args
47
+ * @param {{ valued: string[], flags: string[], positionals: number }} spec
48
+ * @returns {{ offending: string|null, reason: string|null, options: Map<string, string|true>, positionals: string[] }}
49
+ */
50
+ export function checkArgs(args, spec) {
51
+ const options = new Map()
52
+ const positionals = []
53
+ for (let index = 0; index < args.length; index += 1) {
54
+ const token = args[index]
55
+ const [name, inline] = token.startsWith("--") && token.includes("=") ? [token.slice(0, token.indexOf("=")), token.slice(token.indexOf("=") + 1)] : [token, undefined]
56
+ if (spec.valued.includes(name)) {
57
+ const value = inline !== undefined ? inline : args[index + 1]
58
+ if (value === undefined || (inline === undefined && value.startsWith("-"))) return { offending: token, reason: `${name} needs a value`, options, positionals }
59
+ options.set(name, value)
60
+ if (inline === undefined) index += 1
61
+ } else if (spec.flags.includes(token)) {
62
+ options.set(token, true)
63
+ } else if (token.startsWith("-")) {
64
+ return { offending: token, reason: `${token} is not an option this command knows`, options, positionals }
65
+ } else if (positionals.length < spec.positionals) {
66
+ positionals.push(token)
67
+ } else {
68
+ return { offending: token, reason: `${JSON.stringify(token)} is one argument more than the command takes`, options, positionals }
69
+ }
70
+ }
71
+ return { offending: null, reason: null, options, positionals }
72
+ }
@@ -9,6 +9,19 @@ export function omakitCacheDir(name = "", env = process.env) {
9
9
  return join(base, "omakit", name)
10
10
  }
11
11
 
12
+ /**
13
+ * Omakit's user-writable state, following XDG with the usual ~/.local/state
14
+ * fallback: where `omakit weigh` keeps its documents and its per-restart
15
+ * timing. State, not cache, because a measurement is not something to
16
+ * fetch again.
17
+ */
18
+ export function omakitStateDir(name = "", env = process.env) {
19
+ const base = env.XDG_STATE_HOME
20
+ ? resolve(env.XDG_STATE_HOME)
21
+ : join(env.HOME || homedir(), ".local/state")
22
+ return join(base, "omakit", name)
23
+ }
24
+
12
25
  /**
13
26
  * Text for a person, with every path under the home directory written the
14
27
  * way a shell would take it: `~/.cache/omakit/marketplace`. Only `$HOME`
@@ -35,9 +35,10 @@ function stateOf(check) {
35
35
  /**
36
36
  * A check's head line: the mark, the id in bold, and the source in brackets
37
37
  * pushed to the right edge, so the sources form a column of their own and the
38
- * ids form another.
38
+ * ids form another. Exported for the weigh report, whose rows are plugins
39
+ * with their kinds where a check has its source.
39
40
  */
40
- function head(state, id, source, c) {
41
+ export function head(state, id, source, c) {
41
42
  const left = `${mark(state, c)}${c("name", id)}`
42
43
  const tag = c("punctuation", `[${source}]`)
43
44
  const gap = Math.max(2, COLUMNS - width(left) - width(tag))
@@ -5,10 +5,12 @@
5
5
  // and what to try first. It is idempotent, so running it again on a machine that
6
6
  // is already set up just confirms that.
7
7
  //
8
- // It writes exactly one thing: the pinned checkout, through the same `ensurePin`
9
- // that `omakit pin` uses. It does not create symlinks, edit a shell profile or
10
- // install anything. Where a step is the user's to take, it prints the command
11
- // and stops, which is the same contract every other command here keeps.
8
+ // It writes the pinned checkout, through the same `ensurePin` that `omakit
9
+ // pin` uses, and the completion script where the shell in $SHELL loads it
10
+ // from. It does not create symlinks or install anything, and it edits a shell
11
+ // profile in exactly one case, after an explicit yes: the guarded block that
12
+ // makes completions load, when a new shell has no loader. Everywhere else a
13
+ // step that is the user's to take is printed as the command, and stops.
12
14
 
13
15
  import { execFileSync } from "node:child_process"
14
16
  import { banner } from "./banner.mjs"
@@ -18,9 +20,86 @@ import { progress } from "./progress.mjs"
18
20
  import { action, colourEnabled, GUTTER, mark, styler, wrap } from "./style.mjs"
19
21
  import { TAGLINE } from "./usage.mjs"
20
22
  import { installCompletion } from "./completion.mjs"
23
+ import { appendLoaderBlock, completionWorks, loaderBlock, loaderBlockPresent, verifyCompletion } from "./completion-check.mjs"
21
24
  import { submissionContract } from "./form.mjs"
22
25
  import { pathHint } from "./path-hint.mjs"
23
26
  import { withHomeAbbreviated } from "./paths.mjs"
27
+ import { askYes } from "../weigh/confirm.mjs"
28
+ import { tool } from "./doctor.mjs"
29
+
30
+ /**
31
+ * Tab completion, end to end: the script written for the shell in $SHELL
32
+ * where that shell loads it from, then a new interactive shell asked
33
+ * whether it can complete `omakit`, the way TAB asks. Measured before this
34
+ * (docs/MEASUREMENTS.md M8): setup reported the script installed and never
35
+ * asked a shell. When the shell has no completion loader, the one guarded
36
+ * block that gives it one is offered, once, and appended only on yes; with
37
+ * `askRc: false` (the step `upgrade` re-runs) it is named and not offered.
38
+ * `▁ ok` is printed only when a new shell shows the spec.
39
+ *
40
+ * @param {{ repoRoot: string, pin: string, version: string, stream?: NodeJS.WriteStream, env?: object,
41
+ * yes?: boolean, askRc?: boolean, input?: NodeJS.ReadStream, verify?: typeof verifyCompletion }} options
42
+ * @returns {Promise<{ state: "ok"|"note"|"unsupported"|"error", shell: string|null, rcAppended: boolean }>}
43
+ */
44
+ export async function completionStep({ repoRoot, pin, version, stream = process.stdout, env = process.env, yes = false, askRc = true, input = process.stdin, verify = verifyCompletion }) {
45
+ const c = styler(colourEnabled(stream))
46
+ const out = (line = "") => stream.write(`${line}\n`)
47
+ const step = (state, text) => out(`${mark(state, c)}${wrap(withHomeAbbreviated(text, env), { indent: GUTTER }, c).join("\n").trimStart()}`)
48
+ const fix = (text) => { for (const line of action(withHomeAbbreviated(text, env), c)) out(line) }
49
+ let completion
50
+ try {
51
+ const contract = await submissionContract({ repoRoot })
52
+ completion = installCompletion({ contract, pin, version, env })
53
+ } catch (error) {
54
+ step("info", `tab completion was not installed: ${error.message}`)
55
+ return { state: "error", shell: null, rcAppended: false }
56
+ }
57
+ if (completion.state === "unsupported") {
58
+ step("info", completion.shell
59
+ ? `tab completion: no script for ${completion.shell}; there is one for bash, zsh and fish.`
60
+ : "tab completion: $SHELL is not set, so no script was installed.")
61
+ return { state: "unsupported", shell: completion.shell, rcAppended: false }
62
+ }
63
+ const what = { installed: "installed", updated: "updated for this omakit and pin", current: "already installed" }[completion.state]
64
+ const where = `${completion.display}${completion.note ? `, ${completion.note}` : ""}`
65
+ let probe = verify(completion.shell, { env })
66
+ let rcAppended = false
67
+ if (!probe.ran) {
68
+ step("advisory", `tab completion for ${completion.shell} ${what} at ${where}, but a new ${completion.shell} could not be asked whether it loads: ${probe.reason}.`)
69
+ return { state: "note", shell: completion.shell, rcAppended }
70
+ }
71
+ if (!probe.loader) {
72
+ const block = loaderBlock(completion.shell, env)
73
+ const missing = completion.shell === "bash"
74
+ ? "a new bash has no completion loader: /usr/share/bash-completion/bash_completion is not sourced, so a script under ~/.local/share/bash-completion/ is never read"
75
+ : "a new zsh has not run compinit, so no completion function is ever loaded"
76
+ step("advisory", `tab completion for ${completion.shell} ${what} at ${where}, but ${missing}.`)
77
+ if (block && !loaderBlockPresent(completion.shell, env)) {
78
+ const question = `Add one guarded line to ${block.display} so completions load?`
79
+ const agreed = askRc ? (yes || (Boolean(input.isTTY) && Boolean(stream.isTTY) && await askYes({ input, output: process.stderr, question }))) : false
80
+ if (agreed) {
81
+ const wrote = appendLoaderBlock(completion.shell, env)
82
+ rcAppended = wrote.appended
83
+ step("info", `appended to ${block.display}, marked \`${block.lines[0]}\`; omakit never edits or removes it.`)
84
+ probe = verify(completion.shell, { env })
85
+ } else {
86
+ step("info", `nothing was written. The lines that make completions load, for ${block.display}:`)
87
+ for (const line of block.lines) fix(line)
88
+ return { state: "note", shell: completion.shell, rcAppended }
89
+ }
90
+ } else if (block) {
91
+ step("info", `${block.display} already carries the \`${block.lines[0]}\` block; a new shell still reports no loader, so something later in that file undoes it.`)
92
+ return { state: "note", shell: completion.shell, rcAppended }
93
+ }
94
+ }
95
+ if (completionWorks(probe)) {
96
+ step("pass", `tab completion for ${completion.shell} ${what} at ${where}; a new ${completion.shell} completes \`omakit\`${probe.spec === "lazy" ? " on the first TAB" : ""}.${rcAppended ? ` Open terminals need a new shell: \`exec ${completion.shell}\`.` : ""}`)
97
+ return { state: "ok", shell: completion.shell, rcAppended }
98
+ }
99
+ step("advisory", `tab completion for ${completion.shell} ${what} at ${where}, but a new ${completion.shell} ${probe.loader ? "does not load it" : "still has no completion loader"}${rcAppended ? " even after the block was appended" : ""}.`)
100
+ fix(`Open a new shell (\`exec ${completion.shell}\`) and run \`omakit doctor\`; it reports the script, the loader and the spec as \`omakit.completion\`.`)
101
+ return { state: "note", shell: completion.shell, rcAppended }
102
+ }
24
103
 
25
104
  function version(command) {
26
105
  try {
@@ -33,11 +112,13 @@ function version(command) {
33
112
  }
34
113
 
35
114
  /**
36
- * @param {{ repoRoot: string, entryPoint: string, stream?: NodeJS.WriteStream, env?: object }} options
115
+ * @param {{ repoRoot: string, entryPoint: string, stream?: NodeJS.WriteStream, env?: object, yes?: boolean,
116
+ * input?: NodeJS.ReadStream, verify?: typeof verifyCompletion }} options
37
117
  * `env` is where `$HOME` is read from: this is output for a person, so a
38
- * path under it is printed as `~/...`.
118
+ * path under it is printed as `~/...`. `yes` answers the one question
119
+ * setup can ask (the rc block for completion), for an agent.
39
120
  */
40
- export async function setup({ repoRoot, entryPoint, stream = process.stdout, env = process.env }) {
121
+ export async function setup({ repoRoot, entryPoint, stream = process.stdout, env = process.env, yes = false, input = process.stdin, verify = verifyCompletion }) {
41
122
  const c = styler(colourEnabled(stream))
42
123
  const out = (line = "") => stream.write(`${line}\n`)
43
124
  // A step is a status line: the mark, then the fact, wrapped under itself.
@@ -110,23 +191,8 @@ export async function setup({ repoRoot, entryPoint, stream = process.stdout, env
110
191
  }
111
192
 
112
193
  // Tab completion, installed for the shell in $SHELL where that shell loads
113
- // it from, so nobody has to know the path. The script carries the pin's
114
- // categories and tags, so it is rewritten when the pin has moved and left
115
- // alone otherwise.
116
- try {
117
- const contract = await submissionContract({ repoRoot })
118
- const completion = installCompletion({ contract, pin: identity.commit })
119
- if (completion.state === "unsupported") {
120
- step("info", completion.shell
121
- ? `tab completion: no script for ${completion.shell}; there is one for bash, zsh and fish.`
122
- : "tab completion: $SHELL is not set, so no script was installed.")
123
- } else {
124
- const what = { installed: "installed", updated: "updated for this pin", current: "already installed" }[completion.state]
125
- step("pass", `tab completion for ${completion.shell} ${what} at ${completion.display}${completion.note ? `, ${completion.note}` : ""}.`)
126
- }
127
- } catch (error) {
128
- step("info", `tab completion was not installed: ${error.message}`)
129
- }
194
+ // it from, and then proven in a new shell (completionStep).
195
+ await completionStep({ repoRoot, pin: identity.commit, version: tool(repoRoot).version, stream, env, yes, askRc: true, input, verify })
130
196
  out()
131
197
 
132
198
  out("Try it on a plugin you have checked out:")
@@ -144,7 +144,29 @@ export function isExpectedRemote(url, expected = REPOSITORY) {
144
144
  * a way to point the command at somebody else's repository, because nothing
145
145
  * on the command line reaches it.
146
146
  */
147
- export async function upgrade({ repoRoot, stream = process.stdout, dryRun = false, expectedRemote = REPOSITORY, latest = latestOnRegistry, npmRoot = npmGlobalRoot, name = "omakit" }) {
147
+ /**
148
+ * After a successful install, the completion step of the omakit that was
149
+ * just installed: it renders the script with its own version and proves it
150
+ * in a new shell, and never asks the rc question (the loader does not
151
+ * change with an upgrade). Run as a child of the new entry point, not in
152
+ * this process, whose code is the old version's. Frozen arguments.
153
+ */
154
+ export const COMPLETION_REFRESH_ARGS = Object.freeze(["setup", "--completion"])
155
+
156
+ function refreshCompletionWith(root, stream) {
157
+ const entryPoint = join(root, "bin/omakit")
158
+ if (!existsSync(entryPoint)) return { ran: false, reason: `${entryPoint} is not there` }
159
+ try {
160
+ const out = execFileSync(process.execPath, [entryPoint, ...COMPLETION_REFRESH_ARGS], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })
161
+ stream.write(out)
162
+ return { ran: true, ok: true }
163
+ } catch (error) {
164
+ stream.write(error.stdout || "")
165
+ return { ran: true, ok: false, reason: String(error.stderr || error.message).trim() }
166
+ }
167
+ }
168
+
169
+ export async function upgrade({ repoRoot, stream = process.stdout, dryRun = false, expectedRemote = REPOSITORY, latest = latestOnRegistry, npmRoot = npmGlobalRoot, name = "omakit", refreshCompletion = refreshCompletionWith }) {
148
170
  const c = styler(colourEnabled(stream))
149
171
  const out = (line = "") => stream.write(`${line}\n`)
150
172
  const lines = (list) => { for (const line of list) out(line) }
@@ -160,7 +182,7 @@ export async function upgrade({ repoRoot, stream = process.stdout, dryRun = fals
160
182
  if (installKind(repoRoot) === "distro") {
161
183
  return refuse("this is a distro package under /usr, so omakit leaves upgrades to the package manager.", upgradeCommand(repoRoot))
162
184
  }
163
- return upgradeNpm({ repoRoot, stream, dryRun, latest, npmRoot, name, refuse, note, ok, out, lines, c })
185
+ return upgradeNpm({ repoRoot, stream, dryRun, latest, npmRoot, name, refuse, note, ok, out, lines, c, refreshCompletion })
164
186
  }
165
187
 
166
188
  let remote
@@ -245,15 +267,17 @@ export async function upgrade({ repoRoot, stream = process.stdout, dryRun = fals
245
267
  ok(`${before.slice(0, 7)} to ${after.slice(0, 7)} on ${branch}, ${log.length} commit(s)`)
246
268
  for (const line of log) out(subject(line))
247
269
  out()
270
+ const completion = refreshCompletion(resolve(repoRoot), stream)
271
+ if (completion.ran === false) note(`tab completion was not refreshed: ${completion.reason}. Run \`omakit setup\`.`)
248
272
  lines(wrap("The marketplace pin did not move: this updated the tool, not the commit its rules are read from. `omakit doctor` says whether that pin is behind, and docs/UPSTREAM_CONTRACT.md says what moving it involves.", {}, c))
249
- return { ok: true, changed: true, from: before, to: after, commits: log.length }
273
+ return { ok: true, changed: true, from: before, to: after, commits: log.length, completion }
250
274
  }
251
275
 
252
276
  /**
253
277
  * The npm route. `latest` and `npmRoot` are injectable for the tests only, the
254
278
  * way `expectedRemote` is: nothing on the command line reaches them.
255
279
  */
256
- async function upgradeNpm({ repoRoot, stream, dryRun, latest, npmRoot, name, refuse, note, ok, out, lines, c }) {
280
+ async function upgradeNpm({ repoRoot, stream, dryRun, latest, npmRoot, name, refuse, note, ok, out, lines, c, refreshCompletion }) {
257
281
  const root = resolve(repoRoot)
258
282
  const globalRoot = npmRoot()
259
283
  if (!globalRoot) {
@@ -302,6 +326,8 @@ async function upgradeNpm({ repoRoot, stream, dryRun, latest, npmRoot, name, ref
302
326
  }
303
327
  ok(`${current} to ${after}, through the npm that installed it`)
304
328
  out()
329
+ const completion = refreshCompletion(root, stream)
330
+ if (completion.ran === false) note(`tab completion was not refreshed: ${completion.reason}. Run \`omakit setup\`.`)
305
331
  lines(wrap("The marketplace pin did not move: this updated the tool, not the commit its rules are read from. `omakit doctor` says whether that pin is behind, and docs/UPSTREAM_CONTRACT.md says what moving it involves.", {}, c))
306
- return { ok: true, changed: true, from: current, to: after }
332
+ return { ok: true, changed: true, from: current, to: after, completion }
307
333
  }
@@ -22,10 +22,13 @@ export const COMPLETION_SHELLS = Object.freeze(["bash", "zsh", "fish"])
22
22
 
23
23
  export const COMMANDS = Object.freeze([
24
24
  {
25
- signature: "omakit setup",
25
+ signature: "omakit setup [--yes] [--completion]",
26
26
  lines: [
27
27
  "First run, in one command: check the environment, fetch the pinned",
28
- "marketplace checkout, and say what to try first. Idempotent.",
28
+ "marketplace checkout, install tab completion and prove it in a new shell,",
29
+ "and say what to try first. Idempotent. When a new shell has no completion",
30
+ "loader it asks once before adding one guarded block to the rc file; --yes",
31
+ "answers for an agent. --completion is that step alone, never the question.",
29
32
  ],
30
33
  },
31
34
  {
@@ -55,7 +58,7 @@ export const COMMANDS = Object.freeze([
55
58
  ],
56
59
  },
57
60
  {
58
- signature: "omakit watch <issue-url> [--json]",
61
+ signature: "omakit watch <issue-url> [--json] [--out <file>]",
59
62
  lines: [
60
63
  "Compare the commit the marketplace validated on a submission issue with",
61
64
  "the plugin repository's current default-branch HEAD, and say what makes",
@@ -86,7 +89,7 @@ export const COMMANDS = Object.freeze([
86
89
  ],
87
90
  },
88
91
  {
89
- signature: "omakit doctor [--offline] [--json]",
92
+ signature: "omakit doctor [--offline] [--json] [--out <file>]",
90
93
  lines: [
91
94
  "What is installed, what is pinned, and what has moved since. Reads and",
92
95
  "prints; it installs nothing and never moves the pin.",
@@ -99,6 +102,29 @@ export const COMMANDS = Object.freeze([
99
102
  "listed repositories; a packaged install requires --out for evidence.",
100
103
  ],
101
104
  },
105
+ {
106
+ signature: [
107
+ "omakit weigh <plugin-id-or-dir> [--runs <n>] [--window <s>] [--settle <s>]",
108
+ " [--yes] [--json] [--out <file>]",
109
+ "omakit weigh --all",
110
+ "omakit weigh --list [--json]",
111
+ ],
112
+ lines: [
113
+ "What a plugin weighs on the shell, measured: the shell is restarted",
114
+ "without it and with it, several runs, and the difference is the weight,",
115
+ "with the baseline's own spread as the noise floor; memory is printed as",
116
+ "the shell's own startup variance, CPU and child processes as the weight.",
117
+ "The one command that changes your machine: it edits shell.json for the",
118
+ "duration, backs it up first, restores it on every exit path, and asks",
119
+ "before the first restart (--yes answers for you): about a minute per",
120
+ "restart, six restarts for one plugin at three runs. --all weighs every",
121
+ "enabled third-party plugin and is sized for a lab machine, not a working",
122
+ "desktop. Writes the document to --out, by default",
123
+ "$XDG_STATE_HOME/omakit/weigh/<date>.json, and ends with the sentence",
124
+ "for the plugin's README. --list is read-only: every installed plugin",
125
+ "and when it was last weighed, unweighed enabled plugins first.",
126
+ ],
127
+ },
102
128
  ])
103
129
 
104
130
  // A command sits one STEP in from the heading; what it does sits one STEP in