@nemus-cli/nemus 0.11.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,32 @@ 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
+
23
+ ## [0.12.0] - 2026-09-02
24
+
25
+ ### Added
26
+
27
+ - **`nemus version` subcommand** — a companion to the `-V/--version` flag for
28
+ people who type `nemus version`. `--json` additionally reports the Node.js
29
+ version, platform, and arch (handy for bug reports), as a single JSON document
30
+ to stdout. (#74)
31
+ - **`NEMUS_NO_UPDATE_CHECK`** — opt out of the background "update available"
32
+ check entirely (no cache read, no network). Also honors the de-facto
33
+ `NO_UPDATE_NOTIFIER`. An explicit falsey value (`0`/`false`/empty) does not
34
+ disable it. Documented in the README env-var table. (#76)
35
+
10
36
  ## [0.11.0] - 2026-09-02
11
37
 
12
38
  ### Added
package/README.md CHANGED
@@ -288,6 +288,7 @@ Everything Nemus reads from the environment (all optional):
288
288
  | `NEMUS_JUDGE_TIMEOUT_MS` | Timeout for the `reflect` judge call. |
289
289
  | `NEMUS_BUG_REPORT_REPO` | Repo that `report-bug` files issues against. |
290
290
  | `NEMUS_SKIP_CONFIGURE` | Skip the one-time post-install `configure` prompt. |
291
+ | `NEMUS_NO_UPDATE_CHECK` | Disable the background "update available" check (also honors `NO_UPDATE_NOTIFIER`). |
291
292
  | `WORKSPACE_CLONE_TIMEOUT_MS` | Git clone timeout (default 15 min). |
292
293
  | `NO_COLOR` / `FORCE_COLOR` | Disable / force ANSI color (see [Global flags](#global-flags)). |
293
294
  | `VISUAL` / `EDITOR` | Editor launched by `nemus config edit`. |
@@ -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
  }
@@ -0,0 +1,37 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildVersionInfo = buildVersionInfo;
4
+ exports.registerVersionCommand = registerVersionCommand;
5
+ const output_1 = require("../utils/output");
6
+ /**
7
+ * Build the version payload. Pure and process-injectable so the JSON shape is
8
+ * unit-testable without reading the real runtime.
9
+ */
10
+ function buildVersionInfo(version, proc = process) {
11
+ return {
12
+ version,
13
+ node: proc.versions.node,
14
+ platform: proc.platform,
15
+ arch: proc.arch,
16
+ };
17
+ }
18
+ /**
19
+ * `nemus version` — a subcommand companion to the `-V/--version` flag, for
20
+ * people who type `nemus version`. `--json` also reports the Node/OS runtime
21
+ * (handy for bug reports), emitting a single JSON document to stdout.
22
+ */
23
+ function registerVersionCommand(program, version) {
24
+ program
25
+ .command('version')
26
+ .description('Print the Nemus version (with --json for version + runtime info)')
27
+ .option('--json', 'Output version + runtime info as JSON')
28
+ .action((opts) => {
29
+ const info = buildVersionInfo(version);
30
+ if (opts.json) {
31
+ (0, output_1.outputJson)(info);
32
+ }
33
+ else {
34
+ process.stdout.write(`nemus ${info.version}\n`);
35
+ }
36
+ });
37
+ }
package/dist/program.js CHANGED
@@ -40,6 +40,7 @@ const fs = __importStar(require("fs"));
40
40
  const colors_1 = require("./utils/colors");
41
41
  const banner_1 = require("./utils/banner");
42
42
  const global_flags_1 = require("./utils/global-flags");
43
+ const version_1 = require("./commands/version");
43
44
  // Read version from package.json
44
45
  const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8'));
45
46
  // --no-color must be applied BEFORE commander parses so it reaches the help
@@ -66,6 +67,7 @@ exports.program
66
67
  // also pre-scanned above.
67
68
  exports.program.hook('preAction', () => (0, global_flags_1.applyGlobalFlags)(exports.program.opts()));
68
69
  // Register top-level commands
70
+ (0, version_1.registerVersionCommand)(exports.program, pkg.version);
69
71
  const create_1 = require("./commands/create");
70
72
  const list_1 = require("./commands/list");
71
73
  const update_1 = require("./commands/update");
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.checkForUpdate = checkForUpdate;
37
+ exports.updateCheckDisabled = updateCheckDisabled;
37
38
  const fs = __importStar(require("fs/promises"));
38
39
  const path = __importStar(require("path"));
39
40
  const child_process_1 = require("child_process");
@@ -89,6 +90,11 @@ async function fetchLatestVersion() {
89
90
  * This is designed to be non-blocking and best-effort — failures are silent.
90
91
  */
91
92
  async function checkForUpdate() {
93
+ // Opt-out: skip the check entirely (no cache read, no network) when the user
94
+ // asks for it. NEMUS_NO_UPDATE_CHECK is ours; NO_UPDATE_NOTIFIER is the
95
+ // de-facto convention several Node CLIs honor.
96
+ if (updateCheckDisabled(process.env))
97
+ return null;
92
98
  try {
93
99
  const currentVersion = (0, config_1.getPackageVersion)();
94
100
  const cache = await loadCache();
@@ -116,6 +122,15 @@ async function checkForUpdate() {
116
122
  return null;
117
123
  }
118
124
  }
125
+ /**
126
+ * Whether the update check is opted out via env. A value is "set" unless it is
127
+ * empty or an explicit falsey token (`0`/`false`), so `NEMUS_NO_UPDATE_CHECK=0`
128
+ * does NOT disable the check. Pure + exported for testing.
129
+ */
130
+ function updateCheckDisabled(env) {
131
+ const isSet = (v) => v !== undefined && v !== '' && v !== '0' && v.toLowerCase() !== 'false';
132
+ return isSet(env.NEMUS_NO_UPDATE_CHECK) || isSet(env.NO_UPDATE_NOTIFIER);
133
+ }
119
134
  function formatUpdateMessage(current, latest) {
120
135
  return `\x1b[33m[nemus] Update available: ${current} -> ${latest}. Run: npm install -g @nemus-cli/nemus@latest\x1b[0m`;
121
136
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nemus-cli/nemus",
3
- "version": "0.11.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
  }
@@ -0,0 +1,21 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { buildVersionInfo } from './version';
3
+
4
+ describe('buildVersionInfo', () => {
5
+ it('combines the given version with the injected runtime fields', () => {
6
+ const info = buildVersionInfo('1.2.3', {
7
+ versions: { node: '22.13.0' } as NodeJS.ProcessVersions,
8
+ platform: 'linux',
9
+ arch: 'x64',
10
+ });
11
+ expect(info).toEqual({ version: '1.2.3', node: '22.13.0', platform: 'linux', arch: 'x64' });
12
+ });
13
+
14
+ it('defaults to the real process runtime', () => {
15
+ const info = buildVersionInfo('9.9.9');
16
+ expect(info.version).toBe('9.9.9');
17
+ expect(info.node).toBe(process.versions.node);
18
+ expect(info.platform).toBe(process.platform);
19
+ expect(info.arch).toBe(process.arch);
20
+ });
21
+ });
@@ -0,0 +1,45 @@
1
+ import { Command } from 'commander';
2
+ import { outputJson } from '../utils/output';
3
+
4
+ export interface VersionInfo {
5
+ version: string;
6
+ node: string;
7
+ platform: string;
8
+ arch: string;
9
+ }
10
+
11
+ /**
12
+ * Build the version payload. Pure and process-injectable so the JSON shape is
13
+ * unit-testable without reading the real runtime.
14
+ */
15
+ export function buildVersionInfo(
16
+ version: string,
17
+ proc: Pick<NodeJS.Process, 'versions' | 'platform' | 'arch'> = process,
18
+ ): VersionInfo {
19
+ return {
20
+ version,
21
+ node: proc.versions.node,
22
+ platform: proc.platform,
23
+ arch: proc.arch,
24
+ };
25
+ }
26
+
27
+ /**
28
+ * `nemus version` — a subcommand companion to the `-V/--version` flag, for
29
+ * people who type `nemus version`. `--json` also reports the Node/OS runtime
30
+ * (handy for bug reports), emitting a single JSON document to stdout.
31
+ */
32
+ export function registerVersionCommand(program: Command, version: string) {
33
+ program
34
+ .command('version')
35
+ .description('Print the Nemus version (with --json for version + runtime info)')
36
+ .option('--json', 'Output version + runtime info as JSON')
37
+ .action((opts: { json?: boolean }) => {
38
+ const info = buildVersionInfo(version);
39
+ if (opts.json) {
40
+ outputJson(info);
41
+ } else {
42
+ process.stdout.write(`nemus ${info.version}\n`);
43
+ }
44
+ });
45
+ }
package/src/program.ts CHANGED
@@ -4,6 +4,7 @@ import * as fs from 'fs';
4
4
  import { setColorEnabled } from './utils/colors';
5
5
  import { renderHelpBanner } from './utils/banner';
6
6
  import { applyGlobalFlags } from './utils/global-flags';
7
+ import { registerVersionCommand } from './commands/version';
7
8
 
8
9
  // Read version from package.json
9
10
  const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8'));
@@ -35,6 +36,7 @@ program
35
36
  program.hook('preAction', () => applyGlobalFlags(program.opts()));
36
37
 
37
38
  // Register top-level commands
39
+ registerVersionCommand(program, pkg.version);
38
40
  import { registerCreateCommand } from './commands/create';
39
41
  import { registerListCommand } from './commands/list';
40
42
  import { registerUpdateCommand } from './commands/update';
@@ -24,14 +24,41 @@ vi.mock('./config', () => ({
24
24
  getPackageVersion: () => '2.20.0',
25
25
  }));
26
26
 
27
- import { checkForUpdate } from './version-check';
27
+ import { checkForUpdate, updateCheckDisabled } from './version-check';
28
28
  import * as fs from 'fs/promises';
29
29
 
30
+ describe('updateCheckDisabled', () => {
31
+ it('is true when NEMUS_NO_UPDATE_CHECK is set to a truthy value', () => {
32
+ expect(updateCheckDisabled({ NEMUS_NO_UPDATE_CHECK: '1' })).toBe(true);
33
+ expect(updateCheckDisabled({ NEMUS_NO_UPDATE_CHECK: 'yes' })).toBe(true);
34
+ });
35
+ it('honors the de-facto NO_UPDATE_NOTIFIER', () => {
36
+ expect(updateCheckDisabled({ NO_UPDATE_NOTIFIER: 'true' })).toBe(true);
37
+ });
38
+ it('is false when unset, empty, or an explicit falsey token', () => {
39
+ expect(updateCheckDisabled({})).toBe(false);
40
+ expect(updateCheckDisabled({ NEMUS_NO_UPDATE_CHECK: '' })).toBe(false);
41
+ expect(updateCheckDisabled({ NEMUS_NO_UPDATE_CHECK: '0' })).toBe(false);
42
+ expect(updateCheckDisabled({ NEMUS_NO_UPDATE_CHECK: 'false' })).toBe(false);
43
+ });
44
+ });
45
+
30
46
  describe('checkForUpdate', () => {
31
47
  beforeEach(() => {
32
48
  vi.clearAllMocks();
33
49
  });
34
50
 
51
+ it('returns null immediately when opted out via env (no fetch)', async () => {
52
+ process.env.NEMUS_NO_UPDATE_CHECK = '1';
53
+ try {
54
+ const result = await checkForUpdate();
55
+ expect(result).toBeNull();
56
+ expect(mockExecFile).not.toHaveBeenCalled();
57
+ } finally {
58
+ delete process.env.NEMUS_NO_UPDATE_CHECK;
59
+ }
60
+ });
61
+
35
62
  it('returns null when current version matches latest', async () => {
36
63
  // No cached check
37
64
  vi.mocked(fs.readFile).mockRejectedValueOnce(new Error('ENOENT'));
@@ -60,6 +60,10 @@ async function fetchLatestVersion(): Promise<string | null> {
60
60
  * This is designed to be non-blocking and best-effort — failures are silent.
61
61
  */
62
62
  export async function checkForUpdate(): Promise<string | null> {
63
+ // Opt-out: skip the check entirely (no cache read, no network) when the user
64
+ // asks for it. NEMUS_NO_UPDATE_CHECK is ours; NO_UPDATE_NOTIFIER is the
65
+ // de-facto convention several Node CLIs honor.
66
+ if (updateCheckDisabled(process.env)) return null;
63
67
  try {
64
68
  const currentVersion = getPackageVersion();
65
69
  const cache = await loadCache();
@@ -91,6 +95,17 @@ export async function checkForUpdate(): Promise<string | null> {
91
95
  }
92
96
  }
93
97
 
98
+ /**
99
+ * Whether the update check is opted out via env. A value is "set" unless it is
100
+ * empty or an explicit falsey token (`0`/`false`), so `NEMUS_NO_UPDATE_CHECK=0`
101
+ * does NOT disable the check. Pure + exported for testing.
102
+ */
103
+ export function updateCheckDisabled(env: NodeJS.ProcessEnv): boolean {
104
+ const isSet = (v: string | undefined) =>
105
+ v !== undefined && v !== '' && v !== '0' && v.toLowerCase() !== 'false';
106
+ return isSet(env.NEMUS_NO_UPDATE_CHECK) || isSet(env.NO_UPDATE_NOTIFIER);
107
+ }
108
+
94
109
  function formatUpdateMessage(current: string, latest: string): string {
95
110
  return `\x1b[33m[nemus] Update available: ${current} -> ${latest}. Run: npm install -g @nemus-cli/nemus@latest\x1b[0m`;
96
111
  }