@nemus-cli/nemus 0.12.0 → 0.13.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.13.0] - 2026-09-02
11
+
12
+ ### Added
13
+
14
+ - **Shell completions now cover second-level subcommands and value arguments.**
15
+ `nemus config <TAB>` completes `get/set/unset/list/path/edit`, `nemus config
16
+ set <TAB>` completes the config **keys**, and `nemus reflect <TAB>` completes
17
+ `history/show` — across bash, zsh, and fish. Subcommands are auto-derived from
18
+ the command tree (so `suite`/`branch`/`cache`/`mcp` are covered too). (#73)
19
+ - **`nemus completion` infers the shell from `$SHELL`** when no argument is
20
+ given (`nemus completion` → emits your shell's script). An explicit
21
+ `bash|zsh|fish` still wins; an unknown/invalid value errors as before. (#75)
22
+
10
23
  ## [0.12.0] - 2026-09-02
11
24
 
12
25
  ### Added
@@ -1,13 +1,28 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.COMPLETION_BINS = void 0;
3
+ exports.SHELLS = exports.COMPLETION_BINS = void 0;
4
+ exports.detectShell = detectShell;
4
5
  exports.generateCompletion = generateCompletion;
5
6
  exports.specsFromProgram = specsFromProgram;
6
7
  exports.registerCompletionCommand = registerCompletionCommand;
7
8
  const workspace_meta_1 = require("../utils/workspace-meta");
8
9
  const logger_1 = require("../utils/logger");
10
+ const config_schema_1 = require("../utils/config-schema");
9
11
  /** Binaries that get completion registered (the CLI's bins). */
10
12
  exports.COMPLETION_BINS = ['nemus', 'nem'];
13
+ exports.SHELLS = ['bash', 'zsh', 'fish'];
14
+ /** Commands whose subcommands are positional args (not nested commander commands). */
15
+ const POSITIONAL_SUBCOMMANDS = { reflect: ['history', 'show'] };
16
+ /**
17
+ * Infer a shell from a `$SHELL`-style path (e.g. `/bin/zsh` -> `zsh`). Returns
18
+ * null for an unknown/empty value. Pure + exported for testing.
19
+ */
20
+ function detectShell(shellPath) {
21
+ if (!shellPath)
22
+ return null;
23
+ const base = shellPath.trim().split('/').pop()?.toLowerCase() ?? '';
24
+ return exports.SHELLS.find((s) => s === base) ?? null;
25
+ }
11
26
  /** Every token (name + aliases) that should complete as a subcommand. */
12
27
  function allTokens(cmds) {
13
28
  return cmds.flatMap((c) => [c.name, ...c.aliases]);
@@ -16,23 +31,46 @@ function allTokens(cmds) {
16
31
  function workspaceTokens(cmds) {
17
32
  return cmds.filter((c) => c.takesWorkspace).flatMap((c) => [c.name, ...c.aliases]);
18
33
  }
34
+ /** All tokens (name + aliases) that route to a given command spec. */
35
+ function cmdTokens(c) {
36
+ return [c.name, ...c.aliases];
37
+ }
19
38
  /** Escape a description for a fish single-quoted string. */
20
39
  function fishDesc(s) {
21
40
  return s.replace(/\n/g, ' ').replace(/'/g, "'\\''");
22
41
  }
23
42
  /**
24
43
  * Generate a shell completion script. Pure (no I/O) so it's unit-tested. The
25
- * generated script completes subcommands at position 1, and for a subcommand
26
- * that takes a workspace it completes workspace names by calling back into the
27
- * CLI: `<bin> completion --workspaces`. Dynamic values stay fresh without
28
- * regenerating the script.
44
+ * generated script completes, in order: top-level commands (position 1); then
45
+ * either workspace names (for workspace-scoped commands) or a command's own
46
+ * subcommands (position 2); then value completions like `config` keys
47
+ * (position 3). Workspace names stay dynamic via a callback into
48
+ * `<bin> completion --workspaces`.
29
49
  */
30
50
  function generateCompletion(shell, cmds, bins = exports.COMPLETION_BINS) {
31
51
  const commands = allTokens(cmds).join(' ');
32
52
  const wsCommands = workspaceTokens(cmds).join(' ');
53
+ const withSubs = cmds.filter((c) => c.subcommands && c.subcommands.length);
54
+ const withArgVals = cmds.filter((c) => c.argValues && c.argValues.values.length);
33
55
  if (shell === 'bash') {
56
+ const subArms = withSubs
57
+ .map((c) => ` ${cmdTokens(c).join('|')}) echo "${c.subcommands.join(' ')}" ;;`)
58
+ .join('\n');
59
+ const argArms = withArgVals
60
+ .map((c) => ` ${cmdTokens(c).join('|')}) case "$2" in ${c.argValues.after.join('|')}) echo "${c.argValues.values.join(' ')}" ;; esac ;;`)
61
+ .join('\n');
34
62
  return `# nemus bash completion. Install: nemus completion bash > /etc/bash_completion.d/nemus
35
63
  # (or: nemus completion bash >> ~/.bashrc)
64
+ _nemus_subcmds() {
65
+ case "$1" in
66
+ ${subArms}
67
+ esac
68
+ }
69
+ _nemus_argvals() {
70
+ case "$1" in
71
+ ${argArms}
72
+ esac
73
+ }
36
74
  _nemus_complete() {
37
75
  local cur bin sub
38
76
  # bash does not clear COMPREPLY between completions; reset so a stale result
@@ -54,6 +92,20 @@ _nemus_complete() {
54
92
  COMPREPLY=( \$(compgen -W "\$names" -- "\$cur") )
55
93
  return 0
56
94
  fi
95
+ local subs
96
+ subs="\$(_nemus_subcmds "\$sub")"
97
+ if [ -n "\$subs" ]; then
98
+ COMPREPLY=( \$(compgen -W "\$subs" -- "\$cur") )
99
+ fi
100
+ return 0
101
+ fi
102
+ if [ "\$COMP_CWORD" -eq 3 ]; then
103
+ local vals
104
+ vals="\$(_nemus_argvals "\${COMP_WORDS[1]}" "\${COMP_WORDS[2]}")"
105
+ if [ -n "\$vals" ]; then
106
+ COMPREPLY=( \$(compgen -W "\$vals" -- "\$cur") )
107
+ fi
108
+ return 0
57
109
  fi
58
110
  return 0
59
111
  }
@@ -61,6 +113,12 @@ ${bins.map((b) => `complete -F _nemus_complete ${b}`).join('\n')}
61
113
  `;
62
114
  }
63
115
  if (shell === 'zsh') {
116
+ const subArms = withSubs
117
+ .map((c) => ` ${cmdTokens(c).join('|')}) compadd -- ${c.subcommands.join(' ')} ;;`)
118
+ .join('\n');
119
+ const argArms = withArgVals
120
+ .map((c) => ` ${cmdTokens(c).join('|')}) case \${words[3]} in ${c.argValues.after.join('|')}) compadd -- ${c.argValues.values.join(' ')} ;; esac ;;`)
121
+ .join('\n');
64
122
  // Autoloaded form: save as a file named `_nemus` on your $fpath.
65
123
  return `#compdef ${bins.join(' ')}
66
124
  # nemus zsh completion. Install: nemus completion zsh > "\${fpath[1]}/_nemus"
@@ -77,7 +135,17 @@ if (( CURRENT == 3 )); then
77
135
  local -a _nemus_names
78
136
  _nemus_names=(\${(f)"$(\${words[1]} completion --workspaces 2>/dev/null)"})
79
137
  compadd -- $_nemus_names
138
+ return
80
139
  fi
140
+ case $sub in
141
+ ${subArms}
142
+ esac
143
+ return
144
+ fi
145
+ if (( CURRENT == 4 )); then
146
+ case \${words[2]} in
147
+ ${argArms}
148
+ esac
81
149
  fi
82
150
  `;
83
151
  }
@@ -86,7 +154,7 @@ fi
86
154
  for (const bin of bins) {
87
155
  lines.push(`complete -c ${bin} -f`);
88
156
  for (const c of cmds) {
89
- for (const tok of [c.name, ...c.aliases]) {
157
+ for (const tok of cmdTokens(c)) {
90
158
  lines.push(`complete -c ${bin} -n __fish_use_subcommand -a '${tok}' -d '${fishDesc(c.description)}'`);
91
159
  }
92
160
  }
@@ -94,6 +162,14 @@ fi
94
162
  if (wsToks) {
95
163
  lines.push(`complete -c ${bin} -n '__fish_seen_subcommand_from ${wsToks}' -a '(${bin} completion --workspaces)'`);
96
164
  }
165
+ // Second-level subcommands.
166
+ for (const c of withSubs) {
167
+ lines.push(`complete -c ${bin} -n '__fish_seen_subcommand_from ${cmdTokens(c).join(' ')}' -a '${c.subcommands.join(' ')}'`);
168
+ }
169
+ // Third-level value completions (e.g. config keys after get/set/unset).
170
+ for (const c of withArgVals) {
171
+ lines.push(`complete -c ${bin} -n '__fish_seen_subcommand_from ${cmdTokens(c).join(' ')}; and __fish_seen_subcommand_from ${c.argValues.after.join(' ')}' -a '${c.argValues.values.join(' ')}'`);
172
+ }
97
173
  }
98
174
  return lines.join('\n') + '\n';
99
175
  }
@@ -103,11 +179,19 @@ function specsFromProgram(program) {
103
179
  .map((c) => {
104
180
  const args = c.registeredArguments ?? [];
105
181
  const firstArg = args[0]?.name?.();
182
+ const name = c.name();
183
+ // Nested commander subcommands (config/suite/branch/cache/mcp), else a
184
+ // manual override for commands whose subcommands are positional (reflect).
185
+ const nested = c.commands.flatMap((s) => [s.name(), ...s.aliases()]).filter(Boolean);
186
+ const subcommands = nested.length ? nested : POSITIONAL_SUBCOMMANDS[name];
187
+ const argValues = name === 'config' ? { after: ['get', 'set', 'unset'], values: [...config_schema_1.CONFIG_KEYS] } : undefined;
106
188
  return {
107
- name: c.name(),
189
+ name,
108
190
  aliases: c.aliases(),
109
191
  takesWorkspace: typeof firstArg === 'string' && firstArg.toLowerCase().includes('workspace'),
110
192
  description: c.description() ?? '',
193
+ ...(subcommands ? { subcommands } : {}),
194
+ ...(argValues ? { argValues } : {}),
111
195
  };
112
196
  })
113
197
  // The completion command itself and any hidden helper needn't clutter, but
@@ -117,7 +201,7 @@ function specsFromProgram(program) {
117
201
  function registerCompletionCommand(program) {
118
202
  program
119
203
  .command('completion [shell]')
120
- .description('Output a shell completion script (bash|zsh|fish)')
204
+ .description('Output a shell completion script (bash|zsh|fish; inferred from $SHELL if omitted)')
121
205
  .option('--workspaces', 'Print workspace names (used internally by completion scripts)')
122
206
  .action(async (shell, opts) => {
123
207
  // Data helper the generated scripts call back into.
@@ -132,12 +216,22 @@ function registerCompletionCommand(program) {
132
216
  }
133
217
  return;
134
218
  }
135
- const shells = ['bash', 'zsh', 'fish'];
136
- if (!shell || !shells.includes(shell)) {
137
- (0, logger_1.logError)(`completion: specify a shell — one of ${shells.join(', ')}`);
219
+ // An explicit argument wins; only fall back to $SHELL when none is given
220
+ // (an invalid explicit arg is an error, not a reason to guess).
221
+ let target = null;
222
+ if (shell) {
223
+ target = exports.SHELLS.includes(shell) ? shell : null;
224
+ }
225
+ else {
226
+ target = detectShell(process.env.SHELL);
227
+ if (target)
228
+ (0, logger_1.logInfo)(`completion: no shell given — using ${target} (from $SHELL)`);
229
+ }
230
+ if (!target) {
231
+ (0, logger_1.logError)(`completion: specify a shell — one of ${exports.SHELLS.join(', ')}`);
138
232
  (0, logger_1.logError)('e.g. nemus completion bash');
139
233
  process.exit(1);
140
234
  }
141
- process.stdout.write(generateCompletion(shell, specsFromProgram(program)));
235
+ process.stdout.write(generateCompletion(target, specsFromProgram(program)));
142
236
  });
143
237
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nemus-cli/nemus",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "workspaces": [
5
5
  "packages/*"
6
6
  ],
@@ -18,5 +18,10 @@ nemus completion zsh > "${fpath[1]}/_nemus"
18
18
  nemus completion fish > ~/.config/fish/completions/nemus.fish
19
19
  ```
20
20
 
21
- Then restart the shell (or `source` the file). Requires a shell argument
22
- one of `bash|zsh|fish`.
21
+ Then restart the shell (or `source` the file). The shell argument is inferred
22
+ from `$SHELL` when omitted (`nemus completion`), so you usually don't need to
23
+ pass it; an explicit `bash|zsh|fish` always wins.
24
+
25
+ Completions cover second-level subcommands too — e.g. `nemus config <TAB>`
26
+ (get/set/…), `nemus config set <TAB>` (config keys), and `nemus reflect <TAB>`
27
+ (history/show).
@@ -1,6 +1,6 @@
1
- import { describe, it, expect } from 'vitest';
1
+ import { describe, it, expect, vi } from 'vitest';
2
2
  import { Command } from 'commander';
3
- import { generateCompletion, specsFromProgram, CommandSpec } from './completion';
3
+ import { generateCompletion, specsFromProgram, detectShell, registerCompletionCommand, CommandSpec } from './completion';
4
4
 
5
5
  const specs: CommandSpec[] = [
6
6
  { name: 'list', aliases: ['l'], takesWorkspace: false, description: 'List workspaces' },
@@ -8,6 +8,49 @@ const specs: CommandSpec[] = [
8
8
  { name: 'doctor', aliases: ['doc'], takesWorkspace: true, description: 'Health checks' },
9
9
  ];
10
10
 
11
+ // A spec set exercising second-level subcommands and third-level value completion.
12
+ const subSpecs: CommandSpec[] = [
13
+ {
14
+ name: 'config', aliases: [], takesWorkspace: false, description: 'Configure',
15
+ subcommands: ['get', 'set', 'list', 'ls'],
16
+ argValues: { after: ['get', 'set'], values: ['githubOrg', 'cloneProtocol'] },
17
+ },
18
+ { name: 'reflect', aliases: ['retro'], takesWorkspace: false, description: 'Retrospective', subcommands: ['history', 'show'] },
19
+ ];
20
+
21
+ describe('detectShell', () => {
22
+ it('infers the shell from a $SHELL-style path', () => {
23
+ expect(detectShell('/bin/zsh')).toBe('zsh');
24
+ expect(detectShell('/usr/bin/fish')).toBe('fish');
25
+ expect(detectShell('/bin/bash')).toBe('bash');
26
+ });
27
+ it('returns null for unknown/empty values', () => {
28
+ expect(detectShell('/bin/tcsh')).toBeNull();
29
+ expect(detectShell('')).toBeNull();
30
+ expect(detectShell(undefined)).toBeNull();
31
+ });
32
+ });
33
+
34
+ describe('generateCompletion — second/third level', () => {
35
+ it('bash completes subcommands and config-key values, and stays valid', () => {
36
+ const s = generateCompletion('bash', subSpecs);
37
+ expect(s).toContain('config) echo "get set list ls" ;;');
38
+ expect(s).toContain('reflect|retro) echo "history show" ;;');
39
+ expect(s).toContain('case "$2" in get|set) echo "githubOrg cloneProtocol"');
40
+ });
41
+ it('zsh completes subcommands and values at CURRENT 3/4', () => {
42
+ const s = generateCompletion('zsh', subSpecs);
43
+ expect(s).toContain('config) compadd -- get set list ls ;;');
44
+ expect(s).toContain('reflect|retro) compadd -- history show ;;');
45
+ expect(s).toContain('case ${words[3]} in get|set) compadd -- githubOrg cloneProtocol');
46
+ });
47
+ it('fish emits seen-subcommand conditions for subcommands and values', () => {
48
+ const s = generateCompletion('fish', subSpecs);
49
+ expect(s).toContain("-n '__fish_seen_subcommand_from config' -a 'get set list ls'");
50
+ expect(s).toContain("__fish_seen_subcommand_from config; and __fish_seen_subcommand_from get set");
51
+ });
52
+ });
53
+
11
54
  describe('generateCompletion — bash', () => {
12
55
  const s = generateCompletion('bash', specs);
13
56
  it('resets COMPREPLY, lists tokens, and registers both bins', () => {
@@ -43,6 +86,42 @@ describe('generateCompletion — fish', () => {
43
86
  });
44
87
  });
45
88
 
89
+ describe('registerCompletionCommand — stdout hygiene on inferred shell', () => {
90
+ it('writes ONLY the script to stdout; the $SHELL inference note goes to stderr', async () => {
91
+ // The documented install path is `nemus completion zsh > _nemus`, so if the
92
+ // inference note leaked to stdout it would corrupt the generated script.
93
+ const program = new Command();
94
+ program.exitOverride();
95
+ program.command('list').description('list');
96
+ registerCompletionCommand(program);
97
+
98
+ const stdoutChunks: string[] = [];
99
+ const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(((c: unknown) => {
100
+ stdoutChunks.push(String(c));
101
+ return true;
102
+ }) as typeof process.stdout.write);
103
+ const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
104
+ const prevShell = process.env.SHELL;
105
+ process.env.SHELL = '/bin/zsh';
106
+
107
+ let errCalls: string[] = [];
108
+ try {
109
+ await program.parseAsync(['node', 'nemus', 'completion']);
110
+ errCalls = errSpy.mock.calls.map((c) => c.map(String).join(' '));
111
+ } finally {
112
+ if (prevShell === undefined) delete process.env.SHELL;
113
+ else process.env.SHELL = prevShell;
114
+ stdoutSpy.mockRestore();
115
+ errSpy.mockRestore();
116
+ }
117
+
118
+ const stdout = stdoutChunks.join('');
119
+ expect(stdout.startsWith('#compdef')).toBe(true); // the zsh script, nothing prepended
120
+ expect(stdout).not.toContain('no shell given'); // the note never reached stdout
121
+ expect(errCalls.some((l) => l.includes('no shell given'))).toBe(true); // it went to stderr
122
+ });
123
+ });
124
+
46
125
  describe('specsFromProgram', () => {
47
126
  it('detects workspace args + aliases from a commander program', () => {
48
127
  const program = new Command();
@@ -57,4 +136,26 @@ describe('specsFromProgram', () => {
57
136
  expect(byName.list.takesWorkspace).toBe(false);
58
137
  expect(byName.create.takesWorkspace).toBe(false);
59
138
  });
139
+
140
+ it('derives nested subcommands, the reflect override, and config keys', () => {
141
+ const program = new Command();
142
+ const cfg = program.command('config').description('config');
143
+ cfg.command('get').description('get');
144
+ cfg.command('set').description('set');
145
+ program.command('reflect').description('reflect'); // positional subcommands
146
+ program.command('list').description('list');
147
+
148
+ const byName = Object.fromEntries(specsFromProgram(program).map((s) => [s.name, s]));
149
+ // nested commander subcommands are auto-derived
150
+ expect(byName.config.subcommands).toContain('get');
151
+ expect(byName.config.subcommands).toContain('set');
152
+ // reflect's positional subcommands come from the override
153
+ expect(byName.reflect.subcommands).toEqual(['history', 'show']);
154
+ // config gets key-value completion from the authoritative CONFIG_KEYS
155
+ expect(byName.config.argValues?.after).toEqual(['get', 'set', 'unset']);
156
+ expect(byName.config.argValues?.values).toContain('githubOrg');
157
+ // a plain command has neither
158
+ expect(byName.list.subcommands).toBeUndefined();
159
+ expect(byName.list.argValues).toBeUndefined();
160
+ });
60
161
  });
@@ -1,11 +1,16 @@
1
1
  import { Command } from 'commander';
2
2
  import { listWorkspaces } from '../utils/workspace-meta';
3
- import { logError } from '../utils/logger';
3
+ import { logError, logInfo } from '../utils/logger';
4
+ import { CONFIG_KEYS } from '../utils/config-schema';
4
5
 
5
6
  /** Binaries that get completion registered (the CLI's bins). */
6
7
  export const COMPLETION_BINS = ['nemus', 'nem'];
7
8
 
8
9
  export type Shell = 'bash' | 'zsh' | 'fish';
10
+ export const SHELLS: Shell[] = ['bash', 'zsh', 'fish'];
11
+
12
+ /** Commands whose subcommands are positional args (not nested commander commands). */
13
+ const POSITIONAL_SUBCOMMANDS: Record<string, string[]> = { reflect: ['history', 'show'] };
9
14
 
10
15
  /** One top-level command, distilled to what a completion script needs. */
11
16
  export interface CommandSpec {
@@ -14,6 +19,20 @@ export interface CommandSpec {
14
19
  /** True if its first positional argument is a workspace name. */
15
20
  takesWorkspace: boolean;
16
21
  description: string;
22
+ /** Second-level tokens (e.g. `config get`, `reflect history`), if any. */
23
+ subcommands?: string[];
24
+ /** Third-level value completion: for these subcommands, complete `values`. */
25
+ argValues?: { after: string[]; values: string[] };
26
+ }
27
+
28
+ /**
29
+ * Infer a shell from a `$SHELL`-style path (e.g. `/bin/zsh` -> `zsh`). Returns
30
+ * null for an unknown/empty value. Pure + exported for testing.
31
+ */
32
+ export function detectShell(shellPath: string | undefined): Shell | null {
33
+ if (!shellPath) return null;
34
+ const base = shellPath.trim().split('/').pop()?.toLowerCase() ?? '';
35
+ return SHELLS.find((s) => s === base) ?? null;
17
36
  }
18
37
 
19
38
  /** Every token (name + aliases) that should complete as a subcommand. */
@@ -26,6 +45,11 @@ function workspaceTokens(cmds: CommandSpec[]): string[] {
26
45
  return cmds.filter((c) => c.takesWorkspace).flatMap((c) => [c.name, ...c.aliases]);
27
46
  }
28
47
 
48
+ /** All tokens (name + aliases) that route to a given command spec. */
49
+ function cmdTokens(c: CommandSpec): string[] {
50
+ return [c.name, ...c.aliases];
51
+ }
52
+
29
53
  /** Escape a description for a fish single-quoted string. */
30
54
  function fishDesc(s: string): string {
31
55
  return s.replace(/\n/g, ' ').replace(/'/g, "'\\''");
@@ -33,18 +57,40 @@ function fishDesc(s: string): string {
33
57
 
34
58
  /**
35
59
  * Generate a shell completion script. Pure (no I/O) so it's unit-tested. The
36
- * generated script completes subcommands at position 1, and for a subcommand
37
- * that takes a workspace it completes workspace names by calling back into the
38
- * CLI: `<bin> completion --workspaces`. Dynamic values stay fresh without
39
- * regenerating the script.
60
+ * generated script completes, in order: top-level commands (position 1); then
61
+ * either workspace names (for workspace-scoped commands) or a command's own
62
+ * subcommands (position 2); then value completions like `config` keys
63
+ * (position 3). Workspace names stay dynamic via a callback into
64
+ * `<bin> completion --workspaces`.
40
65
  */
41
66
  export function generateCompletion(shell: Shell, cmds: CommandSpec[], bins: string[] = COMPLETION_BINS): string {
42
67
  const commands = allTokens(cmds).join(' ');
43
68
  const wsCommands = workspaceTokens(cmds).join(' ');
69
+ const withSubs = cmds.filter((c) => c.subcommands && c.subcommands.length);
70
+ const withArgVals = cmds.filter((c) => c.argValues && c.argValues.values.length);
44
71
 
45
72
  if (shell === 'bash') {
73
+ const subArms = withSubs
74
+ .map((c) => ` ${cmdTokens(c).join('|')}) echo "${c.subcommands!.join(' ')}" ;;`)
75
+ .join('\n');
76
+ const argArms = withArgVals
77
+ .map(
78
+ (c) =>
79
+ ` ${cmdTokens(c).join('|')}) case "$2" in ${c.argValues!.after.join('|')}) echo "${c.argValues!.values.join(' ')}" ;; esac ;;`,
80
+ )
81
+ .join('\n');
46
82
  return `# nemus bash completion. Install: nemus completion bash > /etc/bash_completion.d/nemus
47
83
  # (or: nemus completion bash >> ~/.bashrc)
84
+ _nemus_subcmds() {
85
+ case "$1" in
86
+ ${subArms}
87
+ esac
88
+ }
89
+ _nemus_argvals() {
90
+ case "$1" in
91
+ ${argArms}
92
+ esac
93
+ }
48
94
  _nemus_complete() {
49
95
  local cur bin sub
50
96
  # bash does not clear COMPREPLY between completions; reset so a stale result
@@ -66,6 +112,20 @@ _nemus_complete() {
66
112
  COMPREPLY=( \$(compgen -W "\$names" -- "\$cur") )
67
113
  return 0
68
114
  fi
115
+ local subs
116
+ subs="\$(_nemus_subcmds "\$sub")"
117
+ if [ -n "\$subs" ]; then
118
+ COMPREPLY=( \$(compgen -W "\$subs" -- "\$cur") )
119
+ fi
120
+ return 0
121
+ fi
122
+ if [ "\$COMP_CWORD" -eq 3 ]; then
123
+ local vals
124
+ vals="\$(_nemus_argvals "\${COMP_WORDS[1]}" "\${COMP_WORDS[2]}")"
125
+ if [ -n "\$vals" ]; then
126
+ COMPREPLY=( \$(compgen -W "\$vals" -- "\$cur") )
127
+ fi
128
+ return 0
69
129
  fi
70
130
  return 0
71
131
  }
@@ -74,6 +134,15 @@ ${bins.map((b) => `complete -F _nemus_complete ${b}`).join('\n')}
74
134
  }
75
135
 
76
136
  if (shell === 'zsh') {
137
+ const subArms = withSubs
138
+ .map((c) => ` ${cmdTokens(c).join('|')}) compadd -- ${c.subcommands!.join(' ')} ;;`)
139
+ .join('\n');
140
+ const argArms = withArgVals
141
+ .map(
142
+ (c) =>
143
+ ` ${cmdTokens(c).join('|')}) case \${words[3]} in ${c.argValues!.after.join('|')}) compadd -- ${c.argValues!.values.join(' ')} ;; esac ;;`,
144
+ )
145
+ .join('\n');
77
146
  // Autoloaded form: save as a file named `_nemus` on your $fpath.
78
147
  return `#compdef ${bins.join(' ')}
79
148
  # nemus zsh completion. Install: nemus completion zsh > "\${fpath[1]}/_nemus"
@@ -90,7 +159,17 @@ if (( CURRENT == 3 )); then
90
159
  local -a _nemus_names
91
160
  _nemus_names=(\${(f)"$(\${words[1]} completion --workspaces 2>/dev/null)"})
92
161
  compadd -- $_nemus_names
162
+ return
93
163
  fi
164
+ case $sub in
165
+ ${subArms}
166
+ esac
167
+ return
168
+ fi
169
+ if (( CURRENT == 4 )); then
170
+ case \${words[2]} in
171
+ ${argArms}
172
+ esac
94
173
  fi
95
174
  `;
96
175
  }
@@ -100,7 +179,7 @@ fi
100
179
  for (const bin of bins) {
101
180
  lines.push(`complete -c ${bin} -f`);
102
181
  for (const c of cmds) {
103
- for (const tok of [c.name, ...c.aliases]) {
182
+ for (const tok of cmdTokens(c)) {
104
183
  lines.push(`complete -c ${bin} -n __fish_use_subcommand -a '${tok}' -d '${fishDesc(c.description)}'`);
105
184
  }
106
185
  }
@@ -110,6 +189,18 @@ fi
110
189
  `complete -c ${bin} -n '__fish_seen_subcommand_from ${wsToks}' -a '(${bin} completion --workspaces)'`,
111
190
  );
112
191
  }
192
+ // Second-level subcommands.
193
+ for (const c of withSubs) {
194
+ lines.push(
195
+ `complete -c ${bin} -n '__fish_seen_subcommand_from ${cmdTokens(c).join(' ')}' -a '${c.subcommands!.join(' ')}'`,
196
+ );
197
+ }
198
+ // Third-level value completions (e.g. config keys after get/set/unset).
199
+ for (const c of withArgVals) {
200
+ lines.push(
201
+ `complete -c ${bin} -n '__fish_seen_subcommand_from ${cmdTokens(c).join(' ')}; and __fish_seen_subcommand_from ${c.argValues!.after.join(' ')}' -a '${c.argValues!.values.join(' ')}'`,
202
+ );
203
+ }
113
204
  }
114
205
  return lines.join('\n') + '\n';
115
206
  }
@@ -120,11 +211,20 @@ export function specsFromProgram(program: Command): CommandSpec[] {
120
211
  .map((c) => {
121
212
  const args = (c as any).registeredArguments ?? [];
122
213
  const firstArg: string | undefined = args[0]?.name?.();
214
+ const name = c.name();
215
+ // Nested commander subcommands (config/suite/branch/cache/mcp), else a
216
+ // manual override for commands whose subcommands are positional (reflect).
217
+ const nested = c.commands.flatMap((s) => [s.name(), ...s.aliases()]).filter(Boolean);
218
+ const subcommands = nested.length ? nested : POSITIONAL_SUBCOMMANDS[name];
219
+ const argValues =
220
+ name === 'config' ? { after: ['get', 'set', 'unset'], values: [...CONFIG_KEYS] } : undefined;
123
221
  return {
124
- name: c.name(),
222
+ name,
125
223
  aliases: c.aliases(),
126
224
  takesWorkspace: typeof firstArg === 'string' && firstArg.toLowerCase().includes('workspace'),
127
225
  description: c.description() ?? '',
226
+ ...(subcommands ? { subcommands } : {}),
227
+ ...(argValues ? { argValues } : {}),
128
228
  };
129
229
  })
130
230
  // The completion command itself and any hidden helper needn't clutter, but
@@ -135,7 +235,7 @@ export function specsFromProgram(program: Command): CommandSpec[] {
135
235
  export function registerCompletionCommand(program: Command) {
136
236
  program
137
237
  .command('completion [shell]')
138
- .description('Output a shell completion script (bash|zsh|fish)')
238
+ .description('Output a shell completion script (bash|zsh|fish; inferred from $SHELL if omitted)')
139
239
  .option('--workspaces', 'Print workspace names (used internally by completion scripts)')
140
240
  .action(async (shell: string | undefined, opts: { workspaces?: boolean }) => {
141
241
  // Data helper the generated scripts call back into.
@@ -149,12 +249,21 @@ export function registerCompletionCommand(program: Command) {
149
249
  return;
150
250
  }
151
251
 
152
- const shells: Shell[] = ['bash', 'zsh', 'fish'];
153
- if (!shell || !shells.includes(shell as Shell)) {
154
- logError(`completion: specify a shell one of ${shells.join(', ')}`);
252
+ // An explicit argument wins; only fall back to $SHELL when none is given
253
+ // (an invalid explicit arg is an error, not a reason to guess).
254
+ let target: Shell | null = null;
255
+ if (shell) {
256
+ target = SHELLS.includes(shell as Shell) ? (shell as Shell) : null;
257
+ } else {
258
+ target = detectShell(process.env.SHELL);
259
+ if (target) logInfo(`completion: no shell given — using ${target} (from $SHELL)`);
260
+ }
261
+
262
+ if (!target) {
263
+ logError(`completion: specify a shell — one of ${SHELLS.join(', ')}`);
155
264
  logError('e.g. nemus completion bash');
156
265
  process.exit(1);
157
266
  }
158
- process.stdout.write(generateCompletion(shell as Shell, specsFromProgram(program)));
267
+ process.stdout.write(generateCompletion(target, specsFromProgram(program)));
159
268
  });
160
269
  }