@camunda8/cli 2.8.0-alpha.1 → 2.8.0-alpha.11

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 (92) hide show
  1. package/PLUGIN-HELP.md +170 -8
  2. package/README.md +1186 -73
  3. package/dist/command-dispatch.d.ts.map +1 -1
  4. package/dist/command-dispatch.js +5 -3
  5. package/dist/command-dispatch.js.map +1 -1
  6. package/dist/command-framework.d.ts.map +1 -1
  7. package/dist/command-framework.js +22 -1
  8. package/dist/command-framework.js.map +1 -1
  9. package/dist/command-registry.d.ts +61 -311
  10. package/dist/command-registry.d.ts.map +1 -1
  11. package/dist/command-registry.js +64 -33
  12. package/dist/command-registry.js.map +1 -1
  13. package/dist/commands/completion.d.ts +16 -33
  14. package/dist/commands/completion.d.ts.map +1 -1
  15. package/dist/commands/completion.js +31 -689
  16. package/dist/commands/completion.js.map +1 -1
  17. package/dist/commands/mcp-proxy.d.ts +0 -17
  18. package/dist/commands/mcp-proxy.d.ts.map +1 -1
  19. package/dist/commands/mcp-proxy.js +3 -104
  20. package/dist/commands/mcp-proxy.js.map +1 -1
  21. package/dist/commands/open.d.ts +3 -44
  22. package/dist/commands/open.d.ts.map +1 -1
  23. package/dist/commands/open.js +5 -81
  24. package/dist/commands/open.js.map +1 -1
  25. package/dist/commands/plugins.d.ts +23 -8
  26. package/dist/commands/plugins.d.ts.map +1 -1
  27. package/dist/commands/plugins.js +58 -29
  28. package/dist/commands/plugins.js.map +1 -1
  29. package/dist/commands/run.d.ts +8 -8
  30. package/dist/commands/run.d.ts.map +1 -1
  31. package/dist/commands/run.js +60 -60
  32. package/dist/commands/run.js.map +1 -1
  33. package/dist/commands/search.d.ts +2 -39
  34. package/dist/commands/search.d.ts.map +1 -1
  35. package/dist/commands/search.js +2 -83
  36. package/dist/commands/search.js.map +1 -1
  37. package/dist/commands/session.d.ts +1 -0
  38. package/dist/commands/session.d.ts.map +1 -1
  39. package/dist/commands/session.js +5 -0
  40. package/dist/commands/session.js.map +1 -1
  41. package/dist/commands/watch.d.ts +2 -1
  42. package/dist/commands/watch.d.ts.map +1 -1
  43. package/dist/commands/watch.js +17 -8
  44. package/dist/commands/watch.js.map +1 -1
  45. package/dist/completion.d.ts +36 -0
  46. package/dist/completion.d.ts.map +1 -0
  47. package/dist/completion.js +816 -0
  48. package/dist/completion.js.map +1 -0
  49. package/dist/config.d.ts.map +1 -1
  50. package/dist/config.js +19 -1
  51. package/dist/config.js.map +1 -1
  52. package/dist/{commands/deployments.d.ts → deployments.d.ts} +1 -1
  53. package/dist/deployments.d.ts.map +1 -0
  54. package/dist/{commands/deployments.js → deployments.js} +8 -8
  55. package/dist/deployments.js.map +1 -0
  56. package/dist/help.d.ts.map +1 -0
  57. package/dist/{commands/help.js → help.js} +68 -4
  58. package/dist/help.js.map +1 -0
  59. package/dist/index.d.ts +23 -0
  60. package/dist/index.d.ts.map +1 -1
  61. package/dist/index.js +285 -9
  62. package/dist/index.js.map +1 -1
  63. package/dist/mcp-proxy-helpers.d.ts +23 -0
  64. package/dist/mcp-proxy-helpers.d.ts.map +1 -0
  65. package/dist/mcp-proxy-helpers.js +109 -0
  66. package/dist/mcp-proxy-helpers.js.map +1 -0
  67. package/dist/open-helpers.d.ts +52 -0
  68. package/dist/open-helpers.d.ts.map +1 -0
  69. package/dist/open-helpers.js +88 -0
  70. package/dist/open-helpers.js.map +1 -0
  71. package/dist/plugin-loader.d.ts +102 -2
  72. package/dist/plugin-loader.d.ts.map +1 -1
  73. package/dist/plugin-loader.js +262 -22
  74. package/dist/plugin-loader.js.map +1 -1
  75. package/dist/plugin-version.d.ts +15 -0
  76. package/dist/plugin-version.d.ts.map +1 -0
  77. package/dist/plugin-version.js +37 -0
  78. package/dist/plugin-version.js.map +1 -0
  79. package/dist/search-helpers.d.ts +46 -0
  80. package/dist/search-helpers.d.ts.map +1 -0
  81. package/dist/search-helpers.js +90 -0
  82. package/dist/search-helpers.js.map +1 -0
  83. package/dist/watch-constants.d.ts +7 -0
  84. package/dist/watch-constants.d.ts.map +1 -0
  85. package/dist/watch-constants.js +7 -0
  86. package/dist/watch-constants.js.map +1 -0
  87. package/package.json +11 -7
  88. package/dist/commands/deployments.d.ts.map +0 -1
  89. package/dist/commands/deployments.js.map +0 -1
  90. package/dist/commands/help.d.ts.map +0 -1
  91. package/dist/commands/help.js.map +0 -1
  92. /package/dist/{commands/help.d.ts → help.d.ts} +0 -0
