@nemus-cli/nemus 0.2.11 → 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,30 @@ 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
+
21
+ ## [0.2.12] - 2026-08-27
22
+
23
+ ### Added
24
+
25
+ - **`--json` for more read-only reporting commands**, extending 0.2.11's set
26
+ (`list`/`status`/`doctor`) to `suite list`, `sessions`, and `analyze-deps`.
27
+ Each emits exactly one JSON document to stdout (no tables, no interactive
28
+ prompt): `suite list --json` (saved suites + entries), `sessions --json`
29
+ (workspace sessions, list-only — no resume), `analyze-deps --json` (per-repo
30
+ dependencies/dependents/missing + circular deps + suggested missing repos;
31
+ requires an explicit workspace). `--json` errors are parseable
32
+ `{ ok:false, error }` on stdout + exit 1, consistent with 0.2.11.
33
+
10
34
  ## [0.2.11] - 2026-08-27
11
35
 
12
36
  ### Added
package/README.md CHANGED
@@ -225,18 +225,41 @@ shared-lib main ✓ Clean ↓3 -
225
225
 
226
226
  ### Scripting: `--json`
227
227
 
228
- `list`, `status`, and `doctor` accept `--json` for stable, machine-readable
229
- output. Diagnostics go to stderr, so stdout is a single JSON document you can
230
- pipe straight into `jq` or a CI step:
228
+ The read-only reporting commands accept `--json` for stable, machine-readable
229
+ output: `list`, `status`, `doctor`, `suite list`, `sessions`, and
230
+ `analyze-deps`. Diagnostics go to stderr, so stdout is a single JSON document
231
+ you can pipe straight into `jq` or a CI step:
231
232
 
232
233
  ```bash
233
234
  nemus list --json | jq -r '.workspaces[].name'
234
235
  nemus status my-workspace --json | jq '.clean'
235
236
  nemus doctor my-workspace --json | jq '.score'
237
+ nemus suite list --json | jq -r '.suites[].name'
238
+ nemus sessions --json | jq -r '.sessions[].workspaceName'
239
+ nemus analyze-deps my-workspace --json | jq '.circularDependencies'
236
240
  ```
237
241
 
238
- `status`/`doctor` with `--json` need an explicit workspace name (they never
239
- prompt).
242
+ The workspace-scoped ones (`status`/`doctor`/`analyze-deps`) need an explicit
243
+ workspace name with `--json` (they never prompt). On failure, `--json` prints a
244
+ parseable `{ "ok": false, "error": … }` to stdout and exits non-zero.
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.
240
263
 
241
264
  ### Suites (reusable repo collections)
242
265
 
@@ -42,6 +42,7 @@ const config_1 = require("../utils/config");
42
42
  const workspace_meta_1 = require("../utils/workspace-meta");
43
43
  const dependency_analyzer_1 = require("../utils/dependency-analyzer");
44
44
  const logger_1 = require("../utils/logger");
45
+ const output_1 = require("../utils/output");
45
46
  const colors_1 = require("../utils/colors");
46
47
  const inquirer_1 = __importDefault(require("inquirer"));
47
48
  const command_helpers_1 = require("../utils/command-helpers");
