@nemus-cli/nemus 0.2.12 → 0.2.13

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,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.13] - 2026-08-27
11
+
12
+ ### Added
13
+
14
+ - **Shell completions**: `nemus completion bash|zsh|fish` prints a completion
15
+ script for that shell. Completes subcommands (names + aliases) and, for a
16
+ workspace-scoped command, live **workspace names** — the script calls back
17
+ into `nemus completion --workspaces`, so completions stay fresh without
18
+ regenerating. Registered for both the `nemus` and `nem` binaries. Install e.g.
19
+ `nemus completion zsh > "${fpath[1]}/_nemus"` (see README).
20
+
10
21
  ## [0.2.12] - 2026-08-27
11
22
 
12
23
  ### Added
package/README.md CHANGED
@@ -243,6 +243,24 @@ The workspace-scoped ones (`status`/`doctor`/`analyze-deps`) need an explicit
243
243
  workspace name with `--json` (they never prompt). On failure, `--json` prints a
244
244
  parseable `{ "ok": false, "error": … }` to stdout and exits non-zero.
245
245
 
246
+ ### Shell completions
247
+
248
+ Tab-complete subcommands and workspace names. `nemus completion <shell>` prints
249
+ a script for `bash`, `zsh`, or `fish` (works for both the `nemus` and `nem`
250
+ binaries):
251
+
252
+ ```bash
253
+ # bash
254
+ nemus completion bash > /etc/bash_completion.d/nemus # or >> ~/.bashrc
255
+ # zsh (a directory on your $fpath)
256
+ nemus completion zsh > "${fpath[1]}/_nemus"
257
+ # fish
258
+ nemus completion fish > ~/.config/fish/completions/nemus.fish
259
+ ```
260
+
261
+ Workspace names are resolved live (the script calls back into the CLI), so they
262
+ stay current without regenerating.
263
+
246
264
  ### Suites (reusable repo collections)
247
265
 
