@navbook/cli 0.4.0 → 0.5.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 (78) hide show
  1. package/dist/args.d.ts +28 -0
  2. package/dist/args.js +48 -0
  3. package/dist/args.js.map +1 -0
  4. package/dist/commands/complete.d.ts +10 -0
  5. package/dist/commands/complete.js +59 -51
  6. package/dist/commands/complete.js.map +1 -1
  7. package/dist/commands/compose.d.ts +35 -0
  8. package/dist/commands/doctor.d.ts +15 -0
  9. package/dist/commands/entity.d.ts +73 -0
  10. package/dist/commands/entity.js +59 -44
  11. package/dist/commands/entity.js.map +1 -1
  12. package/dist/commands/init.d.ts +11 -0
  13. package/dist/commands/install.d.ts +17 -0
  14. package/dist/commands/install.js +6 -3
  15. package/dist/commands/install.js.map +1 -1
  16. package/dist/commands/issue.d.ts +35 -0
  17. package/dist/commands/issue.js +4 -5
  18. package/dist/commands/issue.js.map +1 -1
  19. package/dist/commands/plugin.d.ts +36 -0
  20. package/dist/commands/plugin.js +386 -0
  21. package/dist/commands/plugin.js.map +1 -0
  22. package/dist/commands/policy.d.ts +58 -0
  23. package/dist/commands/policy.js +13 -0
  24. package/dist/commands/policy.js.map +1 -1
  25. package/dist/commands/pr-elsewhere.d.ts +40 -0
  26. package/dist/commands/pr-elsewhere.js +209 -0
  27. package/dist/commands/pr-elsewhere.js.map +1 -0
  28. package/dist/commands/pr.d.ts +69 -0
  29. package/dist/commands/pr.js +86 -60
  30. package/dist/commands/pr.js.map +1 -1
  31. package/dist/context.d.ts +42 -0
  32. package/dist/context.js +2 -0
  33. package/dist/context.js.map +1 -1
  34. package/dist/editor.d.ts +23 -0
  35. package/dist/editor.js +4 -0
  36. package/dist/editor.js.map +1 -1
  37. package/dist/errors.d.ts +17 -0
  38. package/dist/install/completions.d.ts +19 -0
  39. package/dist/install/hook.d.ts +32 -0
  40. package/dist/install/hook.js +48 -4
  41. package/dist/install/hook.js.map +1 -1
  42. package/dist/main.d.ts +21 -0
  43. package/dist/main.js +112 -8
  44. package/dist/main.js.map +1 -1
  45. package/dist/plugins/commands.d.ts +69 -0
  46. package/dist/plugins/commands.js +148 -0
  47. package/dist/plugins/commands.js.map +1 -0
  48. package/dist/plugins/hint.d.ts +23 -0
  49. package/dist/plugins/hint.js +65 -0
  50. package/dist/plugins/hint.js.map +1 -0
  51. package/dist/plugins/host.d.ts +125 -0
  52. package/dist/plugins/host.js +18 -0
  53. package/dist/plugins/host.js.map +1 -0
  54. package/dist/plugins/loader.d.ts +54 -0
  55. package/dist/plugins/loader.js +165 -0
  56. package/dist/plugins/loader.js.map +1 -0
  57. package/dist/plugins/resolve.d.ts +50 -0
  58. package/dist/plugins/resolve.js +134 -0
  59. package/dist/plugins/resolve.js.map +1 -0
  60. package/dist/plugins/runtime.d.ts +69 -0
  61. package/dist/plugins/runtime.js +256 -0
  62. package/dist/plugins/runtime.js.map +1 -0
  63. package/dist/plugins/store.d.ts +96 -0
  64. package/dist/plugins/store.js +186 -0
  65. package/dist/plugins/store.js.map +1 -0
  66. package/dist/program.d.ts +49 -0
  67. package/dist/program.js +351 -113
  68. package/dist/program.js.map +1 -1
  69. package/dist/prompt.d.ts +31 -0
  70. package/dist/render/colors.d.ts +13 -0
  71. package/dist/render/detail.d.ts +22 -0
  72. package/dist/render/table.d.ts +32 -0
  73. package/dist/render/table.js +123 -13
  74. package/dist/render/table.js.map +1 -1
  75. package/dist/sort.d.ts +27 -0
  76. package/package.json +9 -2
  77. package/dist/commands/feature.js +0 -202
  78. package/dist/commands/feature.js.map +0 -1
package/dist/program.js CHANGED
@@ -4,16 +4,20 @@
4
4
  */
5
5
  import { readFileSync } from "node:fs";
6
6
  import { fileURLToPath } from "node:url";
