@camunda8/cli 2.7.0 → 2.8.0-alpha.10

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