248
266
  ```bash
@@ -0,0 +1,143 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.COMPLETION_BINS = void 0;
4
+ exports.generateCompletion = generateCompletion;
5
+ exports.specsFromProgram = specsFromProgram;
6
+ exports.registerCompletionCommand = registerCompletionCommand;
7
+ const workspace_meta_1 = require("../utils/workspace-meta");
8
+ const logger_1 = require("../utils/logger");
9
+ /** Binaries that get completion registered (the CLI's bins). */
10
+ exports.COMPLETION_BINS = ['nemus', 'nem'];
11
+ /** Every token (name + aliases) that should complete as a subcommand. */
12
+ function allTokens(cmds) {
13
+ return cmds.flatMap((c) => [c.name, ...c.aliases]);
14
+ }
15
+ /** Tokens (names + aliases) of the commands that take a workspace argument. */
16
+ function workspaceTokens(cmds) {
17
+ return cmds.filter((c) => c.takesWorkspace).flatMap((c) => [c.name, ...c.aliases]);
18
+ }
19
+ /** Escape a description for a fish single-quoted string. */
20
+ function fishDesc(s) {
21
+ return s.replace(/\n/g, ' ').replace(/'/g, "'\\''");
22
+ }
23
+ /**
24
+ * 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.
29
+ */
30
+ function generateCompletion(shell, cmds, bins = exports.COMPLETION_BINS) {
31
+ const commands = allTokens(cmds).join(' ');
32
+ const wsCommands = workspaceTokens(cmds).join(' ');
33
+ if (shell === 'bash') {
34
+ return `# nemus bash completion. Install: nemus completion bash > /etc/bash_completion.d/nemus
35
+ # (or: nemus completion bash >> ~/.bashrc)
36
+ _nemus_complete() {
37
+ local cur bin sub
38
+ # bash does not clear COMPREPLY between completions; reset so a stale result
39
+ # from a previous TAB can't leak when we return without setting it.
40
+ COMPREPLY=()
41
+ cur="\${COMP_WORDS[COMP_CWORD]}"
42
+ bin="\${COMP_WORDS[0]}"
43
+ local commands="${commands}"
44
+ local ws_commands="${wsCommands}"
45
+ if [ "\$COMP_CWORD" -eq 1 ]; then
46
+ COMPREPLY=( \$(compgen -W "\$commands" -- "\$cur") )
47
+ return 0
48
+ fi
49
+ if [ "\$COMP_CWORD" -eq 2 ]; then
50
+ sub="\${COMP_WORDS[1]}"
51
+ if [[ " \$ws_commands " == *" \$sub "* ]]; then
52
+ local names
53
+ names="\$("\$bin" completion --workspaces 2>/dev/null)"
54
+ COMPREPLY=( \$(compgen -W "\$names" -- "\$cur") )
55
+ return 0
56
+ fi
57
+ fi
58
+ return 0
59
+ }
60
+ ${bins.map((b) => `complete -F _nemus_complete ${b}`).join('\n')}
61
+ `;
62
+ }
63
+ if (shell === 'zsh') {
64
+ // Autoloaded form: save as a file named `_nemus` on your $fpath.
65
+ return `#compdef ${bins.join(' ')}
66
+ # nemus zsh completion. Install: nemus completion zsh > "\${fpath[1]}/_nemus"
67
+ local -a _nemus_commands
68
+ _nemus_commands=(${allTokens(cmds).map((t) => `'${t}'`).join(' ')})
69
+ local _nemus_ws_commands="${wsCommands}"
70
+ if (( CURRENT == 2 )); then
71
+ compadd -- $_nemus_commands
72
+ return
73
+ fi
74
+ if (( CURRENT == 3 )); then
75
+ local sub=\${words[2]}
76
+ if [[ " $_nemus_ws_commands " == *" $sub "* ]]; then
77
+ local -a _nemus_names
78
+ _nemus_names=(\${(f)"$(\${words[1]} completion --workspaces 2>/dev/null)"})
79
+ compadd -- $_nemus_names
80
+ fi
81
+ fi
82
+ `;
83
+ }
84
+ // fish
85
+ const lines = ['# nemus fish completion. Install: nemus completion fish > ~/.config/fish/completions/nemus.fish'];
86
+ for (const bin of bins) {
87
+ lines.push(`complete -c ${bin} -f`);
88
+ for (const c of cmds) {
89
+ for (const tok of [c.name, ...c.aliases]) {
90
+ lines.push(`complete -c ${bin} -n __fish_use_subcommand -a '${tok}' -d '${fishDesc(c.description)}'`);
91
+ }
92
+ }
93
+ const wsToks = workspaceTokens(cmds).join(' ');
94
+ if (wsToks) {
95
+ lines.push(`complete -c ${bin} -n '__fish_seen_subcommand_from ${wsToks}' -a '(${bin} completion --workspaces)'`);
96
+ }
97
+ }
98
+ return lines.join('\n') + '\n';
99
+ }
100
+ /** Distill the program's top-level commands into CommandSpecs. */
101
+ function specsFromProgram(program) {
102
+ return program.commands
103
+ .map((c) => {
104
+ const args = c.registeredArguments ?? [];
105
+ const firstArg = args[0]?.name?.();
106
+ return {
107
+ name: c.name(),
108
+ aliases: c.aliases(),
109
+ takesWorkspace: typeof firstArg === 'string' && firstArg.toLowerCase().includes('workspace'),
110
+ description: c.description() ?? '',
111
+ };
112
+ })
113
+ // The completion command itself and any hidden helper needn't clutter, but
114
+ // keeping them is harmless; only drop entries with no name.
115
+ .filter((s) => s.name);
116
+ }
117
+ function registerCompletionCommand(program) {
118
+ program
119
+ .command('completion [shell]')
120
+ .description('Output a shell completion script (bash|zsh|fish)')
121
+ .option('--workspaces', 'Print workspace names (used internally by completion scripts)')
122
+ .action(async (shell, opts) => {
123
+ // Data helper the generated scripts call back into.
124
+ if (opts.workspaces) {
125
+ try {
126
+ const workspaces = await (0, workspace_meta_1.listWorkspaces)(false);
127
+ for (const ws of workspaces)
128
+ process.stdout.write(ws.name + '\n');
129
+ }
130
+ catch {
131
+ // Silent: completion must never error out the user's shell.
132
+ }
133
+ return;
134
+ }
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(', ')}`);
138
+ (0, logger_1.logError)('e.g. nemus completion bash');
139
+ process.exit(1);
140
+ }
141
+ process.stdout.write(generateCompletion(shell, specsFromProgram(program)));
142
+ });
143
+ }
package/dist/program.js CHANGED
@@ -88,6 +88,7 @@ const ghq_status_1 = require("./commands/ghq-status");
88
88
  const save_context_1 = require("./commands/save-context");