@@ -1,693 +1,14 @@
1
1
  /**
2
- * Shell completion commands derived from COMMAND_REGISTRY + plugins.
3
- */
4
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
- import { homedir, platform } from "node:os";
6
- import { join } from "node:path";
7
- import { defineCommand } from "../command-framework.js";
8
- import { COMMAND_REGISTRY, GLOBAL_FLAGS, RESOURCE_ALIASES, } from "../command-registry.js";
9
- import { getUserDataDir } from "../config.js";
10
- import { getLogger } from "../logger.js";
11
- import { getPluginCommandsInfo, } from "../plugin-loader.js";
12
- import { c8ctl } from "../runtime.js";
13
- // ─── Typed helpers (same pattern as help.ts) ─────────────────────────────────
14
- /** Typed entries for COMMAND_REGISTRY — avoids per-loop casts. */
15
- function registryEntries() {
16
- return Object.entries(COMMAND_REGISTRY);
17
- }
18
- /** Typed entries for a flag record — avoids per-loop casts. */
19
- function flagEntries(flags) {
20
- return Object.entries(flags);
21
- }
22
- // ─── Derived completion data ─────────────────────────────────────────────────
23
- /** Reverse map: canonical resource → all names that resolve to it (including itself). */
24
- function buildCanonicalToAliases() {
25
- const map = new Map();
26
- // Seed with every known canonical name from the registry
27
- for (const [, def] of registryEntries()) {
28
- for (const r of def.resources) {
29
- const canonical = RESOURCE_ALIASES[r] ?? r;
30
- if (!map.has(canonical)) {
31
- map.set(canonical, [canonical]);
32
- }
33
- }
34
- }
35
- // Add every alias
36
- for (const [alias, canonical] of Object.entries(RESOURCE_ALIASES)) {
37
- if (!map.has(canonical)) {
38
- map.set(canonical, [canonical]);
39
- }
40
- const arr = map.get(canonical) ?? [];
41
- if (!arr.includes(alias)) {
42
- arr.push(alias);
43
- }
44
- }
45
- return map;
46
- }
47
- /** All accepted forms for a verb's resources (canonical + all aliases). */
48
- function resourceFormsForVerb(def, canonicalToAliases) {
49
- const seen = new Set();
50
- for (const r of def.resources) {
51
- const canonical = RESOURCE_ALIASES[r] ?? r;
52
- for (const form of canonicalToAliases.get(canonical) ?? [canonical]) {
53
- seen.add(form);
54
- }
55
- }
56
- return [...seen];
57
- }
58
- function deriveVerbInfos(pluginCommandsInfo) {
59
- const canonicalToAliases = buildCanonicalToAliases();
60
- const infos = [];
61
- for (const [verb, def] of registryEntries()) {
62
- // Skip mcp-proxy — not a user-facing verb
63
- if (verb === "mcp-proxy")
64
- continue;
65
- const resources = resourceFormsForVerb(def, canonicalToAliases);
66
- const fileComplete = !def.requiresResource &&
67
- def.resources.length === 0 &&
68
- ["deploy", "run", "watch"].includes(verb);
69
- infos.push({
70
- verb,
71
- description: def.description,
72
- resources,
73
- aliases: def.aliases ?? [],
74
- fileComplete,
75
- });
76
- }
77
- // Plugin-provided verbs (e.g. cluster)
78
- for (const cmd of pluginCommandsInfo) {
79
- // Skip if already in registry
80
- if (cmd.commandName in COMMAND_REGISTRY)
81
- continue;
82
- infos.push({
83
- verb: cmd.commandName,
84
- description: cmd.description ?? "",
85
- resources: (cmd.subcommands ?? []).map((s) => s.name),
86
- aliases: [],
87
- fileComplete: false,
88
- });
89
- }
90
- return infos;
91
- }
92
- /** Collect all unique flag names across all commands + global + search flags. */
93
- function deriveAllFlagNames() {
94
- const names = new Set();
95
- for (const name of Object.keys(GLOBAL_FLAGS)) {
96
- names.add(name);
97
- }
98
- for (const [, def] of registryEntries()) {
99
- for (const [name] of flagEntries(def.flags)) {
100
- names.add(name);
101
- }
102
- if (def.resourceFlags) {
103
- for (const rf of Object.values(def.resourceFlags)) {
104
- for (const name of Object.keys(rf)) {
105
- names.add(name);
106
- }
107
- }
108
- }
109
- }
110
- return [...names].map((n) => `--${n}`);
111
- }
112
- /** Collect all flags with descriptions and types for rich completions (zsh/fish). */
113
- function deriveAllFlags() {
114
- const seen = new Map();
115
- function addFlags(flags) {
116
- for (const [name, def] of flagEntries(flags)) {
117
- if (!seen.has(name)) {
118
- seen.set(name, {
119
- description: def.description,
120
- type: def.type,
121
- short: def.short,
122
- });
123
- }
124
- }
125
- }
126
- addFlags(GLOBAL_FLAGS);
127
- for (const [, def] of registryEntries()) {
128
- addFlags(def.flags);
129
- if (def.resourceFlags) {
130
- for (const rf of Object.values(def.resourceFlags)) {
131
- addFlags(rf);
132
- }
133
- }
134
- }
135
- return [...seen].map(([name, info]) => ({
136
- name,
137
- ...info,
138
- }));
139
- }
140
- /** Get resources for the `help` verb: verbs with hasDetailedHelp + special topics. */
141
- function deriveHelpResources() {
142
- const items = [];
143
- for (const [verb, def] of registryEntries()) {
144
- if (def.hasDetailedHelp) {
145
- items.push({ name: verb, description: `Show ${verb} command help` });
146
- }
147
- }
148
- // Special topics that aren't verbs but have help pages
149
- items.push({
150
- name: "profiles",
151
- description: "Show profile management help",
152
- }, {
153
- name: "profile",
154
- description: "Alias for profile management help",
155
- }, {
156
- name: "plugin",
157
- description: "Show plugin management help",
158
- }, {
159
- name: "plugins",
160
- description: "Alias for plugin management help",
161
- });
162
- // Plugin verbs
163
- const pluginCmds = getPluginCommandsInfo();
164
- for (const cmd of pluginCmds) {
165
- if (!items.some((i) => i.name === cmd.commandName) &&
166
- !(cmd.commandName in COMMAND_REGISTRY)) {
167
- items.push({
168
- name: cmd.commandName,
169
- description: cmd.description
170
- ? `Show ${cmd.commandName} command help`
171
- : `No detailed help; use c8ctl help for general usage`,
172
- });
173
- }
174
- }
175
- return items;
176
- }
177
- // ─── Bash completion ─────────────────────────────────────────────────────────
178
- function generateBashCompletion() {
179
- const pluginCmds = getPluginCommandsInfo();
180
- const verbInfos = deriveVerbInfos(pluginCmds);
181
- const allFlags = deriveAllFlagNames();
182
- const helpResources = deriveHelpResources();
183
- // All verb names (including aliases)
184
- const allVerbs = new Set();
185
- for (const v of verbInfos) {
186
- allVerbs.add(v.verb);
187
- for (const a of v.aliases)
188
- allVerbs.add(a);
189
- }
190
- const verbsStr = [...allVerbs].join(" ");
191
- const flagsStr = allFlags.join(" ");
192
- // Build per-verb resource variables
193
- const resourceVars = [];
194
- const caseBranches = [];
195
- for (const v of verbInfos) {
196
- if (v.verb === "help") {
197
- // Help completes to verbs/topics, not resources
198
- resourceVars.push(` local help_resources="${helpResources.map((r) => r.name).join(" ")}"`);
199
- caseBranches.push(` help)\n COMPREPLY=( $(compgen -W "\${help_resources}" -- "\${cur}") )\n ;;`);
200
- continue;
201
- }
202
- if (v.fileComplete) {
203
- // deploy/run/watch complete with files
204
- caseBranches.push(` ${v.verb})\n COMPREPLY=( $(compgen -f -- "\${cur}") )\n ;;`);
205
- continue;
206
- }
207
- if (v.resources.length === 0)
208
- continue;
209
- const varName = `${v.verb.replace(/-/g, "_")}_resources`;
210
- resourceVars.push(` local ${varName}="${v.resources.join(" ")}"`);
211
- // Include aliases in the case pattern
212
- const casePattern = v.aliases.length > 0 ? `${v.verb}|${v.aliases.join("|")}` : v.verb;
213
- caseBranches.push(` ${casePattern})\n COMPREPLY=( $(compgen -W "\${${varName}}" -- "\${cur}") )\n ;;`);
214
- }
215
- return `# c8ctl-completion-version: ${c8ctl.version}
216
- # c8ctl bash completion
217
- _c8ctl_completions() {
218
- local cur prev words cword
219
-
220
- # Initialize completion variables (standalone, no bash-completion dependency)
221
- COMPREPLY=()
222
- cur="\${COMP_WORDS[COMP_CWORD]}"
223
- prev="\${COMP_WORDS[COMP_CWORD-1]}"
224
- words=("\${COMP_WORDS[@]}")
225
- cword=\${COMP_CWORD}
226
-
227
- # Commands (verbs)
228
- local verbs="${verbsStr}"
229
-
230
- # Resources by verb
231
- ${resourceVars.join("\n")}
232
-
233
- # Global flags
234
- local flags="${flagsStr}"
235
-
236
- case \${cword} in
237
- 1)
238
- # Complete verbs
239
- COMPREPLY=( $(compgen -W "\${verbs}" -- "\${cur}") )
240
- ;;
241
- 2)
242
- # Complete resources based on verb
243
- local verb="\${words[1]}"
244
- case "\${verb}" in
245
- ${caseBranches.join("\n")}
246
- esac
247
- ;;
248
- *)
249
- # Complete flags or files
250
- if [[ \${cur} == -* ]]; then
251
- COMPREPLY=( $(compgen -W "\${flags}" -- "\${cur}") )
252
- else
253
- COMPREPLY=( $(compgen -f -- "\${cur}") )
254
- fi
255
- ;;
256
- esac
257
- }
258
-
259
- complete -F _c8ctl_completions c8ctl
260
- complete -F _c8ctl_completions c8
261
- `;
262
- }
263
- // ─── Zsh completion ──────────────────────────────────────────────────────────
264
- function generateZshCompletion() {
265
- const pluginCmds = getPluginCommandsInfo();
266
- const verbInfos = deriveVerbInfos(pluginCmds);
267
- const allFlags = deriveAllFlags();
268
- const helpResources = deriveHelpResources();
269
- // Verb entries: 'verb:description'
270
- const verbEntries = verbInfos.map((v) => {
271
- const items = [` '${v.verb}:${escZsh(v.description)}'`];
272
- for (const a of v.aliases) {
273
- items.push(` '${a}:${escZsh(v.description)}'`);
274
- }
275
- return items.join("\n");
276
- });
277
- // Flag entries: '--flag[description]:hint:' or '--flag[description]'
278
- const flagEntryLines = allFlags.map((f) => {
279
- const desc = escZsh(f.description);
280
- if (f.short) {
281
- return ` '-${f.short}[${desc}]'\n '--${f.name}[${desc}]${f.type === "string" ? `:${f.name}:` : ""}'`;
282
- }
283
- return ` '--${f.name}[${desc}]${f.type === "string" ? `:${f.name}:` : ""}'`;
284
- });
285
- // Per-verb resource case branches
286
- const resourceCases = [];
287
- for (const v of verbInfos) {
288
- if (v.verb === "help") {
289
- const entries = helpResources.map((r) => ` '${r.name}:${escZsh(r.description)}'`);
290
- resourceCases.push(` help)\n resources=(\n${entries.join("\n")}\n )\n _describe 'resource' resources\n ;;`);
291
- continue;
292
- }
293
- if (v.fileComplete) {
294
- const casePattern = v.aliases.length > 0 ? `${v.verb}|${v.aliases.join("|")}` : v.verb;
295
- resourceCases.push(` ${casePattern})\n _files\n ;;`);
296
- continue;
297
- }
298
- if (v.resources.length === 0)
299
- continue;
300
- const entries = v.resources.map((r) => ` '${r}:${escZsh(capitalize(v.verb))} ${resourceDisplayName(r)}'`);
301
- const casePattern = v.aliases.length > 0 ? `${v.verb}|${v.aliases.join("|")}` : v.verb;
302
- resourceCases.push(` ${casePattern})\n resources=(\n${entries.join("\n")}\n )\n _describe 'resource' resources\n ;;`);
303
- }
304
- return `#compdef c8ctl c8
305
- # c8ctl-completion-version: ${c8ctl.version}
306
-
307
- _c8ctl() {
308
- local -a verbs resources flags
309
-
310
- verbs=(
311
- ${verbEntries.join("\n")}
312
- )
313
-
314
- flags=(
315
- ${flagEntryLines.join("\n")}
316
- )
317
-
318
- case $CURRENT in
319
- 2)
320
- _describe 'command' verbs
321
- ;;
322
- 3)
323
- case "\${words[2]}" in
324
- ${resourceCases.join("\n")}
325
- esac
326
- ;;
327
- *)
328
- _arguments \${flags[@]}
329
- ;;
330
- esac
331
- }
332
-
333
- if (( $+functions[compdef] )); then
334
- compdef _c8ctl c8ctl c8
335
- fi
336
- `;
337
- }
338
- // ─── Fish completion ─────────────────────────────────────────────────────────
339
- function generateFishCompletion() {
340
- const pluginCmds = getPluginCommandsInfo();
341
- const verbInfos = deriveVerbInfos(pluginCmds);
342
- const allFlags = deriveAllFlags();
343
- const helpResources = deriveHelpResources();
344
- const lines = [
345
- "# c8ctl fish completion",
346
- "",
347
- "# Remove all existing completions for c8ctl and c8",
348
- "complete -c c8ctl -e",
349
- "complete -c c8 -e",
350
- "",
351
- ];
352
- // Global flags
353
- lines.push("# Global flags");
354
- for (const f of allFlags) {
355
- const desc = escFish(f.description);
356
- const req = f.type === "string" ? " -r" : "";
357
- if (f.short) {
358
- lines.push(`complete -c c8ctl -s ${f.short} -l ${f.name} -d '${desc}'${req}`);
359
- lines.push(`complete -c c8 -s ${f.short} -l ${f.name} -d '${desc}'${req}`);
360
- }
361
- else {
362
- lines.push(`complete -c c8ctl -l ${f.name} -d '${desc}'${req}`);
363
- lines.push(`complete -c c8 -l ${f.name} -d '${desc}'${req}`);
364
- }
365
- }
366
- lines.push("");
367
- // Verb completions
368
- lines.push("# Commands (verbs) - only suggest when no command is given yet");
369
- for (const v of verbInfos) {
370
- const desc = escFish(v.description);
371
- lines.push(`complete -c c8ctl -n '__fish_use_subcommand' -a '${v.verb}' -d '${desc}'`);
372
- lines.push(`complete -c c8 -n '__fish_use_subcommand' -a '${v.verb}' -d '${desc}'`);
373
- for (const a of v.aliases) {
374
- lines.push(`complete -c c8ctl -n '__fish_use_subcommand' -a '${a}' -d '${desc}'`);
375
- lines.push(`complete -c c8 -n '__fish_use_subcommand' -a '${a}' -d '${desc}'`);
376
- }
377
- }
378
- lines.push("");
379
- // Per-verb resource completions
380
- for (const v of verbInfos) {
381
- if (v.verb === "help") {
382
- lines.push(`# Resources for 'help' command`);
383
- for (const r of helpResources) {
384
- const desc = escFish(r.description);
385
- lines.push(`complete -c c8ctl -n '__fish_seen_subcommand_from help' -a '${r.name}' -d '${desc}'`);
386
- lines.push(`complete -c c8 -n '__fish_seen_subcommand_from help' -a '${r.name}' -d '${desc}'`);
387
- }
388
- lines.push("");
389
- continue;
390
- }
391
- if (v.fileComplete || v.resources.length === 0)
392
- continue;
393
- const seenFrom = [v.verb, ...v.aliases].join(" ");
394
- lines.push(`# Resources for '${v.verb}' command`);
395
- for (const r of v.resources) {
396
- const desc = escFish(`${capitalize(v.verb)} ${resourceDisplayName(r)}`);
397
- lines.push(`complete -c c8ctl -n '__fish_seen_subcommand_from ${seenFrom}' -a '${r}' -d '${desc}'`);
398
- lines.push(`complete -c c8 -n '__fish_seen_subcommand_from ${seenFrom}' -a '${r}' -d '${desc}'`);
399
- }
400
- lines.push("");
401
- }
402
- lines.unshift(`# c8ctl-completion-version: ${c8ctl.version}`);
403
- return `${lines.join("\n")}\n`;
404
- }
405
- // ─── Helpers ─────────────────────────────────────────────────────────────────
406
- function capitalize(s) {
407
- return s.charAt(0).toUpperCase() + s.slice(1);
408
- }
409
- /** Human-readable name from resource alias. */
410
- function resourceDisplayName(resource) {
411
- const canonical = RESOURCE_ALIASES[resource] ?? resource;
412
- return canonical.replace(/-/g, " ") + (resource !== canonical ? "s" : "");
413
- }
414
- function escZsh(s) {
415
- return s.replace(/'/g, "'\\''");
416
- }
417
- function escFish(s) {
418
- return s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
419
- }
420
- // ─── Public API ──────────────────────────────────────────────────────────────
421
- /**
422
- * Show completion command
2
+ * Thin handler wrapper for the `completion` verb.
423
3
  *
424
- * Throws on missing/unknown shell; framework wrapper adds the
425
- * `Failed to completion ...` prefix. Do NOT reintroduce `process.exit`
426
- * `tests/unit/no-process-exit-in-handlers.test.ts` enforces this.
4
+ * All shell-completion implementation logic (rendering, install,
5
+ * detection, refresh) lives in `src/completion.ts` so it can be unit
6
+ * tested without violating the test→commands import boundary (#291).
7
+ * This file holds only the `defineCommand` wrapper that dispatches the
8
+ * verb at runtime.
427
9
  */
428
- export function showCompletion(shell) {
429
- if (!shell) {
430
- throw new Error("Shell type required. Usage: c8 completion <bash|zsh|fish>");
431
- }
432
- const normalizedShell = shell.toLowerCase();
433
- switch (normalizedShell) {
434
- case "bash":
435
- console.log(generateBashCompletion());
436
- break;
437
- case "zsh":
438
- console.log(generateZshCompletion());
439
- break;
440
- case "fish":
441
- console.log(generateFishCompletion());
442
- break;
443
- default:
444
- throw new Error(`Unknown shell: ${shell}. Supported shells: bash, zsh, fish. Usage: c8 completion <bash|zsh|fish>`);
445
- }
446
- }
447
- // ─── Completion install ──────────────────────────────────────────────────────
448
- /** Version header prefix used to tag generated completion files. */
449
- const VERSION_HEADER_PREFIX = "# c8ctl-completion-version: ";
450
- /** Completions subdirectory under the user data dir. */
451
- const COMPLETIONS_DIR = "completions";
452
- /** Shell file extensions. */
453
- const SHELL_EXTENSIONS = {
454
- bash: "bash",
455
- zsh: "zsh",
456
- fish: "fish",
457
- };
458
- /** Detect the user's shell from $SHELL. Returns lowercase shell name or undefined. */
459
- export function detectShell() {
460
- const shellPath = process.env.SHELL;
461
- if (!shellPath)
462
- return undefined;
463
- const base = shellPath.split("/").pop();
464
- if (!base)
465
- return undefined;
466
- const name = base.toLowerCase();
467
- if (name === "bash" || name === "zsh" || name === "fish")
468
- return name;
469
- return undefined;
470
- }
471
- /** Get the appropriate RC file path for a given shell. */
472
- export function getShellRcFile(shell) {
473
- const home = homedir();
474
- switch (shell) {
475
- case "bash":
476
- // macOS uses .bash_profile by default; Linux uses .bashrc
477
- return platform() === "darwin"
478
- ? join(home, ".bash_profile")
479
- : join(home, ".bashrc");
480
- case "zsh":
481
- return join(home, ".zshrc");
482
- case "fish":
483
- // fish auto-loads from completions dir — no rc edit needed
484
- return undefined;
485
- default:
486
- return undefined;
487
- }
488
- }
489
- /** Get the path where the completion file will be written. */
490
- export function getCompletionFilePath(shell) {
491
- const ext = SHELL_EXTENSIONS[shell] ?? shell;
492
- return join(getUserDataDir(), COMPLETIONS_DIR, `c8ctl.${ext}`);
493
- }
494
- /** Get the fish completions dir (fish auto-loads from here). Respects XDG_CONFIG_HOME. */
495
- function getFishCompletionsDir() {
496
- const configHome = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
497
- return join(configHome, "fish", "completions");
498
- }
499
- /** Generate the source line that goes into the shell RC file.
500
- * Uses single quotes to prevent shell expansion of the path. */
501
- function buildSourceLine(completionFilePath) {
502
- // Reject control characters (newlines, null, etc.) that could inject
503
- // additional commands into the shell RC file.
504
- for (const ch of completionFilePath) {
505
- const code = ch.charCodeAt(0);
506
- if (code <= 0x1f || code === 0x7f) {
507
- throw new Error("Completion file path contains control characters.");
508
- }
509
- }
510
- const escaped = completionFilePath.replaceAll("'", "'\\''");
511
- return `source '${escaped}'`;
512
- }
513
- /** Generate the comment+source block for the RC file. */
514
- function buildRcBlock(completionFilePath) {
515
- return `\n# c8ctl shell completion\n${buildSourceLine(completionFilePath)}\n`;
516
- }
517
- /** Check if the RC file already contains the source line.
518
- * Checks for both the current single-quoted source line and the legacy
519
- * double-quoted form, so upgrades are detected without false-positiving
520
- * on the raw path appearing in unrelated lines. */
521
- function rcAlreadyConfigured(rcFile, completionFilePath) {
522
- if (!existsSync(rcFile))
523
- return false;
524
- try {
525
- const content = readFileSync(rcFile, "utf-8");
526
- // Check for current single-quoted source line
527
- if (content.includes(buildSourceLine(completionFilePath)))
528
- return true;
529
- // Check for legacy double-quoted source line
530
- const escaped = completionFilePath.replaceAll('"', '\\"');
531
- if (content.includes(`source "${escaped}"`))
532
- return true;
533
- return false;
534
- }
535
- catch {
536
- return false;
537
- }
538
- }
539
- /** Generate the completion script content for a given shell. */
540
- function generateForShell(shell) {
541
- switch (shell) {
542
- case "bash":
543
- return generateBashCompletion();
544
- case "zsh":
545
- return generateZshCompletion();
546
- case "fish":
547
- return generateFishCompletion();
548
- default:
549
- throw new Error(`Unknown shell: ${shell}`);
550
- }
551
- }
552
- /** Extract the version from a completion file's header lines.
553
- * Scans the first few lines so the header can appear after
554
- * shell-required directives like zsh's #compdef. */
555
- export function extractCompletionVersion(filePath) {
556
- if (!existsSync(filePath))
557
- return undefined;
558
- try {
559
- const content = readFileSync(filePath, "utf-8");
560
- const lines = content.split("\n").slice(0, 5);
561
- for (const line of lines) {
562
- if (line.startsWith(VERSION_HEADER_PREFIX)) {
563
- return line.slice(VERSION_HEADER_PREFIX.length).trim();
564
- }
565
- }
566
- return undefined;
567
- }
568
- catch {
569
- return undefined;
570
- }
571
- }
572
- /**
573
- * Install shell completions: write script to data dir, wire into RC file.
574
- *
575
- * Supports --dry-run via the c8ctl runtime flag.
576
- */
577
- export function installCompletion(shellOverride) {
578
- const logger = getLogger();
579
- const shell = shellOverride?.toLowerCase() ?? detectShell();
580
- if (!shell) {
581
- throw new Error("Could not detect shell. Specify with: c8ctl completion install --shell <bash|zsh|fish>");
582
- }
583
- if (!["bash", "zsh", "fish"].includes(shell)) {
584
- throw new Error(`Unsupported shell: ${shell}. Supported shells: bash, zsh, fish`);
585
- }
586
- const completionFile = getCompletionFilePath(shell);
587
- const rcFile = getShellRcFile(shell);
588
- const rcConfigured = rcFile
589
- ? rcAlreadyConfigured(rcFile, completionFile)
590
- : true;
591
- // Dry-run support
592
- if (c8ctl.dryRun) {
593
- const result = {
594
- dryRun: true,
595
- detectedShell: shell,
596
- completionFile,
597
- };
598
- if (rcFile) {
599
- result.rcFile = rcFile;
600
- result.sourceLine = buildSourceLine(completionFile);
601
- result.rcAlreadyConfigured = rcConfigured;
602
- }
603
- if (shell === "fish") {
604
- result.fishCompletionsDir = getFishCompletionsDir();
605
- }
606
- logger.json(result);
607
- return;
608
- }
609
- // Write the completion file
610
- try {
611
- const completionDir = join(getUserDataDir(), COMPLETIONS_DIR);
612
- mkdirSync(completionDir, { recursive: true });
613
- const script = generateForShell(shell);
614
- writeFileSync(completionFile, script, "utf-8");
615
- logger.info(`Completion script written to ${completionFile}`);
616
- // Fish: also copy to fish auto-load directory
617
- if (shell === "fish") {
618
- const fishDir = getFishCompletionsDir();
619
- mkdirSync(fishDir, { recursive: true });
620
- writeFileSync(join(fishDir, "c8ctl.fish"), script, "utf-8");
621
- logger.info(`Fish completion installed to ${fishDir}/c8ctl.fish`);
622
- logger.info("Completions will be loaded automatically on next shell start.");
623
- return;
624
- }
625
- // Wire into RC file (bash/zsh)
626
- if (rcFile) {
627
- if (rcConfigured) {
628
- logger.info(`RC file already configured: ${rcFile}`);
629
- }
630
- else {
631
- const block = buildRcBlock(completionFile);
632
- writeFileSync(rcFile, block, { encoding: "utf-8", flag: "a" });
633
- logger.info(`Added source line to ${rcFile}`);
634
- }
635
- }
636
- logger.info("Restart your shell or run:");
637
- logger.info(` ${buildSourceLine(completionFile)}`);
638
- }
639
- catch (err) {
640
- const msg = err instanceof Error ? err.message : String(err);
641
- logger.info(`Target path: ${completionFile}`);
642
- if (rcFile) {
643
- logger.info(`Add this line manually to your shell config:\n ${buildSourceLine(completionFile)}`);
644
- }
645
- throw new Error(`Failed to install completions: ${msg}`);
646
- }
647
- }
648
- /**
649
- * Refresh the installed completion file if the CLI version has changed.
650
- *
651
- * Called on every CLI invocation — no-op if completions are not installed
652
- * or if the embedded version matches the running CLI version.
653
- * Synchronous write (~1ms for a few KB).
654
- */
655
- export function refreshCompletionsIfStale() {
656
- // Skip in dry-run mode — refresh is a side effect
657
- if (c8ctl.dryRun)
658
- return;
659
- // Use the same source of truth as generateForShell() for version headers
660
- const currentVersion = c8ctl.version;
661
- // Check each shell — user may have installed for multiple shells
662
- for (const shell of ["bash", "zsh", "fish"]) {
663
- const filePath = getCompletionFilePath(shell);
664
- const installed = extractCompletionVersion(filePath);
665
- if (installed === undefined) {
666
- // No version header — treat existing file as stale, regenerate
667
- if (!existsSync(filePath))
668
- continue; // not installed for this shell
669
- }
670
- else if (installed === currentVersion) {
671
- continue; // up to date
672
- }
673
- // Stale — regenerate
674
- try {
675
- const script = generateForShell(shell);
676
- writeFileSync(filePath, script, "utf-8");
677
- // Fish: also update the auto-load copy
678
- if (shell === "fish") {
679
- const fishTarget = join(getFishCompletionsDir(), "c8ctl.fish");
680
- if (existsSync(fishTarget)) {
681
- writeFileSync(fishTarget, script, "utf-8");
682
- }
683
- }
684
- }
685
- catch {
686
- // Best-effort — don't crash if the write fails
687
- }
688
- }
689
- }
690
- // ─── defineCommand wrappers ──────────────────────────────────────────────────
10
+ import { defineCommand } from "../command-framework.js";
11
+ import { installCompletion, showCompletion } from "../completion.js";
691
12
  /**
692
13
  * `completion` verb dispatcher.
693
14
  *
@@ -697,19 +18,40 @@ export function refreshCompletionsIfStale() {
697
18
  * invocation to `completion:` because `requiresResource` is false. This
698
19
  * single handler branches on the incoming resource.
699
20
  *
21
+ * Registered against `"install"` (not `""`) so the typed `flags`
22
+ * parameter includes `shell` — the only resource-scoped flag, declared
23
+ * in `resourceFlags.install` per the verb/resource flag-bucket
24
+ * disjointness invariant (#256). The dispatch key is still `completion:`
25
+ * because dispatch is derived from `requiresResource`, not from the
26
+ * registration resource. The framework uses `ctx.resource` for error
27
+ * messages so failures on non-install branches are not mislabelled
28
+ * "Failed to completion install".
29
+ *
700
30
  * All validation errors are raised via `throw` so the framework wrapper
701
31
  * routes them through `handleCommandError`. The architectural guard in
702
32
  * `tests/unit/no-process-exit-in-handlers.test.ts` forbids `process.exit`
703
33
  * here.
704
34
  */
705
- export const completionCommand = defineCommand("completion", "", async (ctx, flags) => {
35
+ export const completionCommand = defineCommand("completion", "install", async (ctx, flags) => {
706
36
  const resource = ctx.resource;
707
37
  if (resource === "install") {
708
38
  const shellFlag = flags.shell;
709
39
  installCompletion(typeof shellFlag === "string" ? shellFlag : undefined);
710
40
  return undefined;
711
41
  }
712
- // Empty resource → show usage error consistent with prior behaviour.
42
+ // Non-install resource → render the requested shell's completion
43
+ // script when `resource` is one of bash/zsh/fish. When `resource`
44
+ // is empty (`c8ctl completion` with no positional), `showCompletion`
45
+ // throws a usage error listing the supported shells; the framework
46
+ // wrapper routes that through `handleCommandError`.
47
+ //
48
+ // Note: `flags.shell` may still be populated here because dispatch is
49
+ // `completion:` regardless of resource and the install schema is in
50
+ // effect — `parseArgs` will accept `--shell` and `deserializeFlags`
51
+ // will populate it. `detectUnknownFlags` (src/command-validation.ts)
52
+ // emits a warning that `--shell` is not valid for non-install
53
+ // resources, but the value is not stripped. This branch deliberately
54
+ // ignores `flags.shell`; the warning is the user-facing signal.
713
55
  showCompletion(resource || undefined);
714
56
  return undefined;
715
57
  });