7
- import { MERGE_METHODS } from "@navbook/core";
7
+ import { DEADLINE_TERMS, entityJson, MERGE_METHODS, QUERY_STATUSES, queryTermsFor, REVIEW_DECISIONS, readMarkerVersion, } from "@navbook/core";
8
8
  import { Command, Option } from "commander";
9
+ import { finiteNumber, wholeNumber } from "./args.js";
9
10
  import { cmdComplete } from "./commands/complete.js";
10
11
  import { cmdDoctor } from "./commands/doctor.js";
11
12
  import { cmdClose, cmdComment, cmdDelete, cmdEdit, cmdList, cmdReopen, cmdShow, } from "./commands/entity.js";
12
- import { cmdFeatureEdit, cmdFeatureList, cmdFeatureOpen, cmdFeatureShow, cmdSpecAdd, cmdSpecEdit, cmdSpecList, } from "./commands/feature.js";
13
13
  import { cmdId, cmdInit } from "./commands/init.js";
14
14
  import { cmdInstall, cmdUninstall } from "./commands/install.js";
15
15
  import { cmdIssueLink, cmdIssueOpen, cmdIssueUnlink } from "./commands/issue.js";
16
+ import { cmdPluginInstall, cmdPluginList, cmdPluginRemove, cmdPluginUpdate, } from "./commands/plugin.js";
17
+ import { warnNewerFormat } from "./commands/policy.js";
16
18
  import { cmdPrClose, cmdPrList, cmdPrMerge, cmdPrOpen, cmdPrRequest, cmdPrReview, cmdPrUpdate, } from "./commands/pr.js";
19
+ import { YES_HELP } from "./commands/pr-elsewhere.js";
20
+ import { applyOption, buildPluginCommand as buildDeclaredCommand, optionCollision, } from "./plugins/commands.js";
17
21
  import { DEFAULT_SORT, SORT_ORDERS } from "./sort.js";
