@nemus-cli/nemus 0.12.0 → 0.14.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,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.14.0] - 2026-09-02
11
+
12
+ ### Changed
13
+
14
+ - **Shell integration now keeps its generated functions out of your shell RC
15
+ files.** Nemus writes them to `~/.nemus/shell-integration.sh` and adds only a
16
+ single guarded `source` line to `.zshrc`/`.bashrc`, so upgrades no longer
17
+ churn hundreds of lines in your personal shell config. Existing inline
18
+ installations migrate automatically without disturbing surrounding user
19
+ content, and reinstalls stay idempotent. Uninstall removes the generated file
20
+ too. Thanks to @uzikilon for the idea and original implementation (#50).
21
+
22
+ ## [0.13.0] - 2026-09-02
23
+
24
+ ### Added
25
+
26
+ - **Shell completions now cover second-level subcommands and value arguments.**
27
+ `nemus config <TAB>` completes `get/set/unset/list/path/edit`, `nemus config
28
+ set <TAB>` completes the config **keys**, and `nemus reflect <TAB>` completes
29
+ `history/show` — across bash, zsh, and fish. Subcommands are auto-derived from
30
+ the command tree (so `suite`/`branch`/`cache`/`mcp` are covered too). (#73)
31
+ - **`nemus completion` infers the shell from `$SHELL`** when no argument is
32
+ given (`nemus completion` → emits your shell's script). An explicit
33
+ `bash|zsh|fish` still wins; an unknown/invalid value errors as before. (#75)
34
+
10
35
  ## [0.12.0] - 2026-09-02
11
36
 
12
37
  ### Added
package/README.md CHANGED
@@ -138,7 +138,9 @@ npm install -g @nemus-cli/nemus
138
138
  ```
139
139
 
140
140
  The postinstall step sets up optional shell integration (auto-cd into new
141
- workspaces + a quick-navigate helper).
141
+ workspaces + a quick-navigate helper). It keeps the generated functions in
142
+ `~/.nemus/shell-integration.sh` and adds only a guarded source line to your
143
+ shell RC file, so upgrades don't churn your `.zshrc`/`.bashrc`.
142
144
 
143
145
  ### From source
144
146
 
@@ -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
  }
@@ -491,17 +491,30 @@ else
491
491
  exit 1
492
492
  fi
493
493
 
494
- # Check if already installed (version marker is in the grep below)
495
- if grep -q "Shell Integration (v50)" "$RC_FILE" 2>/dev/null; then
494
+ # Keep the generated functions out of the user's shell config. The same file
495
+ # works in both bash and zsh; the RC file only needs this guarded source line.
496
+ SHELL_INTEGRATION_DIR="$HOME/.nemus"
497
+ SHELL_INTEGRATION_FILE="$SHELL_INTEGRATION_DIR/shell-integration.sh"
498
+ SOURCE_LINE='[ -f "$HOME/.nemus/shell-integration.sh" ] && source "$HOME/.nemus/shell-integration.sh"'
499
+
500
+ mkdir -p "$SHELL_INTEGRATION_DIR"
501
+ SHELL_TMPFILE=$(mktemp "$SHELL_INTEGRATION_DIR/.shell-integration.XXXXXX")
502
+ printf '%s\n' "$SHELL_FUNCTION" > "$SHELL_TMPFILE"
503
+ mv "$SHELL_TMPFILE" "$SHELL_INTEGRATION_FILE"
504
+
505
+ append_source_line() {
506
+ printf '\n# Nemus - Shell Integration\n%s\n' "$SOURCE_LINE" >> "$1"
507
+ }
508
+
509
+ # Check if already installed
510
+ if grep -Fq "$SOURCE_LINE" "$RC_FILE" 2>/dev/null; then
496
511
  echo "✅ Shell integration already up to date in $RC_FILE"
497
512
  elif grep -q "# Nemus - Shell Integration" "$RC_FILE" 2>/dev/null; then
498
- # Old version installed — remove it and install new version
513
+ # Old inline version installed — remove it and leave only the source line
499
514
  echo "🔄 Upgrading shell integration in $RC_FILE..."
500
515
  TMPFILE=$(mktemp)
501
516
  awk -f "$SCRIPT_DIR/remove-shell-block.awk" "$RC_FILE" > "$TMPFILE"
502
- # Append new shell integration to temp file so mv is atomic
503
- echo "" >> "$TMPFILE"
504
- echo "$SHELL_FUNCTION" >> "$TMPFILE"
517
+ append_source_line "$TMPFILE"
505
518
  cp "$RC_FILE" "${RC_FILE}.backup.$(date +%Y%m%d_%H%M%S)"
506
519
  chmod --reference="$RC_FILE" "$TMPFILE" 2>/dev/null || chmod "$(stat -f '%Lp' "$RC_FILE")" "$TMPFILE" 2>/dev/null || true
507
520
  mv "$TMPFILE" "$RC_FILE"
@@ -514,9 +527,8 @@ else
514
527
  echo "📦 Backup created: ${RC_FILE}.backup.*"
515
528
  fi
516
529
 
517
- # Append shell function (creates the file if it doesn't exist)
518
- echo "" >> "$RC_FILE"
519
- echo "$SHELL_FUNCTION" >> "$RC_FILE"
530
+ # Append the source line (creates the file if it doesn't exist)
531
+ append_source_line "$RC_FILE"
520
532
 
521
533
  echo "✅ Shell integration installed in $RC_FILE"
522
534
  echo ""
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nemus-cli/nemus",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "workspaces": [
5
5
  "packages/*"
6
6
  ],
@@ -33,6 +33,14 @@ skip {
33
33
  saw_func = 1
34
34
  next
35
35
  }
36
+ if (/\.nemus\/shell-integration\.sh/ && /source/) {
37
+ # Current installs keep the functions in a generated file and leave only
38
+ # this source line in the RC file.
39
+ skip = 0
40
+ saw_func = 0
41
+ buf = ""
42
+ next
43
+ }
36
44
  # Non-function line: block is over — emit any buffered lines
37
45
  skip = 0
38
46
  if (buf != "") printf "%s", buf
@@ -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
  }
package/uninstall.sh CHANGED
@@ -38,7 +38,7 @@ echo -e "${YELLOW}This will NOT remove:${NC}"
38
38
  echo " • Your workspaces ($HOME/workspaces/)"
39
39
  echo " • Cache files (~/.workspace-manager-cache/)"
40
40
  echo " • Configuration files (~/.workspace-manager-claude-config.json)"
41
- echo " • Shell integration (from .zshrc or .bashrc)"
41
+ echo " • Shell integration (from .zshrc or .bashrc and ~/.nemus/)"
42
42
  echo " • ghq (if installed)"
43
43
  echo ""
44
44
 
@@ -85,8 +85,11 @@ if [[ $REPLY =~ ^[Yy]$ ]]; then
85
85
  if grep -q "# Nemus - Shell Integration" "$HOME/.zshrc"; then
86
86
  # Create backup
87
87
  cp "$HOME/.zshrc" "$HOME/.zshrc.backup.$(date +%Y%m%d_%H%M%S)"
88
- # Remove the function
89
- sed -i.bak '/# Nemus - Shell Integration/,/^}$/d' "$HOME/.zshrc"
88
+ # Remove the block/source line (awk handles both formats safely)
89
+ TMPFILE=$(mktemp)
90
+ awk -f "$SCRIPT_DIR/remove-shell-block.awk" "$HOME/.zshrc" > "$TMPFILE"
91
+ chmod --reference="$HOME/.zshrc" "$TMPFILE" 2>/dev/null || chmod "$(stat -f '%Lp' "$HOME/.zshrc")" "$TMPFILE" 2>/dev/null || true
92
+ mv "$TMPFILE" "$HOME/.zshrc"
90
93
  print_success "Removed from .zshrc (backup created)"
91
94
  fi
92
95
  fi
@@ -96,11 +99,19 @@ if [[ $REPLY =~ ^[Yy]$ ]]; then
96
99
  if grep -q "# Nemus - Shell Integration" "$HOME/.bashrc"; then
97
100
  # Create backup
98
101
  cp "$HOME/.bashrc" "$HOME/.bashrc.backup.$(date +%Y%m%d_%H%M%S)"
99
- # Remove the function
100
- sed -i.bak '/# Nemus - Shell Integration/,/^}$/d' "$HOME/.bashrc"
102
+ # Remove the block/source line (awk handles both formats safely)
103
+ TMPFILE=$(mktemp)
104
+ awk -f "$SCRIPT_DIR/remove-shell-block.awk" "$HOME/.bashrc" > "$TMPFILE"
105
+ chmod --reference="$HOME/.bashrc" "$TMPFILE" 2>/dev/null || chmod "$(stat -f '%Lp' "$HOME/.bashrc")" "$TMPFILE" 2>/dev/null || true
106
+ mv "$TMPFILE" "$HOME/.bashrc"
101
107
  print_success "Removed from .bashrc (backup created)"
102
108
  fi
103
109
  fi
110
+
111
+ if [ -f "$HOME/.nemus/shell-integration.sh" ]; then
112
+ rm -f "$HOME/.nemus/shell-integration.sh"
113
+ print_success "Removed ~/.nemus/shell-integration.sh"
114
+ fi
104
115
  fi
105
116
 
106
117
  # Optionally remove cache