@@ -71,22 +72,51 @@ function registerAnalyzeDepsCommand(parent) {
71
72
  .alias('ad')
72
73
  .description('Analyze inter-repo dependencies')
73
74
  .argument('[workspace]', 'Workspace name')
74
- .action(async (workspace) => {
75
- await handleAnalyzeDeps(workspace);
75
+ .option('--json', 'Output as JSON (no interactive save)')
76
+ .action(async (workspace, opts) => {
77
+ await handleAnalyzeDeps(workspace, opts);
76
78
  });
77
79
  }
78
- async function handleAnalyzeDeps(workspaceArg) {
80
+ async function handleAnalyzeDeps(workspaceArg, opts = {}) {
79
81
  try {
82
+ // JSON mode is non-interactive: require an explicit workspace rather than prompt.
83
+ if (opts.json && !workspaceArg) {
84
+ (0, output_1.outputJsonError)('analyze-deps --json requires a workspace name');
85
+ process.exit(1);
86
+ }
80
87
  const selectedWorkspace = await (0, command_helpers_1.resolveWorkspace)(workspaceArg);
81
88
  const workspacePath = path.join(config_1.WORKSPACES_DIR, selectedWorkspace);
82
89
  const metadata = await (0, workspace_meta_1.loadMetadata)(workspacePath);
83
90
  if (!metadata) {
84
- (0, logger_1.logError)(`Workspace metadata not found for: ${selectedWorkspace}`);
91
+ if (opts.json)
92
+ (0, output_1.outputJsonError)(`Workspace metadata not found for: ${selectedWorkspace}`);
93
+ else
94
+ (0, logger_1.logError)(`Workspace metadata not found for: ${selectedWorkspace}`);
85
95
  process.exit(1);
86
96
  }
97
+ const repoNames = metadata.repositories.map(r => r.name);
98
+ if (opts.json) {
99
+ const analyses = await (0, dependency_analyzer_1.analyzeDependencies)(workspacePath, repoNames);
100
+ const cycles = (0, dependency_analyzer_1.detectCircularDependencies)(analyses);
101
+ const missing = new Set();
102
+ for (const [, a] of analyses)
103
+ for (const dep of a.missingDependencies)
104
+ missing.add(dep);
105
+ (0, output_1.outputJson)({
106
+ workspace: selectedWorkspace,
107
+ repositories: Array.from(analyses.entries()).map(([name, a]) => ({
108
+ name,
109
+ dependencies: a.dependencies,
110
+ dependents: a.dependents,
111
+ missingDependencies: a.missingDependencies,
112
+ })),
113
+ circularDependencies: cycles,
114
+ missingRepositories: Array.from(missing),
115
+ });
116
+ return;
117
+ }
87
118
  (0, logger_1.logStep)(`Analyzing dependencies for workspace: ${(0, colors_1.colorize)(selectedWorkspace, 'cyan')}`);
88
119
  (0, logger_1.logInfo)('Scanning package.json, Dockerfile, and docker-compose.yml files...');
89
- const repoNames = metadata.repositories.map(r => r.name);
90
120
  const analyses = await (0, dependency_analyzer_1.analyzeDependencies)(workspacePath, repoNames);
91
121
  displayDependencyAnalysis(analyses);
92
122
  const cycles = (0, dependency_analyzer_1.detectCircularDependencies)(analyses);
@@ -129,9 +159,13 @@ async function handleAnalyzeDeps(workspaceArg) {
129
159
  }
130
160
  }
131
161
  catch (error) {
132
- (0, logger_1.logError)('Failed to analyze dependencies');
133
- if (error instanceof Error) {
134
- (0, logger_1.logError)(error.message);
162
+ if (opts.json) {
163
+ (0, output_1.outputJsonError)(error instanceof Error ? error.message : 'Failed to analyze dependencies');
164
+ }
165
+ else {
166
+ (0, logger_1.logError)('Failed to analyze dependencies');
167
+ if (error instanceof Error)
168
+ (0, logger_1.logError)(error.message);
135
169
  }
136
170
  process.exit(1);
137
171
  }
@@ -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
+ }
@@ -43,6 +43,7 @@ const fs = __importStar(require("fs/promises"));
43
43
  const claude_sessions_1 = require("../utils/claude-sessions");
44
44
  const workspace_meta_1 = require("../utils/workspace-meta");
45
45
  const logger_1 = require("../utils/logger");
46
+ const output_1 = require("../utils/output");
46
47
  const colors_1 = require("../utils/colors");
47
48
  const inquirer_1 = __importDefault(require("inquirer"));
48
49
  const inquirer_autocomplete_prompt_1 = __importDefault(require("inquirer-autocomplete-prompt"));
@@ -55,14 +56,19 @@ function registerSessionsCommand(parent) {
55
56
  .command('sessions')
56
57
  .alias('ses')
57
58
  .description('Resume a Claude session in a workspace')
58
- .action(async () => {
59
- await handleSessions();
59
+ .option('--json', 'List sessions as JSON (no interactive resume)')
60
+ .action(async (opts) => {
61
+ await handleSessions(opts);
60
62
  });
61
63
  }
62
- async function handleSessions() {
64
+ async function handleSessions(opts = {}) {
63
65
  try {
64
66
  const sessions = await (0, claude_sessions_1.getWorkspaceSessions)();
65
67
  if (sessions.length === 0) {
68
+ if (opts.json) {
69
+ (0, output_1.outputJson)({ count: 0, sessions: [] });
70
+ return;
71
+ }
66
72
  (0, logger_1.logInfo)('No workspace sessions found.');
67
73
  console.log('\nYou can create a workspace with: nemus create');
68
74
  console.log('Or navigate to one with: w go');
@@ -76,6 +82,22 @@ async function handleSessions() {
76
82
  const repoLabel = repoCount > 0 ? `${repoCount} repos` : 'no repos';
77
83
  return { session, repoLabel };
78
84
  });
85
+ // JSON mode: list sessions to stdout, no resume prompt / temp-file writes.
86
+ if (opts.json) {
87
+ (0, output_1.outputJson)({
88
+ count: items.length,
89
+ sessions: items.map(i => ({
90
+ workspaceName: i.session.workspaceName,
91
+ workspacePath: i.session.workspacePath,
92
+ sessionId: i.session.sessionId,
93
+ agentType: i.session.agentType ?? null,
94
+ lastActive: i.session.lastActiveLabel,
95
+ lastActiveAt: i.session.lastActiveAt.toISOString(),
96
+ repoCount: workspaceMap.get(i.session.workspaceName)?.metadata?.repositories?.length ?? 0,
97
+ })),
98
+ });
99
+ return;
100
+ }
79
101
  const maxNameLen = Math.max(...items.map(i => i.session.workspaceName.length));
80
102
  console.log('');
81
103
  console.log((0, colors_1.colorize)(' Workspace Sessions', 'bright') + (0, colors_1.colorize)(' (sorted by last active)', 'dim'));
@@ -120,9 +142,14 @@ async function handleSessions() {
120
142
  catch (error) {
121
143
  if (error?.name === 'ExitPromptError')
122
144
  return;
123
- (0, logger_1.logError)('Failed to list sessions');
124
- if (error instanceof Error) {
125
- (0, logger_1.logError)(error.message);
145
+ if (opts.json) {
146
+ (0, output_1.outputJsonError)(error instanceof Error ? error.message : 'Failed to list sessions');
147
+ }
148
+ else {
149
+ (0, logger_1.logError)('Failed to list sessions');
150
+ if (error instanceof Error) {
151
+ (0, logger_1.logError)(error.message);
152
+ }
126
153
  }
127
154
  process.exit(1);
128
155
  }
@@ -49,9 +49,10 @@ function registerSuiteCommands(parent) {
49
49
  suite
50
50
  .command('list')
51
51
  .description('List all saved suites')
52
- .action(async () => {
52
+ .option('--json', 'Output as JSON')
53
+ .action(async (opts) => {
53
54
  const { main } = await Promise.resolve().then(() => __importStar(require('./list')));
54
- await main();
55
+ await main(opts);
55
56
  });
56
57
  suite
57
58
  .command('delete')
@@ -4,13 +4,28 @@ Object.defineProperty(exports, "__esModule", { value: true });
4
4
  exports.main = main;
5
5
  const suite_1 = require("../../utils/suite");
6
6
  const logger_1 = require("../../utils/logger");
7
+ const output_1 = require("../../utils/output");
7
8
  const colors_1 = require("../../utils/colors");
8
- async function main() {
9
- console.log('\n' + '='.repeat(60));
10
- console.log((0, colors_1.colorize)('Saved Suites', 'bright'));
11
- console.log('='.repeat(60) + '\n');
9
+ async function main(opts = {}) {
12
10
  try {
13
11
  const suites = await (0, suite_1.listSuites)();
12
+ if (opts.json) {
13
+ (0, output_1.outputJson)({
14
+ count: suites.length,
15
+ suites: suites.map(s => ({
16
+ name: s.name,
17
+ description: s.description ?? null,
18
+ repoCount: s.entries.length,
19
+ entries: s.entries.map(e => ({ directoryName: e.directoryName, repoName: e.repoName })),
20
+ createdAt: s.createdAt,
21
+ updatedAt: s.updatedAt,
22
+ })),
23
+ });
24
+ return;
25
+ }
26
+ console.log('\n' + '='.repeat(60));
27
+ console.log((0, colors_1.colorize)('Saved Suites', 'bright'));
28
+ console.log('='.repeat(60) + '\n');
14
29
  if (suites.length === 0) {
15
30
  (0, logger_1.logInfo)('No suites found');
16
31
  console.log('\nCreate a new suite with:');
@@ -38,9 +53,13 @@ async function main() {
38
53
  console.log('='.repeat(60) + '\n');
39
54
  }
40
55
  catch (error) {
41
- (0, logger_1.logError)('Failed to list suites');
42
- if (error instanceof Error) {
43
- (0, logger_1.logError)(error.message);
56
+ if (opts.json) {
57
+ (0, output_1.outputJsonError)(error instanceof Error ? error.message : 'Failed to list suites');
58
+ }
59
+ else {
60
+ (0, logger_1.logError)('Failed to list suites');
61
+ if (error instanceof Error)
62
+ (0, logger_1.logError)(error.message);
44
63
  }
45
64
  process.exit(1);
46
65
  }
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.11",
3
+ "version": "0.2.13",
4
4
  "workspaces": [
5
5
  "packages/*"
6
6
  ],
@@ -9,6 +9,7 @@ import {
9
9
  updateWorkspaceMetadata,
10
10
  } from '../utils/dependency-analyzer';
11
11
  import { logError, logInfo, logStep, logSuccess } from '../utils/logger';
12
+ import { outputJson, outputJsonError } from '../utils/output';
12
13
  import { colorize } from '../utils/colors';
13
14
  import inquirer from 'inquirer';
14
15
  import { resolveWorkspace } from '../utils/command-helpers';
@@ -42,26 +43,53 @@ export function registerAnalyzeDepsCommand(parent: Command) {
42
43
  .alias('ad')
43
44
  .description('Analyze inter-repo dependencies')
44
45
  .argument('[workspace]', 'Workspace name')
45
- .action(async (workspace) => {
46
- await handleAnalyzeDeps(workspace);
46
+ .option('--json', 'Output as JSON (no interactive save)')
47
+ .action(async (workspace, opts) => {
48
+ await handleAnalyzeDeps(workspace, opts);
47
49
  });
48
50
  }
49
51
 
50
- async function handleAnalyzeDeps(workspaceArg?: string) {
52
+ async function handleAnalyzeDeps(workspaceArg?: string, opts: { json?: boolean } = {}) {
51
53
  try {
54
+ // JSON mode is non-interactive: require an explicit workspace rather than prompt.
55
+ if (opts.json && !workspaceArg) {
56
+ outputJsonError('analyze-deps --json requires a workspace name');
57
+ process.exit(1);
58
+ }
52
59
  const selectedWorkspace = await resolveWorkspace(workspaceArg);
53
60
  const workspacePath = path.join(WORKSPACES_DIR, selectedWorkspace);
54
61
  const metadata = await loadMetadata(workspacePath);
55
62
 
56
63
  if (!metadata) {
57
- logError(`Workspace metadata not found for: ${selectedWorkspace}`);
64
+ if (opts.json) outputJsonError(`Workspace metadata not found for: ${selectedWorkspace}`);
65
+ else logError(`Workspace metadata not found for: ${selectedWorkspace}`);
58
66
  process.exit(1);
59
67
  }
60
68
 
69
+ const repoNames = metadata.repositories.map(r => r.name);
70
+
71
+ if (opts.json) {
72
+ const analyses = await analyzeDependencies(workspacePath, repoNames);
73
+ const cycles = detectCircularDependencies(analyses);
74
+ const missing = new Set<string>();
75
+ for (const [, a] of analyses) for (const dep of a.missingDependencies) missing.add(dep);
76
+ outputJson({
77
+ workspace: selectedWorkspace,
78
+ repositories: Array.from(analyses.entries()).map(([name, a]) => ({
79
+ name,
80
+ dependencies: a.dependencies,
81
+ dependents: a.dependents,
82
+ missingDependencies: a.missingDependencies,
83
+ })),
84
+ circularDependencies: cycles,
85
+ missingRepositories: Array.from(missing),
86
+ });
87
+ return;
88
+ }
89
+
61
90
  logStep(`Analyzing dependencies for workspace: ${colorize(selectedWorkspace, 'cyan')}`);
62
91
  logInfo('Scanning package.json, Dockerfile, and docker-compose.yml files...');
63
92
 
64
- const repoNames = metadata.repositories.map(r => r.name);
65
93
  const analyses = await analyzeDependencies(workspacePath, repoNames);
66
94
 
67
95
  displayDependencyAnalysis(analyses);
@@ -110,9 +138,11 @@ async function handleAnalyzeDeps(workspaceArg?: string) {
110
138
  }
111
139
  }
112
140
  } catch (error) {
113
- logError('Failed to analyze dependencies');
114
- if (error instanceof Error) {
115
- logError(error.message);
141
+ if (opts.json) {
142
+ outputJsonError(error instanceof Error ? error.message : 'Failed to analyze dependencies');
143
+ } else {
144
+ logError('Failed to analyze dependencies');
145
+ if (error instanceof Error) logError(error.message);
116
146
  }
117
147
  process.exit(1);
118
148
  }
@@ -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
+ }
@@ -5,6 +5,7 @@ import * as fs from 'fs/promises';
5
5
  import { getWorkspaceSessions, WorkspaceSession } from '../utils/claude-sessions';
6
6
  import { listWorkspaces } from '../utils/workspace-meta';
7
7
  import { logError, logInfo } from '../utils/logger';
8
+ import { outputJson, outputJsonError } from '../utils/output';
8
9
  import { colorize } from '../utils/colors';
9
10
  import inquirer from 'inquirer';
10
11
  import autocompletePrompt from 'inquirer-autocomplete-prompt';
@@ -20,16 +21,21 @@ export function registerSessionsCommand(parent: Command) {
20
21
  .command('sessions')
21
22
  .alias('ses')
22
23
  .description('Resume a Claude session in a workspace')
23
- .action(async () => {
24
- await handleSessions();
24
+ .option('--json', 'List sessions as JSON (no interactive resume)')
25
+ .action(async (opts) => {
26
+ await handleSessions(opts);
25
27
  });
26
28
  }
27
29
 
28
- async function handleSessions() {
30
+ async function handleSessions(opts: { json?: boolean } = {}) {
29
31
  try {
30
32
  const sessions = await getWorkspaceSessions();
31
33
 
32
34
  if (sessions.length === 0) {
35
+ if (opts.json) {
36
+ outputJson({ count: 0, sessions: [] });
37
+ return;
38
+ }
33
39
  logInfo('No workspace sessions found.');
34
40
  console.log('\nYou can create a workspace with: nemus create');
35
41
  console.log('Or navigate to one with: w go');
@@ -46,6 +52,23 @@ async function handleSessions() {
46
52
  return { session, repoLabel };
47
53
  });
48
54
 
55
+ // JSON mode: list sessions to stdout, no resume prompt / temp-file writes.
56
+ if (opts.json) {
57
+ outputJson({
58
+ count: items.length,
59
+ sessions: items.map(i => ({
60
+ workspaceName: i.session.workspaceName,
61
+ workspacePath: i.session.workspacePath,
62
+ sessionId: i.session.sessionId,
63
+ agentType: i.session.agentType ?? null,
64
+ lastActive: i.session.lastActiveLabel,
65
+ lastActiveAt: i.session.lastActiveAt.toISOString(),
66
+ repoCount: workspaceMap.get(i.session.workspaceName)?.metadata?.repositories?.length ?? 0,
67
+ })),
68
+ });
69
+ return;
70
+ }
71
+
49
72
  const maxNameLen = Math.max(...items.map(i => i.session.workspaceName.length));
50
73
 
51
74
  console.log('');
@@ -92,8 +115,12 @@ async function handleSessions() {
92
115
  console.log(`\n${colorize('Resuming:', 'green')} ${session.workspaceName} (last active ${session.lastActiveLabel})`);
93
116
  } catch (error) {
94
117
  if ((error as any)?.name === 'ExitPromptError') return;
95
- logError('Failed to list sessions');
96
- if (error instanceof Error) { logError(error.message); }
118
+ if (opts.json) {
119
+ outputJsonError(error instanceof Error ? error.message : 'Failed to list sessions');
120
+ } else {
121
+ logError('Failed to list sessions');
122
+ if (error instanceof Error) { logError(error.message); }
123
+ }
97
124
  process.exit(1);
98
125
  }
99
126
  }
@@ -17,9 +17,10 @@ export function registerSuiteCommands(parent: Command) {
17
17
  suite
18
18
  .command('list')
19
19
  .description('List all saved suites')
20
- .action(async () => {
20
+ .option('--json', 'Output as JSON')
21
+ .action(async (opts) => {
21
22
  const { main } = await import('./list');
22
- await main();
23
+ await main(opts);
23
24
  });
24
25
 
25
26
  suite
@@ -0,0 +1,62 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+
3
+ const { mockListSuites } = vi.hoisted(() => ({ mockListSuites: vi.fn() }));
4
+
5
+ vi.mock('../../utils/suite', () => ({ listSuites: mockListSuites }));
6
+ vi.mock('../../utils/logger', () => ({ logInfo: vi.fn(), logError: vi.fn() }));
7
+ vi.mock('../../utils/colors', () => ({ colorize: (t: string) => t }));
8
+
9
+ import { main } from './list';
10
+
11
+ function makeSuite(name: string, entries = 1) {
12
+ return {
13
+ name,
14
+ description: `${name} desc`,
15
+ entries: Array.from({ length: entries }, (_, i) => ({ directoryName: `repo-${i}`, repoName: `repo-${i}` })),
16
+ createdAt: '2026-01-01T00:00:00.000Z',
17
+ updatedAt: '2026-01-02T00:00:00.000Z',
18
+ };
19
+ }
20
+
21
+ describe('suite list --json', () => {
22
+ let writeSpy: ReturnType<typeof vi.spyOn>;
23
+ beforeEach(() => {
24
+ vi.clearAllMocks();
25
+ writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
26
+ vi.spyOn(console, 'log').mockImplementation(() => {});
27
+ });
28
+ afterEach(() => vi.restoreAllMocks());
29
+
30
+ it('emits one valid JSON document with normalized suites', async () => {
31
+ mockListSuites.mockResolvedValueOnce([makeSuite('fees', 2), makeSuite('platform', 1)]);
32
+ await main({ json: true });
33
+ expect(writeSpy).toHaveBeenCalledTimes(1);
34
+ const payload = JSON.parse(writeSpy.mock.calls[0][0] as string);
35
+ expect(payload.count).toBe(2);
36
+ expect(payload.suites[0]).toEqual({
37
+ name: 'fees',
38
+ description: 'fees desc',
39
+ repoCount: 2,
40
+ entries: [
41
+ { directoryName: 'repo-0', repoName: 'repo-0' },
42
+ { directoryName: 'repo-1', repoName: 'repo-1' },
43
+ ],
44
+ createdAt: '2026-01-01T00:00:00.000Z',
45
+ updatedAt: '2026-01-02T00:00:00.000Z',
46
+ });
47
+ });
48
+
49
+ it('empty list emits count 0 (no header/log noise)', async () => {
50
+ mockListSuites.mockResolvedValueOnce([]);
51
+ await main({ json: true });
52
+ const payload = JSON.parse(writeSpy.mock.calls[0][0] as string);
53
+ expect(payload).toEqual({ count: 0, suites: [] });
54
+ });
55
+
56
+ it('non-json mode does not write to the stdout data channel', async () => {
57
+ mockListSuites.mockResolvedValueOnce([makeSuite('fees')]);
58
+ await main();
59
+ // human output goes through console.log (mocked), not process.stdout.write
60
+ expect(writeSpy).not.toHaveBeenCalled();
61
+ });
62
+ });
@@ -2,16 +2,32 @@
2
2
 
3
3
  import { listSuites } from '../../utils/suite';
4
4
  import { logInfo, logError } from '../../utils/logger';
5
+ import { outputJson, outputJsonError } from '../../utils/output';
5
6
  import { colorize } from '../../utils/colors';
6
7
 
7
- export async function main() {
8
- console.log('\n' + '='.repeat(60));
9
- console.log(colorize('Saved Suites', 'bright'));
10
- console.log('='.repeat(60) + '\n');
11
-
8
+ export async function main(opts: { json?: boolean } = {}) {
12
9
  try {
13
10
  const suites = await listSuites();
14
11
 
12
+ if (opts.json) {
13
+ outputJson({
14
+ count: suites.length,
15
+ suites: suites.map(s => ({
16
+ name: s.name,
17
+ description: s.description ?? null,
18
+ repoCount: s.entries.length,
19
+ entries: s.entries.map(e => ({ directoryName: e.directoryName, repoName: e.repoName })),
20
+ createdAt: s.createdAt,
21
+ updatedAt: s.updatedAt,
22
+ })),
23
+ });
24
+ return;
25
+ }
26
+
27
+ console.log('\n' + '='.repeat(60));
28
+ console.log(colorize('Saved Suites', 'bright'));
29
+ console.log('='.repeat(60) + '\n');
30
+
15
31
  if (suites.length === 0) {
16
32
  logInfo('No suites found');
17
33
  console.log('\nCreate a new suite with:');
@@ -43,9 +59,11 @@ export async function main() {
43
59
 
44
60
  console.log('='.repeat(60) + '\n');
45
61
  } catch (error) {
46
- logError('Failed to list suites');
47
- if (error instanceof Error) {
48
- logError(error.message);
62
+ if (opts.json) {
63
+ outputJsonError(error instanceof Error ? error.message : 'Failed to list suites');
64
+ } else {
65
+ logError('Failed to list suites');
66
+ if (error instanceof Error) logError(error.message);
49
67
  }
50
68
  process.exit(1);
51
69
  }
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