18
22
  /**
19
23
  * Read straight from package.json rather than duplicating the version as a
@@ -28,27 +32,91 @@ function readOwnVersion() {
28
32
  return pkg.version;
29
33
  }
30
34
  export const VERSION = readOwnVersion();
31
- const QUERY_HELP = `Query terms AND together. Terms:
32
- status:open|closed|merged entity status (path); 'merged' is PR-only
33
- label:L L is among the entity's labels (repeatable, ANDs)
34
- assignee:EMAIL assignee address, or a fragment of its domain
35
- author:EMAIL author address, same matching
36
- milestone:M exact milestone
37
- feature:SLUG SLUG is among the entity's features (repeatable, ANDs)
38
- reviewer:EMAIL asked to review it; PRs only, same matching
39
- review:DECISION pending, approved or changes-requested; PRs only
40
- awaiting:EMAIL asked to review it and has not yet; PRs only
41
- WORD or "some phrase" case-insensitive substring of the title,
42
- description, or any comment body; also the
43
- entity's own ID, from four characters
44
- Same-key terms OR for single-valued fields (status, author, milestone, review)
45
- and AND for multi-valued ones (label, assignee, feature, reviewer, awaiting).
46
- The default query is status:open.`;
35
+ /**
36
+ * What each keyed term means, in the words `--help` uses.
37
+ *
38
+ * Only the prose lives here: which terms exist, which noun has them and how
39
+ * they combine all come from `QUERY_TERMS`, which the parser reads too, and so
40
+ * do the values of the three keys that take a fixed set. Keyed by `QueryKey`,
41
+ * so a term added to the grammar does not type-check until it is described.
42
+ * `status` is the one whose values differ by noun, so `queryHelp` writes it.
43
+ */
44
+ const TERM_HELP = {
45
+ label: { syntax: "label:L", hint: ["L is among the entity's labels (repeatable, ANDs)"] },
46
+ assignee: { syntax: "assignee:EMAIL", hint: ["assignee address, or a fragment of its domain"] },
47
+ author: { syntax: "author:EMAIL", hint: ["author address, same matching"] },
48
+ milestone: { syntax: "milestone:M", hint: ["exact milestone"] },
49
+ reviewer: { syntax: "reviewer:EMAIL", hint: ["asked to review it; same matching"] },
50
+ review: { syntax: "review:DECISION", hint: [`one of ${REVIEW_DECISIONS.join(", ")}`] },
51
+ awaiting: { syntax: "awaiting:EMAIL", hint: ["asked to review it and has not yet"] },
52
+ deadline: {
53
+ syntax: `deadline:${DEADLINE_TERMS.join("|")}`,
54
+ hint: ["overdue: due before today (UTC), strictly;", "none: no deadline at all"],
55
+ },
56
+ };
57
+ /** How wide the syntax column is, as the help has always aligned it. */
58
+ const SYNTAX_WIDTH = 28;
59
+ function helpRow(syntax, hint) {
60
+ return hint.map((line, i) => ` ${(i === 0 ? syntax : "").padEnd(SYNTAX_WIDTH)}${line}`);
61
+ }
62
+ /**
63
+ * A plugin's declared `help` line as rows of the table above.
64
+ *
65
+ * The manifest writes it as the built-in rows read, syntax then a gap then
66
+ * the hint; it is re-aligned here so a plugin need not know the column this
67
+ * help happens to use. A line with no gap is all syntax, and a syntax too wide
68
+ * for its column puts the hint on the next line rather than against it.
69
+ */
70
+ export function pluginHelpRow(help) {
71
+ const [syntax = "", hint = ""] = help.trim().split(/\s{2,}(.*)/s);
72
+ if (hint === "")
73
+ return [` ${syntax}`];
74
+ if (syntax.length >= SYNTAX_WIDTH)
75
+ return [` ${syntax}`, ...helpRow("", [hint])];
76
+ return helpRow(syntax, [hint]);
77
+ }
78
+ /**
79
+ * The query grammar as `nav {issue,pr} list --help` states it.
80
+ *
81
+ * Built per noun, so neither page offers a term its own parser refuses. The
82
+ * terms plugins declared for the noun follow the built-in ones, read from
83
+ * their manifests alone (spec 04 §4.3: help costs no plugin code).
84
+ */
85
+ function queryHelp(kind, pluginKeys = []) {
86
+ const terms = queryTermsFor(kind);
87
+ const rows = terms.flatMap(({ key }) => {
88
+ if (key === "status") {
89
+ return helpRow(`status:${QUERY_STATUSES[kind].join("|")}`, ["entity status (path)"]);
90
+ }
91
+ return helpRow(TERM_HELP[key].syntax, TERM_HELP[key].hint);
92
+ });
93
+ rows.push(...pluginKeys.flatMap((key) => pluginHelpRow(key.help)));
94
+ const combining = (combines) => terms
95
+ .filter((term) => term.combines === combines)
96
+ .map((term) => term.key)
97
+ .join(", ");
98
+ return [
99
+ "Query terms AND together. Terms:",
100
+ ...rows,
101
+ ...helpRow('WORD or "some phrase"', [
102
+ "case-insensitive substring of the title,",
103
+ "description, or any comment body; also the",
104
+ "entity's own ID, from four characters",
105
+ ]),
106
+ `Same-key terms OR for single-valued fields (${combining("or")})`,
107
+ `and AND for multi-valued ones (${combining("and")}).`,
108
+ // A manifest says what its term means but not how it combines, so the
109
+ // sentence above cannot name it; its own description has to.
110
+ ...(pluginKeys.length > 0 ? ["A plugin's terms combine as their descriptions say."] : []),
111
+ "The default query is status:open.",
112
+ ].join("\n");
113
+ }
47
114
  /**
48
115
  * Help for `--commit`, naming the subject the verb commits under (spec 03 §3.2).
49
116
  *
50
- * The argument is the commit's scope rather than an entity kind: `feature` is
51
- * one of the scopes and is deliberately not one of the kinds.
117
+ * The argument is the commit's scope rather than an entity kind: a plugin
118
+ * declares scopes of its own (spec 03 §3.2), and those are deliberately not
119
+ * kinds.
52
120
  */