89
89
  const migrate_1 = require("./commands/migrate");
90
90
  const report_bug_1 = require("./commands/report-bug");
91
+ const completion_1 = require("./commands/completion");
91
92
  (0, create_1.registerCreateCommand)(exports.program);
92
93
  (0, list_1.registerListCommand)(exports.program);
93
94
  (0, update_1.registerUpdateCommand)(exports.program);
@@ -111,6 +112,7 @@ const report_bug_1 = require("./commands/report-bug");
111
112
  (0, save_context_1.registerSaveContextCommand)(exports.program);
112
113
  (0, migrate_1.registerMigrateCommand)(exports.program);
113
114
  (0, report_bug_1.registerReportBugCommand)(exports.program);
115
+ (0, completion_1.registerCompletionCommand)(exports.program);
114
116
  // Register TUI (delegates to existing Ink/React implementation)
115
117
  exports.program
116
118
  .command('tui')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nemus-cli/nemus",
3
- "version": "0.2.12",
3
+ "version": "0.2.13",
4
4
  "workspaces": [
5
5
  "packages/*"
6
6
  ],
@@ -0,0 +1,60 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { Command } from 'commander';
3
+ import { generateCompletion, specsFromProgram, CommandSpec } from './completion';
4
+
5
+ const specs: CommandSpec[] = [
6
+ { name: 'list', aliases: ['l'], takesWorkspace: false, description: 'List workspaces' },
7
+ { name: 'status', aliases: ['st'], takesWorkspace: true, description: "Show a repo's git status" },
8
+ { name: 'doctor', aliases: ['doc'], takesWorkspace: true, description: 'Health checks' },
9
+ ];
10
+
11
+ describe('generateCompletion — bash', () => {
12
+ const s = generateCompletion('bash', specs);
13
+ it('resets COMPREPLY, lists tokens, and registers both bins', () => {
14
+ expect(s).toContain('COMPREPLY=()'); // guards the stale-completion leak
15
+ expect(s).toContain('local commands="list l status st doctor doc"');
16
+ expect(s).toContain('local ws_commands="status st doctor doc"');
17
+ expect(s).toContain('complete -F _nemus_complete nemus');
18
+ expect(s).toContain('complete -F _nemus_complete nem');
19
+ });
20
+ it('calls back into the invoked bin for workspace names', () => {
21
+ expect(s).toContain('"$bin" completion --workspaces');
22
+ });
23
+ });
24
+
25
+ describe('generateCompletion — zsh', () => {
26
+ const s = generateCompletion('zsh', specs);
27
+ it('is an autoloadable #compdef script with the tokens + callback', () => {
28
+ expect(s.startsWith('#compdef nemus nem')).toBe(true);
29
+ expect(s).toContain("_nemus_commands=('list' 'l' 'status' 'st' 'doctor' 'doc')");
30
+ expect(s).toContain('_nemus_ws_commands="status st doctor doc"');
31
+ expect(s).toContain('completion --workspaces');
32
+ });
33
+ });
34
+
35
+ describe('generateCompletion — fish', () => {
36
+ const s = generateCompletion('fish', specs);
37
+ it('emits subcommand + workspace completions for both bins with escaped descriptions', () => {
38
+ expect(s).toContain("complete -c nemus -n __fish_use_subcommand -a 'list' -d 'List workspaces'");
39
+ expect(s).toContain("complete -c nem -n __fish_use_subcommand -a 'status'");
40
+ // apostrophe in the description is escaped for fish's single-quoted string
41
+ expect(s).toContain("Show a repo'\\''s git status");
42
+ expect(s).toContain("-n '__fish_seen_subcommand_from status st doctor doc' -a '(nemus completion --workspaces)'");
43
+ });
44
+ });
45
+
46
+ describe('specsFromProgram', () => {
47
+ it('detects workspace args + aliases from a commander program', () => {
48
+ const program = new Command();
49
+ program.command('list').alias('l').description('list');
50
+ program.command('status [workspace]').alias('st').description('status');
51
+ program.command('create').description('create');
52
+
53
+ const out = specsFromProgram(program);
54
+ const byName = Object.fromEntries(out.map((s) => [s.name, s]));
55
+ expect(byName.status.takesWorkspace).toBe(true);
56
+ expect(byName.status.aliases).toEqual(['st']);
57
+ expect(byName.list.takesWorkspace).toBe(false);
58
+ expect(byName.create.takesWorkspace).toBe(false);
59
+ });
60
+ });
@@ -0,0 +1,160 @@
1
+ import { Command } from 'commander';
2
+ import { listWorkspaces } from '../utils/workspace-meta';
3
+ import { logError } from '../utils/logger';
4
+
5
+ /** Binaries that get completion registered (the CLI's bins). */
6
+ export const COMPLETION_BINS = ['nemus', 'nem'];
7
+
8
+ export type Shell = 'bash' | 'zsh' | 'fish';
9
+
10
+ /** One top-level command, distilled to what a completion script needs. */
11
+ export interface CommandSpec {
12
+ name: string;
13
+ aliases: string[];
14
+ /** True if its first positional argument is a workspace name. */
15
+ takesWorkspace: boolean;
16
+ description: string;
17
+ }
18
+
19
+ /** Every token (name + aliases) that should complete as a subcommand. */
20
+ function allTokens(cmds: CommandSpec[]): string[] {
21
+ return cmds.flatMap((c) => [c.name, ...c.aliases]);
22
+ }
23
+
24
+ /** Tokens (names + aliases) of the commands that take a workspace argument. */
25
+ function workspaceTokens(cmds: CommandSpec[]): string[] {
26
+ return cmds.filter((c) => c.takesWorkspace).flatMap((c) => [c.name, ...c.aliases]);
27
+ }
28
+
29
+ /** Escape a description for a fish single-quoted string. */
30
+ function fishDesc(s: string): string {
31
+ return s.replace(/\n/g, ' ').replace(/'/g, "'\\''");
32
+ }
33
+
34
+ /**
35
+ * 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.
40
+ */
41
+ export function generateCompletion(shell: Shell, cmds: CommandSpec[], bins: string[] = COMPLETION_BINS): string {
42
+ const commands = allTokens(cmds).join(' ');
43
+ const wsCommands = workspaceTokens(cmds).join(' ');
44
+
45
+ if (shell === 'bash') {
46
+ return `# nemus bash completion. Install: nemus completion bash > /etc/bash_completion.d/nemus
47
+ # (or: nemus completion bash >> ~/.bashrc)
48
+ _nemus_complete() {
49
+ local cur bin sub
50
+ # bash does not clear COMPREPLY between completions; reset so a stale result
51
+ # from a previous TAB can't leak when we return without setting it.
52
+ COMPREPLY=()
53
+ cur="\${COMP_WORDS[COMP_CWORD]}"
54
+ bin="\${COMP_WORDS[0]}"
55
+ local commands="${commands}"
56
+ local ws_commands="${wsCommands}"
57
+ if [ "\$COMP_CWORD" -eq 1 ]; then
58
+ COMPREPLY=( \$(compgen -W "\$commands" -- "\$cur") )
59
+ return 0
60
+ fi
61
+ if [ "\$COMP_CWORD" -eq 2 ]; then
62
+ sub="\${COMP_WORDS[1]}"
63
+ if [[ " \$ws_commands " == *" \$sub "* ]]; then
64
+ local names
65
+ names="\$("\$bin" completion --workspaces 2>/dev/null)"
66
+ COMPREPLY=( \$(compgen -W "\$names" -- "\$cur") )
67
+ return 0
68
+ fi
69
+ fi
70
+ return 0
71
+ }
72
+ ${bins.map((b) => `complete -F _nemus_complete ${b}`).join('\n')}
73
+ `;
74
+ }
75
+
76
+ if (shell === 'zsh') {
77
+ // Autoloaded form: save as a file named `_nemus` on your $fpath.
78
+ return `#compdef ${bins.join(' ')}
79
+ # nemus zsh completion. Install: nemus completion zsh > "\${fpath[1]}/_nemus"
80
+ local -a _nemus_commands
81
+ _nemus_commands=(${allTokens(cmds).map((t) => `'${t}'`).join(' ')})
82
+ local _nemus_ws_commands="${wsCommands}"
83
+ if (( CURRENT == 2 )); then
84
+ compadd -- $_nemus_commands
85
+ return
86
+ fi
87
+ if (( CURRENT == 3 )); then
88
+ local sub=\${words[2]}
89
+ if [[ " $_nemus_ws_commands " == *" $sub "* ]]; then
90
+ local -a _nemus_names
91
+ _nemus_names=(\${(f)"$(\${words[1]} completion --workspaces 2>/dev/null)"})
92
+ compadd -- $_nemus_names
93
+ fi
94
+ fi
95
+ `;
96
+ }
97
+
98
+ // fish
99
+ const lines: string[] = ['# nemus fish completion. Install: nemus completion fish > ~/.config/fish/completions/nemus.fish'];
100
+ for (const bin of bins) {
101
+ lines.push(`complete -c ${bin} -f`);
102
+ for (const c of cmds) {
103
+ for (const tok of [c.name, ...c.aliases]) {
104
+ lines.push(`complete -c ${bin} -n __fish_use_subcommand -a '${tok}' -d '${fishDesc(c.description)}'`);
105
+ }
106
+ }
107
+ const wsToks = workspaceTokens(cmds).join(' ');
108
+ if (wsToks) {
109
+ lines.push(
110
+ `complete -c ${bin} -n '__fish_seen_subcommand_from ${wsToks}' -a '(${bin} completion --workspaces)'`,
111
+ );
112
+ }
113
+ }
114
+ return lines.join('\n') + '\n';
115
+ }
116
+
117
+ /** Distill the program's top-level commands into CommandSpecs. */
118
+ export function specsFromProgram(program: Command): CommandSpec[] {
119
+ return program.commands
120
+ .map((c) => {
121
+ const args = (c as any).registeredArguments ?? [];
122
+ const firstArg: string | undefined = args[0]?.name?.();
123
+ return {
124
+ name: c.name(),
125
+ aliases: c.aliases(),
126
+ takesWorkspace: typeof firstArg === 'string' && firstArg.toLowerCase().includes('workspace'),
127
+ description: c.description() ?? '',
128
+ };
129
+ })
130
+ // The completion command itself and any hidden helper needn't clutter, but
131
+ // keeping them is harmless; only drop entries with no name.
132
+ .filter((s) => s.name);
133
+ }
134
+
135
+ export function registerCompletionCommand(program: Command) {
136
+ program
137
+ .command('completion [shell]')
138
+ .description('Output a shell completion script (bash|zsh|fish)')
139
+ .option('--workspaces', 'Print workspace names (used internally by completion scripts)')
140
+ .action(async (shell: string | undefined, opts: { workspaces?: boolean }) => {
141
+ // Data helper the generated scripts call back into.
142
+ if (opts.workspaces) {
143
+ try {
144
+ const workspaces = await listWorkspaces(false);
145
+ for (const ws of workspaces) process.stdout.write(ws.name + '\n');
146
+ } catch {
147
+ // Silent: completion must never error out the user's shell.
148
+ }
149
+ return;
150
+ }
151
+
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(', ')}`);
155
+ logError('e.g. nemus completion bash');
156
+ process.exit(1);
157
+ }
158
+ process.stdout.write(generateCompletion(shell as Shell, specsFromProgram(program)));
159
+ });
160
+ }
package/src/program.ts CHANGED
@@ -58,6 +58,7 @@ import { registerGhqStatusCommand } from './commands/ghq-status';
58
58
  import { registerSaveContextCommand } from './commands/save-context';
59
59
  import { registerMigrateCommand } from './commands/migrate';
60
60
  import { registerReportBugCommand } from './commands/report-bug';
61
+ import { registerCompletionCommand } from './commands/completion';
61
62
 
62
63
  registerCreateCommand(program);
63
64
  registerListCommand(program);
@@ -82,6 +83,7 @@ registerGhqStatusCommand(program);
82
83
  registerSaveContextCommand(program);
83
84
  registerMigrateCommand(program);
84
85
  registerReportBugCommand(program);
86
+ registerCompletionCommand(program);
85
87
 
86
88
  // Register TUI (delegates to existing Ink/React implementation)
87
89
  program