@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 +24 -0
- package/README.md +28 -5
- package/dist/commands/analyze-deps.js +42 -8
- package/dist/commands/completion.js +143 -0
- package/dist/commands/sessions.js +33 -6
- package/dist/commands/suite/index.js +3 -2
- package/dist/commands/suite/list.js +26 -7
- package/dist/program.js +2 -0
- package/package.json +1 -1
- package/src/commands/analyze-deps.ts +38 -8
- package/src/commands/completion.test.ts +60 -0
- package/src/commands/completion.ts +160 -0
- package/src/commands/sessions.ts +32 -5
- package/src/commands/suite/index.ts +3 -2
- package/src/commands/suite/list.test.ts +62 -0
- package/src/commands/suite/list.ts +26 -8
- package/src/program.ts +2 -0
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
|
-
|
|
229
|
-
output
|
|
230
|
-
|
|
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`
|
|
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
|
-
.
|
|
75
|
-
|
|
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
|
-
(
|
|
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
|
-
(
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
.
|
|
59
|
-
|
|
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
|
-
(
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
.
|
|
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
|
-
(
|
|
42
|
-
|
|
43
|
-
|
|
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
|
@@ -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
|
-
.
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
+
}
|
package/src/commands/sessions.ts
CHANGED
|
@@ -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
|
-
.
|
|
24
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
.
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|