53
121
  function commitHelp(scope) {
54
122
  return `wrap the change in a 'docs${scope ? `(${scope})` : ""}:' commit`;
@@ -65,7 +133,45 @@ function commitHelp(scope) {
65
133
  function withoutHelpVerb(command) {
66
134
  return command.helpCommand(false);
67
135
  }
68
- export function buildProgram(getCtx) {
136
+ /**
137
+ * The nouns and utilities this CLI defines, which a plugin may not take.
138
+ *
139
+ * Kept as data because two things read it: the collision check that refuses a
140
+ * plugin claiming one of these (spec 04 §4.3), and completion, which offers
141
+ * them alongside whatever plugins added.
142
+ */
143
+ export const BUILTIN_NOUNS = [
144
+ "issue",
145
+ "pr",
146
+ "plugin",
147
+ "init",
148
+ "id",
149
+ "doctor",
150
+ "install",
151
+ "uninstall",
152
+ ];
153
+ /**
154
+ * The top-level commands that do not warn about a newer tree: `doctor` reports
155
+ * it as D16 itself, and the rest set the repository up or print something
156
+ * that does not depend on the format.
157
+ */
158
+ const FORMAT_INDEPENDENT = new Set([
159
+ "init",
160
+ "id",
161
+ "doctor",
162
+ "install",
163
+ "uninstall",
164
+ "plugin",
165
+ "__complete",
166
+ ]);
167
+ /** Whether a command, by its top-level name, answers from the tree. */
168
+ function readsTheTree(action) {
169
+ let top = action;
170
+ while (top.parent?.parent)
171
+ top = top.parent;
172
+ return !FORMAT_INDEPENDENT.has(top.name());
173
+ }
174
+ export function buildProgram(getCtx, plugins) {
69
175
  const program = withoutHelpVerb(new Command());
70
176
  program
71
177
  .name("nav")
@@ -73,6 +179,22 @@ export function buildProgram(getCtx) {
73
179
  .version(VERSION, "-V, --version")
74
180
  .showHelpAfterError()
75
181
  .enablePositionalOptions();
182
+ // Before any command that reads or writes the tree, and before it can fail:
183
+ // an answer from a tool older than the tree is a guess, and the person
184
+ // reading it should know that first (spec 04 §4.3, D16).
185
+ program.hook("preAction", (_program, action) => {
186
+ if (!readsTheTree(action))
187
+ return;
188
+ let ctx;
189
+ try {
190
+ ctx = getCtx();
191
+ }
192
+ catch {
193
+ return; // the action will report what is wrong with the context
194
+ }
195
+ if (ctx.hasNavbook)
196
+ warnNewerFormat(ctx, readMarkerVersion(ctx));
197
+ });
76
198
  program
77
199
  .command("init")
78
200
  .description("create the Navbook skeleton at the repository root")
@@ -81,7 +203,7 @@ export function buildProgram(getCtx) {
81
203
  program
82
204
  .command("id")
83
205
  .description("mint and print a fresh Navbook ID")
84
- .option("-n, --count <n>", "how many IDs to print", (value) => Number.parseInt(value, 10), 1)
206
+ .option("-n, --count <n>", "how many IDs to print", wholeNumber("IDs", 1), 1)
85
207
  .action((opts) => cmdId(getCtx(), opts));
86
208
  program
87
209
  .command("doctor")
@@ -112,13 +234,91 @@ export function buildProgram(getCtx) {
112
234
  .command("__complete", { hidden: true })
113
235
  .description("internal: print completion candidates for the words typed so far")
114
236
  .argument("[words...]")
115
- .action((words) => cmdComplete(getCtx(), words));
116
- program.addCommand(buildIssueCommand(getCtx));
117
- program.addCommand(buildPrCommand(getCtx));
118
- program.addCommand(buildFeatureCommand(getCtx));
237
+ .action(async (words) => cmdComplete(getCtx(), words, plugins));
238
+ program.addCommand(buildPluginCommand(getCtx));
239
+ program.addCommand(buildIssueCommand(getCtx, plugins));
240
+ program.addCommand(buildPrCommand(getCtx, plugins));
241
+ // Built from manifests alone: no plugin code is imported here, which is what
242
+ // lets `nav --help` cost the same with plugins installed as without
243
+ // (spec 04 §4.3).
244
+ for (const [, { plugin, spec }] of plugins?.commands.nouns ?? []) {
245
+ program.addCommand(buildDeclaredCommand(plugin, spec, getCtx, (target, path, args, opts) => plugins.run(getCtx(), target, path, args, opts)));
246
+ }
247
+ warnUnknownContributions(program, getCtx, plugins);
119
248
  return program;
120
249
  }
121
- function buildPrCommand(getCtx) {
250
+ /**
251
+ * Say which contributions name a command that does not exist.
252
+ *
253
+ * Checked once the whole tree is built, so every noun — built-in, shared verb
254
+ * or another plugin's — has had its chance to be the target. A contribution
255
+ * that matches nothing does nothing, and a plugin author who typed
256
+ * `issue lsit` deserves to hear that rather than wonder where their option went.
257
+ */
258
+ function warnUnknownContributions(program, getCtx, plugins) {
259
+ for (const [on, entries] of plugins?.commands.contributions ?? []) {
260
+ let command = program;
261
+ for (const word of on.split(" ").filter(Boolean)) {
262
+ command = command?.commands.find((candidate) => candidate.name() === word);
263
+ }
264
+ if (command !== undefined && command !== program)
265
+ continue;
266
+ for (const { plugin } of entries) {
267
+ stderrOf(getCtx).write(`nav: plugin ${plugin.name} contributes to '${on}', which is not a nav command\n`);
268
+ }
269
+ }
270
+ }
271
+ /**
272
+ * Where to warn while the command tree is being built.
273
+ *
274
+ * The context's stream when there is one; the process's otherwise, because
275
+ * building a context needs a repository and `nav id` outside one must not fail
276
+ * over a warning about a plugin.
277
+ */
278
+ function stderrOf(getCtx) {
279
+ try {
280
+ return getCtx().stderr;
281
+ }
282
+ catch {
283
+ return process.stderr;
284
+ }
285
+ }
286
+ /**
287
+ * `nav plugin` — the store verbs of spec 04 §4.3.
288
+ *
289
+ * The store is per user, not per repository, so none of these needs one:
290
+ * installing a plugin from a home directory is as ordinary as installing it
291
+ * from a checkout. Inside a repository they still read its declaration.
292
+ */
293
+ function buildPluginCommand(getRepoCtx) {
294
+ const getCtx = () => getRepoCtx({ requireRepo: false });
295
+ const plugin = withoutHelpVerb(new Command("plugin")).description("install and manage plugins");
296
+ plugin
297
+ .command("install")
298
+ .argument("[name...]", "packages to install; the repository's declaration otherwise")
299
+ .description("install plugins into the per-user store")
300
+ .option("-y, --yes", "do not ask for confirmation")
301
+ .action((names, opts) => cmdPluginInstall(getCtx(), names, opts));
302
+ plugin
303
+ .command("remove")
304
+ .argument("<name...>", "packages to remove")
305
+ .description("remove plugins from the store")
306
+ .option("-y, --yes", "do not ask for confirmation")
307
+ .action((names, opts) => cmdPluginRemove(getCtx(), names, opts));
308
+ plugin
309
+ .command("update")
310
+ .argument("[name...]", "packages to update; all of them otherwise")
311
+ .description("update installed plugins")
312
+ .option("-y, --yes", "do not ask for confirmation")
313
+ .action((names, opts) => cmdPluginUpdate(getCtx(), names, opts));
314
+ plugin
315
+ .command("list")
316
+ .description("what is installed, and whether this repository declares it")
317
+ .option("--json", "one JSON object per plugin, newline-delimited")
318
+ .action((opts) => cmdPluginList(getCtx(), opts));
319
+ return plugin;
320
+ }
321
+ function buildPrCommand(getCtx, plugins) {
122
322
  const pr = withoutHelpVerb(new Command("pr")).description("work with pull requests");
123
323
  pr.command("open")
124
324
  .description("open a pull request from the current branch")
@@ -130,12 +330,12 @@ function buildPrCommand(getCtx) {
130
330
  .option("--assignee <email>", "assign to a person (repeatable)", collect, [])
131
331
  .option("--reviewer <email>", "ask a person to review it (repeatable)", collect, [])
132
332
  .option("--milestone <name>", "milestone")
133
- .option("--feature <slug>", "attach it to a feature (repeatable)", collect, [])
134
333
  .option("--commit", commitHelp("pr"))
135
- .action((opts) => cmdPrOpen(getCtx(), opts));
334
+ .action(async (opts) => cmdPrOpen(getCtx(), { ...opts, ext: await openFields(getCtx(), plugins, "pr open", opts) }));
136
335
  pr.command("update")
137
336
  .argument("<id>", "ID or unambiguous prefix")
138
337
  .description("append a revision pinning the current HEAD")
338
+ .option("-y, --yes", YES_HELP)
139
339
  .option("--commit", commitHelp("pr"))
140
340
  .action((id, opts) => cmdPrUpdate(getCtx(), id, opts));
141
341
  pr.command("request")
@@ -143,6 +343,7 @@ function buildPrCommand(getCtx) {
143
343
  .argument("<email...>", "who to ask")
144
344
  .description("ask people to review a pull request")
145
345
  .option("--remove", "take them off the reviewers instead")
346
+ .option("-y, --yes", YES_HELP)
146
347
  .option("--commit", commitHelp("pr"))
147
348
  .action((id, people, opts) => cmdPrRequest(getCtx(), id, people, opts));
148
349
  pr.command("review")
@@ -155,6 +356,7 @@ function buildPrCommand(getCtx) {
155
356
  .option("--revision <sha>", "bind to this revision instead of the latest")
156
357
  .option("--file <path>", "anchor the comment to a file")
157
358
  .option("--line <n|start-end>", "anchor the comment to a line or range")
359
+ .option("-y, --yes", YES_HELP)
158
360
  .option("--commit", commitHelp("pr"))
159
361
  .action((id, opts) => cmdPrReview(getCtx(), id, opts));
160
362
  pr.command("merge")
@@ -166,6 +368,7 @@ function buildPrCommand(getCtx) {
166
368
  .option("--no-sync-source", "leave the source branch behind instead of fast-forwarding it")
167
369
  .action((id, opts) => cmdPrMerge(getCtx(), id, opts));
168
370
  addSharedVerbs(pr, "pr", getCtx, {
371
+ ...(plugins ? { plugins, verbPrefix: "pr" } : {}),
169
372
  // No extra columns here: `cmdPrList` owns the PR listing's columns, because
170
373
  // it appends a `refs` one when scanning across branches.
171
374
  extraColumns: [],
@@ -175,74 +378,12 @@ function buildPrCommand(getCtx) {
175
378
  },
176
379
  runList: (ctx, terms, options) => cmdPrList(ctx, terms, options),
177
380
  });
381
+ // After the shared verbs, which are as much this noun's commands as `open`:
382
+ // an option contributed to `pr list` has to find `list` already there.
383
+ applyContributedOptions(pr, "pr", plugins, getCtx);
178
384
  return pr;
179
385
  }
180
- /**
181
- * `nav feature` — spec 04 §4.3.
182
- *
183
- * None of the shared verbs appear here. A feature does not open and close, and
184
- * it is not discussed: the discussion belongs to the issues attached to it. So
185
- * the family is small on purpose, and `spec` groups what acts on the documents
186
- * rather than on the feature itself.
187
- */
188
- function buildFeatureCommand(getCtx) {
189
- const feature = withoutHelpVerb(new Command("feature")).description("work with features");
190
- feature
191
- .command("open")
192
- .argument("<title>", "one-line name for the feature")
193
- .description("create a feature")
194
- .option("-m, --message <text>", "summary text; without it $EDITOR is opened")
195
- .option("--slug <slug>", "directory name to file it under; derived from the title otherwise")
196
- .option("--commit", commitHelp("feature"))
197
- .action((title, opts) => cmdFeatureOpen(getCtx(), title, opts));
198
- feature
199
- .command("list")
200
- .description("list features, with how much work is attached to each")
201
- .option("--json", "one JSON object per feature, newline-delimited")
202
- .action((opts) => cmdFeatureList(getCtx(), opts));
203
- feature
204
- .command("show")
205
- .argument("<slug>", "the feature's directory name")
206
- .description("render a feature, its documents, and what has touched it")
207
- .option("--json", "emit a single JSON object naming its issues and pull requests")
208
- .option("--commits <n>", "recent commits to list (default 10)", (value) => value.trim() === "" ? Number.NaN : Number(value))
209
- .action((slug, opts) => cmdFeatureShow(getCtx(), slug, opts));
210
- feature
211
- .command("edit")
212
- .argument("<slug>", "the feature's directory name")
213
- .description("open the feature's feature.md in $EDITOR")
214
- .option("--commit", commitHelp("feature"))
215
- .action((slug, opts) => cmdFeatureEdit(getCtx(), slug, opts));
216
- feature.addCommand(buildSpecCommand(getCtx));
217
- return feature;
218
- }
219
- function buildSpecCommand(getCtx) {
220
- const spec = withoutHelpVerb(new Command("spec")).description("work with a feature's specification documents");
221
- spec
222
- .command("add")
223
- .argument("<slug>", "the feature's directory name")
224
- .argument("<title>", "one-line name for the document")
225
- .description("add a specification document to a feature")
226
- .option("-m, --message <text>", "document text; without it $EDITOR is opened")
227
- .option("--file <name>", "file to write it to; derived from the title otherwise")
228
- .option("--commit", commitHelp("feature"))
229
- .action((slug, title, opts) => cmdSpecAdd(getCtx(), slug, title, opts));
230
- spec
231
- .command("edit")
232
- .argument("<slug>", "the feature's directory name")
233
- .argument("<file>", "the document's file name")
234
- .description("open a specification document in $EDITOR")
235
- .option("--commit", commitHelp("feature"))
236
- .action((slug, file, opts) => cmdSpecEdit(getCtx(), slug, file, opts));
237
- spec
238
- .command("list")
239
- .argument("<slug>", "the feature's directory name")
240
- .description("list a feature's specification documents")
241
- .option("--json", "one JSON object per document, newline-delimited")
242
- .action((slug, opts) => cmdSpecList(getCtx(), slug, opts));
243
- return spec;
244
- }
245
- function buildIssueCommand(getCtx) {
386
+ function buildIssueCommand(getCtx, plugins) {
246
387
  const issue = withoutHelpVerb(new Command("issue")).description("work with issues");
247
388
  issue
248
389
  .command("open")
@@ -252,15 +393,14 @@ function buildIssueCommand(getCtx) {
252
393
  .option("--label <label>", "add a label (repeatable)", collect, [])
253
394
  .option("--assignee <email>", "assign to a person (repeatable)", collect, [])
254
395
  .option("--milestone <name>", "milestone")
255
- .option("--feature <slug>", "attach it to a feature (repeatable)", collect, [])
256
- // Number rather than parseInt, and blank rather than 0, for the reason
257
- // `--depth` does it: a value the command must refuse has to reach it
258
- // intact rather than arrive silently rounded or defaulted.
259
- .option("--rank <n>", "where it sits in the queue; lower first", (value) => value.trim() === "" ? Number.NaN : Number(value))
396
+ .option("--rank <n>", "where it sits in the queue; lower first", finiteNumber)
260
397
  .option("--deadline <date>", "when the work is wanted, YYYY-MM-DD")
261
398
  .option("--parent <id>", "file it as a subtask of an existing issue")
262
399
  .option("--commit", commitHelp("issue"))
263
- .action((title, opts) => cmdIssueOpen(getCtx(), title, opts));
400
+ .action(async (title, opts) => cmdIssueOpen(getCtx(), title, {
401
+ ...opts,
402
+ ext: await openFields(getCtx(), plugins, "issue open", opts),
403
+ }));
264
404
  issue
265
405
  .command("link")
266
406
  .argument("<id>", "ID or unambiguous prefix")
@@ -276,17 +416,44 @@ function buildIssueCommand(getCtx) {
276
416
  .option("--commit", commitHelp("issue"))
277
417
  .action((id, opts) => cmdIssueUnlink(getCtx(), id, opts));
278
418
  addSharedVerbs(issue, "issue", getCtx, {
419
+ ...(plugins ? { plugins, verbPrefix: "issue" } : {}),
279
420
  extraColumns: [],
280
421
  configureList: (command) => command.option("--sort <order>", `listing order: ${SORT_ORDERS.join(", ")} (default ${DEFAULT_SORT})`),
281
422
  configureShow: (command) =>
282
- // Number, not parseInt: '2.5' has to reach the command as 2.5 so it can
283
- // be refused, rather than being silently rounded to something valid.
284
423
  // No default here — `cmdShow` owns it, so every caller gets the same one.
285
- command.option("--depth <n>", "levels of subtasks to render (default 1)", (value) => value.trim() === "" ? Number.NaN : Number(value)),
424
+ command.option("--depth <n>", "levels of subtasks to render (default 1)", wholeNumber("levels")),
286
425
  configureDelete: (command) => command.option("-r, --recursive", "delete its subtasks too, to any depth"),
287
426
  });
427
+ // After the shared verbs, for the reason `buildPrCommand` gives.
428
+ applyContributedOptions(issue, "issue", plugins, getCtx);
288
429
  return issue;
289
430
  }
431
+ /** What plugins contributed to one of this noun's verbs, loading them if any. */
432
+ async function verbHandlers(ctx, shared, verb) {
433
+ if (shared.plugins === undefined || shared.verbPrefix === undefined)
434
+ return [];
435
+ return shared.plugins.handlersFor(ctx, `${shared.verbPrefix} ${verb}`);
436
+ }
437
+ /**
438
+ * One `jsonExtra` from several, or undefined when no plugin contributed one.
439
+ *
440
+ * A key the entity's own JSON already has is dropped: `title`, `status` and
441
+ * every frontmatter key are the format's answer, and the one a reader of
442
+ * `nav --json` is entitled to (spec 04 §4.2). A plugin adds keys; it does not
443
+ * get to quietly replace the built-in ones.
444
+ */
445
+ function mergedJsonExtra(handlers) {
446
+ const contributors = handlers.filter((handler) => handler.jsonExtra !== undefined);
447
+ if (contributors.length === 0)
448
+ return undefined;
449
+ return (entity) => {
450
+ const merged = Object.assign({}, ...contributors.map((handler) => handler.jsonExtra?.(entity) ?? {}));
451
+ // Which keys exist does not depend on the Navbook directory's name.
452
+ for (const key of Object.keys(entityJson("", entity)))
453
+ delete merged[key];
454
+ return merged;
455
+ };
456
+ }
290
457
  /** Register the verbs both nouns share, so their behavior can never drift. */
291
458
  export function addSharedVerbs(parent, kind, getCtx, shared) {
292
459
  const noun = kind === "issue" ? "issue" : "pull request";
@@ -295,31 +462,55 @@ export function addSharedVerbs(parent, kind, getCtx, shared) {
295
462
  .command("list")
296
463
  .argument("[query...]", "query terms; default status:open")
297
464
  .description(`list ${kind === "issue" ? "issues" : "pull requests"} matching a query`)
298
- .addHelpText("after", `\n${QUERY_HELP}`)
465
+ .addHelpText("after", `\n${queryHelp(kind, shared.plugins?.queryKeys(kind))}`)
299
466
  .option("--json", "one JSON object per entity, newline-delimited");
300
467
  shared.configureList?.(list);
301
- list.action((terms, opts) => shared.runList
302
- ? shared.runList(getCtx(), terms, { ...opts, extraColumns })
303
- : cmdList(getCtx(), kind, terms, { ...opts, extraColumns }));
468
+ list.action(async (terms, opts) => {
469
+ const contributed = await verbHandlers(getCtx(), shared, "list");
470
+ const columns = [...extraColumns, ...contributed.flatMap((h) => h.columns ?? [])];
471
+ const jsonExtra = mergedJsonExtra(contributed);
472
+ const options = { ...opts, extraColumns: columns, ...(jsonExtra ? { jsonExtra } : {}) };
473
+ if (shared.runList)
474
+ shared.runList(getCtx(), terms, options);
475
+ else
476
+ cmdList(getCtx(), kind, terms, options);
477
+ });
304
478
  const show = parent
305
479
  .command("show")
306
480
  .argument("<id>", "ID or unambiguous prefix")
307
481
  .description(`render one ${noun} and its comments`)
308
482
  .option("--json", "emit a single JSON object including comments");
309
483
  shared.configureShow?.(show);
310
- show.action((id, opts) => cmdShow(getCtx(), kind, id, opts));
311
- parent
484
+ show.action(async (id, opts) => {
485
+ const contributed = await verbHandlers(getCtx(), shared, "show");
486
+ const jsonExtra = mergedJsonExtra(contributed);
487
+ const sections = contributed.flatMap((handlers) => handlers.showSection ? [handlers.showSection] : []);
488
+ cmdShow(getCtx(), kind, id, {
489
+ ...opts,
490
+ ...(jsonExtra ? { jsonExtra } : {}),
491
+ ...(sections.length > 0 ? { sections } : {}),
492
+ });
493
+ });
494
+ // An issue lives on whatever branch you are standing on, so there is never
495
+ // another worktree to send its write to: the flag is a pull-request notion.
496
+ const edit = parent
312
497
  .command("edit")
313
498
  .argument("<id>", "ID or unambiguous prefix")
314
- .description(`open the ${noun}'s file in $EDITOR`)
499
+ .description(`open the ${noun}'s file in $EDITOR`);
500
+ if (kind === "pr")
501
+ edit.option("-y, --yes", YES_HELP);
502
+ edit
315
503
  .option("--commit", commitHelp(kind))
316
504
  .action((id, opts) => cmdEdit(getCtx(), kind, id, opts));
317
- parent
505
+ const comment = parent
318
506
  .command("comment")
319
507
  .argument("<id>", "ID or unambiguous prefix")
320
508
  .description(`add a comment to a ${noun}`)
321
509
  .option("-m, --message <text>", "comment text; without it $EDITOR is opened")
322
- .option("--reply-to <comment-id>", "comment this one replies to")
510
+ .option("--reply-to <comment-id>", "comment this one replies to");
511
+ if (kind === "pr")
512
+ comment.option("-y, --yes", YES_HELP);
513
+ comment
323
514
  .option("--commit", commitHelp(kind))
324
515
  .action((id, opts) => cmdComment(getCtx(), kind, id, opts));
325
516
  parent
@@ -354,5 +545,52 @@ export function addSharedVerbs(parent, kind, getCtx, shared) {
354
545
  export function collect(value, previous) {
355
546
  return [...previous, value];
356
547
  }
548
+ /**
549
+ * Add the options plugins declared for one verb, refusing a collision.
550
+ *
551
+ * From the manifest, so no plugin code runs: a `--feature` on `issue open`
552
+ * exists in `--help` and in completion whether or not anybody has run the
553
+ * command that would load the plugin implementing it.
554
+ */
555
+ function applyContributedOptions(noun, kind, plugins, getCtx) {
556
+ if (plugins === undefined)
557
+ return;
558
+ for (const [on, entries] of plugins.commands.contributions) {
559
+ const [target, verb] = on.split(" ");
560
+ if (target !== kind || verb === undefined)
561
+ continue;
562
+ const command = noun.commands.find((candidate) => candidate.name() === verb);
563
+ if (command === undefined)
564
+ continue;
565
+ for (const { plugin, spec } of entries) {
566
+ for (const option of spec.options ?? []) {
567
+ const problem = optionCollision(command, option);
568
+ if (problem !== null) {
569
+ // Before `.option()`, which throws on a duplicate flag: the plugin
570
+ // loses its option and says so, rather than taking the CLI down.
571
+ stderrOf(getCtx).write(`nav: plugin ${plugin.name} option skipped on '${on}': ${problem}\n`);
572
+ continue;
573
+ }
574
+ applyOption(command, option);
575
+ }
576
+ }
577
+ }
578
+ }
579
+ /**
580
+ * Frontmatter a plugin wants on a newly opened entity, read off its options.
581
+ *
582
+ * This is the one contribution that loads plugin code on a built-in verb, and
583
+ * only when a plugin declared a contribution to *this* verb — so `nav issue
584
+ * open` with no such plugin imports nothing.
585
+ */
586
+ async function openFields(ctx, plugins, verb, opts) {
587
+ if (plugins === undefined || !plugins.commands.contributions.has(verb))
588
+ return undefined;
589
+ const fields = {};
590
+ for (const handlers of await plugins.handlersFor(ctx, verb)) {
591
+ Object.assign(fields, handlers.openFields?.(opts) ?? {});
592
+ }
593
+ return Object.keys(fields).length > 0 ? fields : undefined;
594
+ }
357
595
  export { Option };
358
596
  //# sourceMappingURL=program.js.map