@nemus-cli/nemus 0.2.10 → 0.2.12

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,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.12] - 2026-08-27
11
+
12
+ ### Added
13
+
14
+ - **`--json` for more read-only reporting commands**, extending 0.2.11's set
15
+ (`list`/`status`/`doctor`) to `suite list`, `sessions`, and `analyze-deps`.
16
+ Each emits exactly one JSON document to stdout (no tables, no interactive
17
+ prompt): `suite list --json` (saved suites + entries), `sessions --json`
18
+ (workspace sessions, list-only — no resume), `analyze-deps --json` (per-repo
19
+ dependencies/dependents/missing + circular deps + suggested missing repos;
20
+ requires an explicit workspace). `--json` errors are parseable
21
+ `{ ok:false, error }` on stdout + exit 1, consistent with 0.2.11.
22
+
23
+ ## [0.2.11] - 2026-08-27
24
+
25
+ ### Added
26
+
27
+ - **`--json` output** for the read-only reporting commands `list`, `status`, and
28
+ `doctor`. Emits exactly one JSON document to stdout (no table, no interactive
29
+ prompt), so `nemus list --json | jq …` and CI/scripts get stable,
30
+ machine-readable output. `status --json` / `doctor --json` require an explicit
31
+ workspace name (they never prompt).
32
+
33
+ ### Changed
34
+
35
+ - **stdout/stderr hygiene.** Diagnostic logs (info/success/error/warning/step)
36
+ now go to **stderr**, leaving **stdout** for a command's actual data. This is
37
+ what makes `--json` pipe cleanly and lets non-TTY consumers separate data from
38
+ progress. Human-facing table/plain output is unchanged on stdout.
39
+
10
40
  ## [0.2.10] - 2026-08-27
11
41
 
12
42
  ### Security
@@ -59,6 +89,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
59
89
  (P4) a vendor-neutral **notification seam** — `Notifier` with Slack (incoming
60
90
  webhook) + generic webhook sinks (`notifierFromEnv`), wired into the CI-loop as
61
91
  optional out-of-band report-back.
92
+ **Code-host breadth:** the `GitForge` seam gained a dependency-free **GitLab**
93
+ implementation (`GitLabForge` — merge requests, commit statuses, MR notes;
94
+ self-managed via `GITLAB_API_URL`) alongside GitHub, plus a **forge registry**
95
+ (`createForge`/`registerForge`/`registeredForges` + `NEMUS_FORGE_HOST`) so a
96
+ run targets GitHub or GitLab — or a custom "bring your own backend" host
97
+ (Gitea, Bitbucket, …) — with no code change. Docs in `packages/cloud/README.md`.
98
+ **CI-loop + notifier wired into the agent image:** a second container entry
99
+ mode `NEMUS_MODE=fix-pr` (`runFixPr`/`parseFixPrEnv`, CLI `nemus-cloud fix-pr
100
+ --repo --pr --branch`) drives an *existing* PR to green with the bounded
101
+ CI-loop (P3) + optional Slack/webhook notifications (P4) — clone, checkout PR
102
+ head, `runCiLoop`, then write the same versioned `result.json` with
103
+ `mode: 'fix-pr'` + a compact `ci` summary. So P3+P4 are usable end-to-end in a
104
+ container, on GitHub or GitLab.
62
105
  Design: `docs/plans/2026-08-26-cloud-iac.md`.
63
106
 
64
107
  ## [0.2.9] - 2026-08-26
package/README.md CHANGED
@@ -223,6 +223,26 @@ web feature/x ⚠ 2 modified ↑1 2 files
223
223
  shared-lib main ✓ Clean ↓3 -
224
224
  ```
225
225
 
226
+ ### Scripting: `--json`
227
+
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:
232
+
233
+ ```bash
234
+ nemus list --json | jq -r '.workspaces[].name'
235
+ nemus status my-workspace --json | jq '.clean'
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'
240
+ ```
241
+
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
+
226
246
  ### Suites (reusable repo collections)
227
247
 
228
248
  ```bash
@@ -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
  }
@@ -39,6 +39,7 @@ const config_1 = require("../utils/config");
39
39
  const workspace_meta_1 = require("../utils/workspace-meta");
40
40
  const health_checks_1 = require("../utils/health-checks");
41
41
  const logger_1 = require("../utils/logger");
42
+ const output_1 = require("../utils/output");
42
43
  const colors_1 = require("../utils/colors");
43
44
  const command_helpers_1 = require("../utils/command-helpers");
44
45
  const displayHealthCheck = (result) => {
@@ -96,19 +97,42 @@ function registerDoctorCommand(parent) {
96
97
  .alias('doc')
97
98
  .description('Run comprehensive health checks')
98
99
  .argument('[workspace]', 'Workspace name')
99
- .action(async (workspace) => {
100
- await handleDoctor(workspace);
100
+ .option('--json', 'Output as JSON')
101
+ .action(async (workspace, opts) => {
102
+ await handleDoctor(workspace, opts);
101
103
  });
102
104
  }
103
- async function handleDoctor(workspaceArg) {
105
+ async function handleDoctor(workspaceArg, opts = {}) {
106
+ // In --json mode, failures are parseable JSON on stdout + exit 1; otherwise a
107
+ // human log on stderr. `process.exit(1)` stays the last statement so TS still
108
+ // narrows `metadata` to non-null below.
104
109
  try {
110
+ // JSON mode is non-interactive: require an explicit workspace rather than prompt.
111
+ if (opts.json && !workspaceArg) {
112
+ (0, output_1.outputJsonError)('doctor --json requires a workspace name');
113
+ process.exit(1);
114
+ }
105
115
  const selectedWorkspace = await (0, command_helpers_1.resolveWorkspace)(workspaceArg);
106
116
  const workspacePath = path.join(config_1.WORKSPACES_DIR, selectedWorkspace);
107
117
  const metadata = await (0, workspace_meta_1.loadMetadata)(workspacePath);
108
118
  if (!metadata) {
109
- (0, logger_1.logError)(`Workspace metadata not found for: ${selectedWorkspace}`);
119
+ if (opts.json)
120
+ (0, output_1.outputJsonError)(`Workspace metadata not found for: ${selectedWorkspace}`);
121
+ else
122
+ (0, logger_1.logError)(`Workspace metadata not found for: ${selectedWorkspace}`);
110
123
  process.exit(1);
111
124
  }
125
+ if (opts.json) {
126
+ const results = await (0, health_checks_1.runAllHealthChecks)(workspacePath, metadata);
127
+ const score = (0, health_checks_1.calculateHealthScore)(results);
128
+ const overall = results.some(r => r.status === 'error')
129
+ ? 'error'
130
+ : results.some(r => r.status === 'warning')
131
+ ? 'warning'
132
+ : 'healthy';
133
+ (0, output_1.outputJson)({ workspace: selectedWorkspace, score, status: overall, checks: results });
134
+ return;
135
+ }
112
136
  (0, logger_1.logStep)(`Running health checks for workspace: ${(0, colors_1.colorize)(selectedWorkspace, 'cyan')}`);
113
137
  (0, logger_1.logInfo)('This may take a moment...');
114
138
  const results = await (0, health_checks_1.runAllHealthChecks)(workspacePath, metadata);
@@ -132,9 +156,13 @@ async function handleDoctor(workspaceArg) {
132
156
  }
133
157
  }
134
158
  catch (error) {
135
- (0, logger_1.logError)('Failed to run health checks');
136
- if (error instanceof Error) {
137
- (0, logger_1.logError)(error.message);
159
+ if (opts.json) {
160
+ (0, output_1.outputJsonError)(error instanceof Error ? error.message : 'Failed to run health checks');
161
+ }
162
+ else {
163
+ (0, logger_1.logError)('Failed to run health checks');
164
+ if (error instanceof Error)
165
+ (0, logger_1.logError)(error.message);
138
166
  }
139
167
  process.exit(1);
140
168
  }
@@ -44,6 +44,7 @@ const fsPromises = __importStar(require("fs/promises"));
44
44
  const workspace_meta_1 = require("../utils/workspace-meta");
45
45
  const claude_sessions_1 = require("../utils/claude-sessions");
46
46
  const logger_1 = require("../utils/logger");
47
+ const output_1 = require("../utils/output");
47
48
  const colors_1 = require("../utils/colors");
48
49
  const inquirer_1 = __importDefault(require("inquirer"));
49
50
  const inquirer_autocomplete_prompt_1 = __importDefault(require("inquirer-autocomplete-prompt"));
@@ -57,6 +58,7 @@ function registerListCommand(parent) {
57
58
  .alias('l')
58
59
  .description('List workspaces and navigate to one')
59
60
  .option('-a, --archived', 'Show archived workspaces')
61
+ .option('--json', 'Output as JSON (no interactive selection)')
60
62
  .action(async (opts) => {
61
63
  await handleList(opts);
62
64
  });
@@ -72,6 +74,10 @@ async function handleList(opts) {
72
74
  (0, claude_sessions_1.getWorkspaceSessions)(),
73
75
  ]);
74
76
  if (workspaces.length === 0) {
77
+ if (opts.json) {
78
+ (0, output_1.outputJson)({ archived: showArchived, count: 0, workspaces: [] });
79
+ return;
80
+ }
75
81
  console.log('\n' + '='.repeat(60));
76
82
  console.log((0, colors_1.colorize)(title, 'bright'));
77
83
  console.log('='.repeat(60) + '\n');
@@ -108,6 +114,22 @@ async function handleList(opts) {
108
114
  return 1;
109
115
  return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
110
116
  });
117
+ // JSON mode: one document to stdout, no table, no interactive selection.
118
+ if (opts.json) {
119
+ (0, output_1.outputJson)({
120
+ archived: showArchived,
121
+ count: items.length,
122
+ workspaces: items.map(i => ({
123
+ name: i.name,
124
+ path: i.wsPath,
125
+ repoCount: i.repoCount,
126
+ createdAt: i.createdAt || null,
127
+ lastActive: i.lastActiveLabel,
128
+ hasSession: i.hasSession,
129
+ })),
130
+ });
131
+ return;
132
+ }
111
133
  console.log('');
112
134
  console.log((0, colors_1.colorize)(' ' + title, 'bright') + (0, colors_1.colorize)(' (sorted by last active)', 'dim'));
113
135
  console.log((0, colors_1.colorize)(' ' + '─'.repeat(54), 'dim'));
@@ -187,5 +209,6 @@ async function handleList(opts) {
187
209
  async function main() {
188
210
  const args = process.argv.slice(2);
189
211
  const archived = args.includes('--archived') || args.includes('-a');
190
- await handleList({ archived });
212
+ const json = args.includes('--json');
213
+ await handleList({ archived, json });
191
214
  }
@@ -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
  }
@@ -39,6 +39,7 @@ const config_1 = require("../utils/config");
39
39
  const workspace_meta_1 = require("../utils/workspace-meta");
40
40
  const git_status_1 = require("../utils/git-status");
41
41
  const logger_1 = require("../utils/logger");
42
+ const output_1 = require("../utils/output");
42
43
  const colors_1 = require("../utils/colors");
43
44
  const command_helpers_1 = require("../utils/command-helpers");
44
45
  const displayStatusTable = (statuses) => {
@@ -109,29 +110,57 @@ function registerStatusCommand(parent) {
109
110
  .alias('st')
110
111
  .description('Show git status across all repos')
111
112
  .argument('[workspace]', 'Workspace name')
112
- .action(async (workspace) => {
113
- await handleStatus(workspace);
113
+ .option('--json', 'Output as JSON')
114
+ .action(async (workspace, opts) => {
115
+ await handleStatus(workspace, opts);
114
116
  });
115
117
  }
116
- async function handleStatus(workspaceArg) {
118
+ async function handleStatus(workspaceArg, opts = {}) {
119
+ // In --json mode, failures are parseable JSON on stdout + exit 1; otherwise a
120
+ // human log on stderr. `process.exit(1)` stays the last statement so TS still
121
+ // narrows `metadata` to non-null below.
117
122
  try {
123
+ // JSON mode is non-interactive: require an explicit workspace rather than prompt.
124
+ if (opts.json && !workspaceArg) {
125
+ (0, output_1.outputJsonError)('status --json requires a workspace name');
126
+ process.exit(1);
127
+ }
118
128
  const selectedWorkspace = await (0, command_helpers_1.resolveWorkspace)(workspaceArg);
119
129
  const workspacePath = path.join(config_1.WORKSPACES_DIR, selectedWorkspace);
120
130
  const metadata = await (0, workspace_meta_1.loadMetadata)(workspacePath);
121
131
  if (!metadata) {
122
- (0, logger_1.logError)(`Workspace metadata not found for: ${selectedWorkspace}`);
132
+ if (opts.json)
133
+ (0, output_1.outputJsonError)(`Workspace metadata not found for: ${selectedWorkspace}`);
134
+ else
135
+ (0, logger_1.logError)(`Workspace metadata not found for: ${selectedWorkspace}`);
123
136
  process.exit(1);
124
137
  }
138
+ const repoDirectoryNames = metadata.repositories.map(r => r.directoryName);
139
+ if (opts.json) {
140
+ const statuses = await (0, git_status_1.getAllReposStatus)(workspacePath, repoDirectoryNames, 3);
141
+ (0, output_1.outputJson)({
142
+ workspace: selectedWorkspace,
143
+ path: workspacePath,
144
+ repoCount: statuses.length,
145
+ clean: statuses.every(s => s.clean),
146
+ repositories: statuses,
147
+ });
148
+ return;
149
+ }
125
150
  (0, logger_1.logStep)(`Checking status for workspace: ${(0, colors_1.colorize)(selectedWorkspace, 'cyan')}`);
126
151
  (0, logger_1.logInfo)(`Found ${metadata.repositories.length} repositories`);
127
- const repoDirectoryNames = metadata.repositories.map(r => r.directoryName);
128
152
  const statuses = await (0, git_status_1.getAllReposStatus)(workspacePath, repoDirectoryNames, 3);
129
153
  displayStatusTable(statuses);
130
154
  }
131
155
  catch (error) {
132
- (0, logger_1.logError)('Failed to check workspace status');
133
- if (error instanceof Error) {
134
- (0, logger_1.logError)(error.message);
156
+ const msg = error instanceof Error ? error.message : 'Failed to check workspace status';
157
+ if (opts.json) {
158
+ (0, output_1.outputJsonError)(msg);
159
+ }
160
+ else {
161
+ (0, logger_1.logError)('Failed to check workspace status');
162
+ if (error instanceof Error)
163
+ (0, logger_1.logError)(error.message);
135
164
  }
136
165
  process.exit(1);
137
166
  }
@@ -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
  }
@@ -2,6 +2,10 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.logStep = exports.logWarning = exports.logError = exports.logSuccess = exports.logInfo = void 0;
4
4
  const colors_1 = require("./colors");
5
+ // Diagnostics (info/success/error/warn/step) go to STDERR so stdout carries only
6
+ // a command's actual data — required for clean `--json` piping (nemus list
7
+ // --json | jq …) and for non-TTY consumers. Use `outputJson`/stdout for data.
8
+ const logStream = (line) => console.error(line);
5
9
  const getTimestamp = () => {
6
10
  const now = new Date();
7
11
  return now.toLocaleTimeString('en-US', {
@@ -12,29 +16,29 @@ const getTimestamp = () => {
12
16
  });
13
17
  };
14
18
  const logInfo = (message) => {
15
- console.log(`${colors_1.colors.gray}[${getTimestamp()}]${colors_1.colors.reset} ${message}`);
19
+ logStream(`${colors_1.colors.gray}[${getTimestamp()}]${colors_1.colors.reset} ${message}`);
16
20
  };
17
21
  exports.logInfo = logInfo;
18
22
  const logSuccess = (message) => {
19
- console.log(`${colors_1.colors.gray}[${getTimestamp()}]${colors_1.colors.reset} ${(0, colors_1.colorize)('✓', 'green')} ${message}`);
23
+ logStream(`${colors_1.colors.gray}[${getTimestamp()}]${colors_1.colors.reset} ${(0, colors_1.colorize)('✓', 'green')} ${message}`);
20
24
  };
21
25
  exports.logSuccess = logSuccess;
22
26
  const logError = (message) => {
23
- console.log(`${colors_1.colors.gray}[${getTimestamp()}]${colors_1.colors.reset} ${(0, colors_1.colorize)('✗', 'red')} ${message}`);
27
+ logStream(`${colors_1.colors.gray}[${getTimestamp()}]${colors_1.colors.reset} ${(0, colors_1.colorize)('✗', 'red')} ${message}`);
24
28
  };
25
29
  exports.logError = logError;
26
30
  const logWarning = (message) => {
27
- console.log(`${colors_1.colors.gray}[${getTimestamp()}]${colors_1.colors.reset} ${(0, colors_1.colorize)('⚠', 'yellow')} ${message}`);
31
+ logStream(`${colors_1.colors.gray}[${getTimestamp()}]${colors_1.colors.reset} ${(0, colors_1.colorize)('⚠', 'yellow')} ${message}`);
28
32
  };
29
33
  exports.logWarning = logWarning;
30
34
  const logStep = (stepOrMessage, total, message) => {
31
35
  if (typeof stepOrMessage === 'string') {
32
36
  // Single parameter version: just a message
33
- console.log(`${colors_1.colors.gray}[${getTimestamp()}]${colors_1.colors.reset} ${(0, colors_1.colorize)('▸', 'cyan')} ${stepOrMessage}`);
37
+ logStream(`${colors_1.colors.gray}[${getTimestamp()}]${colors_1.colors.reset} ${(0, colors_1.colorize)('▸', 'cyan')} ${stepOrMessage}`);
34
38
  }
35
39
  else {
36
40
  // Three parameter version: step, total, message
37
- console.log(`${colors_1.colors.gray}[${getTimestamp()}]${colors_1.colors.reset} ${(0, colors_1.colorize)(`[${stepOrMessage}/${total}]`, 'cyan')} ${message}`);
41
+ logStream(`${colors_1.colors.gray}[${getTimestamp()}]${colors_1.colors.reset} ${(0, colors_1.colorize)(`[${stepOrMessage}/${total}]`, 'cyan')} ${message}`);
38
42
  }
39
43
  };
40
44
  exports.logStep = logStep;
@@ -0,0 +1,23 @@
1
+ "use strict";
2
+ /**
3
+ * Machine-readable output helpers. The one rule: a command's DATA goes to
4
+ * stdout, diagnostics go to stderr (see logger.ts). In `--json` mode a command
5
+ * writes exactly one JSON document to stdout and nothing else, so it pipes
6
+ * cleanly into `jq` and is safe for scripts/CI.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.outputJson = outputJson;
10
+ exports.outputJsonError = outputJsonError;
11
+ /** Write one pretty-printed JSON document to stdout (data channel). */
12
+ function outputJson(data) {
13
+ process.stdout.write(JSON.stringify(data, null, 2) + '\n');
14
+ }
15
+ /**
16
+ * Emit a structured error as JSON to stdout for `--json` callers, so a script
17
+ * parsing stdout always gets a parseable object (`{ ok: false, error }`) rather
18
+ * than empty stdout + a human log line on stderr. The caller still signals
19
+ * failure with a non-zero exit code.
20
+ */
21
+ function outputJsonError(message) {
22
+ outputJson({ ok: false, error: message });
23
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nemus-cli/nemus",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
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
  }
@@ -4,6 +4,7 @@ import { WORKSPACES_DIR } from '../utils/config';
4
4
  import { loadMetadata } from '../utils/workspace-meta';
5
5
  import { runAllHealthChecks, calculateHealthScore } from '../utils/health-checks';
6
6
  import { logError, logInfo, logStep, logSuccess, logWarning } from '../utils/logger';
7
+ import { outputJson, outputJsonError } from '../utils/output';
7
8
  import { colorize } from '../utils/colors';
8
9
  import { HealthCheckResult } from '../types';
9
10
  import { resolveWorkspace } from '../utils/command-helpers';
@@ -59,22 +60,44 @@ export function registerDoctorCommand(parent: Command) {
59
60
  .alias('doc')
60
61
  .description('Run comprehensive health checks')
61
62
  .argument('[workspace]', 'Workspace name')
62
- .action(async (workspace) => {
63
- await handleDoctor(workspace);
63
+ .option('--json', 'Output as JSON')
64
+ .action(async (workspace, opts) => {
65
+ await handleDoctor(workspace, opts);
64
66
  });
65
67
  }
66
68
 
67
- async function handleDoctor(workspaceArg?: string) {
69
+ async function handleDoctor(workspaceArg?: string, opts: { json?: boolean } = {}) {
70
+ // In --json mode, failures are parseable JSON on stdout + exit 1; otherwise a
71
+ // human log on stderr. `process.exit(1)` stays the last statement so TS still
72
+ // narrows `metadata` to non-null below.
68
73
  try {
74
+ // JSON mode is non-interactive: require an explicit workspace rather than prompt.
75
+ if (opts.json && !workspaceArg) {
76
+ outputJsonError('doctor --json requires a workspace name');
77
+ process.exit(1);
78
+ }
69
79
  const selectedWorkspace = await resolveWorkspace(workspaceArg);
70
80
  const workspacePath = path.join(WORKSPACES_DIR, selectedWorkspace);
71
81
  const metadata = await loadMetadata(workspacePath);
72
82
 
73
83
  if (!metadata) {
74
- logError(`Workspace metadata not found for: ${selectedWorkspace}`);
84
+ if (opts.json) outputJsonError(`Workspace metadata not found for: ${selectedWorkspace}`);
85
+ else logError(`Workspace metadata not found for: ${selectedWorkspace}`);
75
86
  process.exit(1);
76
87
  }
77
88
 
89
+ if (opts.json) {
90
+ const results = await runAllHealthChecks(workspacePath, metadata);
91
+ const score = calculateHealthScore(results);
92
+ const overall = results.some(r => r.status === 'error')
93
+ ? 'error'
94
+ : results.some(r => r.status === 'warning')
95
+ ? 'warning'
96
+ : 'healthy';
97
+ outputJson({ workspace: selectedWorkspace, score, status: overall, checks: results });
98
+ return;
99
+ }
100
+
78
101
  logStep(`Running health checks for workspace: ${colorize(selectedWorkspace, 'cyan')}`);
79
102
  logInfo('This may take a moment...');
80
103
 
@@ -101,9 +124,11 @@ async function handleDoctor(workspaceArg?: string) {
101
124
  logSuccess('All health checks passed! Your workspace is in good shape.');
102
125
  }
103
126
  } catch (error) {
104
- logError('Failed to run health checks');
105
- if (error instanceof Error) {
106
- logError(error.message);
127
+ if (opts.json) {
128
+ outputJsonError(error instanceof Error ? error.message : 'Failed to run health checks');
129
+ } else {
130
+ logError('Failed to run health checks');
131
+ if (error instanceof Error) logError(error.message);
107
132
  }
108
133
  process.exit(1);
109
134
  }
@@ -196,6 +196,36 @@ describe('list-workspaces main', () => {
196
196
  expect(messages).toContainEqual(expect.stringContaining('ws-a'));
197
197
  });
198
198
 
199
+ it('--json: writes one valid JSON document to stdout and does not prompt', async () => {
200
+ const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
201
+ process.argv = ['node', 'list-workspaces.js', '--json'];
202
+ mockListWorkspaces.mockResolvedValueOnce(makeWorkspaceList('ws-a', 'ws-b'));
203
+
204
+ await main();
205
+
206
+ expect(mockPrompt).not.toHaveBeenCalled();
207
+ expect(writeSpy).toHaveBeenCalledTimes(1);
208
+ const payload = JSON.parse(writeSpy.mock.calls[0][0] as string);
209
+ expect(payload.count).toBe(2);
210
+ expect(payload.workspaces.map((w: any) => w.name).sort()).toEqual(['ws-a', 'ws-b']);
211
+ expect(payload.workspaces[0]).toMatchObject({ repoCount: 1, hasSession: false });
212
+ writeSpy.mockRestore();
213
+ });
214
+
215
+ it('--json: empty list emits count 0, no prompt, no log noise', async () => {
216
+ const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
217
+ process.argv = ['node', 'list-workspaces.js', '--json'];
218
+ mockListWorkspaces.mockResolvedValueOnce([]);
219
+
220
+ await main();
221
+
222
+ expect(logInfo).not.toHaveBeenCalled();
223
+ expect(mockPrompt).not.toHaveBeenCalled();
224
+ const payload = JSON.parse(writeSpy.mock.calls[0][0] as string);
225
+ expect(payload).toEqual({ archived: false, count: 0, workspaces: [] });
226
+ writeSpy.mockRestore();
227
+ });
228
+
199
229
  it('sorts workspaces with sessions before those without', async () => {
200
230
  mockListWorkspaces.mockResolvedValueOnce(makeWorkspaceList('no-session', 'has-session'));
201
231
  mockGetWorkspaceSessions.mockResolvedValueOnce([{
@@ -5,6 +5,7 @@ import * as fsPromises from 'fs/promises';
5
5
  import { listWorkspaces } from '../utils/workspace-meta';
6
6
  import { getWorkspaceSessions } from '../utils/claude-sessions';
7
7
  import { logInfo, logError } from '../utils/logger';
8
+ import { outputJson } from '../utils/output';
8
9
  import { colorize } from '../utils/colors';
9
10
  import inquirer from 'inquirer';
10
11
  import autocompletePrompt from 'inquirer-autocomplete-prompt';
@@ -31,12 +32,13 @@ export function registerListCommand(parent: Command) {
31
32
  .alias('l')
32
33
  .description('List workspaces and navigate to one')
33
34
  .option('-a, --archived', 'Show archived workspaces')
35
+ .option('--json', 'Output as JSON (no interactive selection)')
34
36
  .action(async (opts) => {
35
37
  await handleList(opts);
36
38
  });
37
39
  }
38
40
 
39
- async function handleList(opts: { archived?: boolean }) {
41
+ async function handleList(opts: { archived?: boolean; json?: boolean }) {
40
42
  const showArchived = opts.archived ?? false;
41
43
  const title = showArchived ? 'Archived Workspaces' : 'Existing Workspaces';
42
44
 
@@ -49,6 +51,10 @@ async function handleList(opts: { archived?: boolean }) {
49
51
  ]);
50
52
 
51
53
  if (workspaces.length === 0) {
54
+ if (opts.json) {
55
+ outputJson({ archived: showArchived, count: 0, workspaces: [] });
56
+ return;
57
+ }
52
58
  console.log('\n' + '='.repeat(60));
53
59
  console.log(colorize(title, 'bright'));
54
60
  console.log('='.repeat(60) + '\n');
@@ -85,6 +91,23 @@ async function handleList(opts: { archived?: boolean }) {
85
91
  return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
86
92
  });
87
93
 
94
+ // JSON mode: one document to stdout, no table, no interactive selection.
95
+ if (opts.json) {
96
+ outputJson({
97
+ archived: showArchived,
98
+ count: items.length,
99
+ workspaces: items.map(i => ({
100
+ name: i.name,
101
+ path: i.wsPath,
102
+ repoCount: i.repoCount,
103
+ createdAt: i.createdAt || null,
104
+ lastActive: i.lastActiveLabel,
105
+ hasSession: i.hasSession,
106
+ })),
107
+ });
108
+ return;
109
+ }
110
+
88
111
  console.log('');
89
112
  console.log(colorize(' ' + title, 'bright') + colorize(' (sorted by last active)', 'dim'));
90
113
  console.log(colorize(' ' + '─'.repeat(54), 'dim'));
@@ -172,5 +195,6 @@ async function handleList(opts: { archived?: boolean }) {
172
195
  export async function main() {
173
196
  const args = process.argv.slice(2);
174
197
  const archived = args.includes('--archived') || args.includes('-a');
175
- await handleList({ archived });
198
+ const json = args.includes('--json');
199
+ await handleList({ archived, json });
176
200
  }
@@ -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
  }
@@ -4,6 +4,7 @@ import { WORKSPACES_DIR } from '../utils/config';
4
4
  import { loadMetadata } from '../utils/workspace-meta';
5
5
  import { getAllReposStatus } from '../utils/git-status';
6
6
  import { logError, logInfo, logStep } from '../utils/logger';
7
+ import { outputJson, outputJsonError } from '../utils/output';
7
8
  import { colorize } from '../utils/colors';
8
9
  import { resolveWorkspace } from '../utils/command-helpers';
9
10
 
@@ -78,33 +79,59 @@ export function registerStatusCommand(parent: Command) {
78
79
  .alias('st')
79
80
  .description('Show git status across all repos')
80
81
  .argument('[workspace]', 'Workspace name')
81
- .action(async (workspace) => {
82
- await handleStatus(workspace);
82
+ .option('--json', 'Output as JSON')
83
+ .action(async (workspace, opts) => {
84
+ await handleStatus(workspace, opts);
83
85
  });
84
86
  }
85
87
 
86
- async function handleStatus(workspaceArg?: string) {
88
+ async function handleStatus(workspaceArg?: string, opts: { json?: boolean } = {}) {
89
+ // In --json mode, failures are parseable JSON on stdout + exit 1; otherwise a
90
+ // human log on stderr. `process.exit(1)` stays the last statement so TS still
91
+ // narrows `metadata` to non-null below.
87
92
  try {
93
+ // JSON mode is non-interactive: require an explicit workspace rather than prompt.
94
+ if (opts.json && !workspaceArg) {
95
+ outputJsonError('status --json requires a workspace name');
96
+ process.exit(1);
97
+ }
88
98
  const selectedWorkspace = await resolveWorkspace(workspaceArg);
89
99
  const workspacePath = path.join(WORKSPACES_DIR, selectedWorkspace);
90
100
  const metadata = await loadMetadata(workspacePath);
91
101
 
92
102
  if (!metadata) {
93
- logError(`Workspace metadata not found for: ${selectedWorkspace}`);
103
+ if (opts.json) outputJsonError(`Workspace metadata not found for: ${selectedWorkspace}`);
104
+ else logError(`Workspace metadata not found for: ${selectedWorkspace}`);
94
105
  process.exit(1);
95
106
  }
96
107
 
108
+ const repoDirectoryNames = metadata.repositories.map(r => r.directoryName);
109
+
110
+ if (opts.json) {
111
+ const statuses = await getAllReposStatus(workspacePath, repoDirectoryNames, 3);
112
+ outputJson({
113
+ workspace: selectedWorkspace,
114
+ path: workspacePath,
115
+ repoCount: statuses.length,
116
+ clean: statuses.every(s => s.clean),
117
+ repositories: statuses,
118
+ });
119
+ return;
120
+ }
121
+
97
122
  logStep(`Checking status for workspace: ${colorize(selectedWorkspace, 'cyan')}`);
98
123
  logInfo(`Found ${metadata.repositories.length} repositories`);
99
124
 
100
- const repoDirectoryNames = metadata.repositories.map(r => r.directoryName);
101
125
  const statuses = await getAllReposStatus(workspacePath, repoDirectoryNames, 3);
102
126
 
103
127
  displayStatusTable(statuses);
104
128
  } catch (error) {
105
- logError('Failed to check workspace status');
106
- if (error instanceof Error) {
107
- logError(error.message);
129
+ const msg = error instanceof Error ? error.message : 'Failed to check workspace status';
130
+ if (opts.json) {
131
+ outputJsonError(msg);
132
+ } else {
133
+ logError('Failed to check workspace status');
134
+ if (error instanceof Error) logError(error.message);
108
135
  }
109
136
  process.exit(1);
110
137
  }
@@ -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
  }
@@ -1,5 +1,10 @@
1
1
  import { colors, colorize } from './colors';
2
2
 
3
+ // Diagnostics (info/success/error/warn/step) go to STDERR so stdout carries only
4
+ // a command's actual data — required for clean `--json` piping (nemus list
5
+ // --json | jq …) and for non-TTY consumers. Use `outputJson`/stdout for data.
6
+ const logStream = (line: string): void => console.error(line);
7
+
3
8
  const getTimestamp = (): string => {
4
9
  const now = new Date();
5
10
  return now.toLocaleTimeString('en-US', {
@@ -11,27 +16,27 @@ const getTimestamp = (): string => {
11
16
  };
12
17
 
13
18
  export const logInfo = (message: string): void => {
14
- console.log(`${colors.gray}[${getTimestamp()}]${colors.reset} ${message}`);
19
+ logStream(`${colors.gray}[${getTimestamp()}]${colors.reset} ${message}`);
15
20
  };
16
21
 
17
22
  export const logSuccess = (message: string): void => {
18
- console.log(`${colors.gray}[${getTimestamp()}]${colors.reset} ${colorize('✓', 'green')} ${message}`);
23
+ logStream(`${colors.gray}[${getTimestamp()}]${colors.reset} ${colorize('✓', 'green')} ${message}`);
19
24
  };
20
25
 
21
26
  export const logError = (message: string): void => {
22
- console.log(`${colors.gray}[${getTimestamp()}]${colors.reset} ${colorize('✗', 'red')} ${message}`);
27
+ logStream(`${colors.gray}[${getTimestamp()}]${colors.reset} ${colorize('✗', 'red')} ${message}`);
23
28
  };
24
29
 
25
30
  export const logWarning = (message: string): void => {
26
- console.log(`${colors.gray}[${getTimestamp()}]${colors.reset} ${colorize('⚠', 'yellow')} ${message}`);
31
+ logStream(`${colors.gray}[${getTimestamp()}]${colors.reset} ${colorize('⚠', 'yellow')} ${message}`);
27
32
  };
28
33
 
29
34
  export const logStep = (stepOrMessage: number | string, total?: number, message?: string): void => {
30
35
  if (typeof stepOrMessage === 'string') {
31
36
  // Single parameter version: just a message
32
- console.log(`${colors.gray}[${getTimestamp()}]${colors.reset} ${colorize('▸', 'cyan')} ${stepOrMessage}`);
37
+ logStream(`${colors.gray}[${getTimestamp()}]${colors.reset} ${colorize('▸', 'cyan')} ${stepOrMessage}`);
33
38
  } else {
34
39
  // Three parameter version: step, total, message
35
- console.log(`${colors.gray}[${getTimestamp()}]${colors.reset} ${colorize(`[${stepOrMessage}/${total}]`, 'cyan')} ${message}`);
40
+ logStream(`${colors.gray}[${getTimestamp()}]${colors.reset} ${colorize(`[${stepOrMessage}/${total}]`, 'cyan')} ${message}`);
36
41
  }
37
42
  };
@@ -0,0 +1,36 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import { outputJson, outputJsonError } from './output';
3
+
4
+ function captureStdout(): { calls: string[]; restore: () => void } {
5
+ const calls: string[] = [];
6
+ const spy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk: any) => {
7
+ calls.push(String(chunk));
8
+ return true;
9
+ });
10
+ return { calls, restore: () => spy.mockRestore() };
11
+ }
12
+
13
+ describe('outputJson', () => {
14
+ afterEach(() => vi.restoreAllMocks());
15
+
16
+ it('writes exactly one pretty JSON document + trailing newline to stdout', () => {
17
+ const { calls, restore } = captureStdout();
18
+ outputJson({ a: 1, b: ['x', 'y'] });
19
+ restore();
20
+ expect(calls).toHaveLength(1);
21
+ expect(calls[0].endsWith('\n')).toBe(true);
22
+ expect(calls[0]).toBe(JSON.stringify({ a: 1, b: ['x', 'y'] }, null, 2) + '\n');
23
+ expect(JSON.parse(calls[0])).toEqual({ a: 1, b: ['x', 'y'] });
24
+ });
25
+ });
26
+
27
+ describe('outputJsonError', () => {
28
+ afterEach(() => vi.restoreAllMocks());
29
+
30
+ it('emits a parseable { ok:false, error } object to stdout', () => {
31
+ const { calls, restore } = captureStdout();
32
+ outputJsonError('boom');
33
+ restore();
34
+ expect(JSON.parse(calls[0])).toEqual({ ok: false, error: 'boom' });
35
+ });
36
+ });
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Machine-readable output helpers. The one rule: a command's DATA goes to
3
+ * stdout, diagnostics go to stderr (see logger.ts). In `--json` mode a command
4
+ * writes exactly one JSON document to stdout and nothing else, so it pipes
5
+ * cleanly into `jq` and is safe for scripts/CI.
6
+ */
7
+
8
+ /** Write one pretty-printed JSON document to stdout (data channel). */
9
+ export function outputJson(data: unknown): void {
10
+ process.stdout.write(JSON.stringify(data, null, 2) + '\n');
11
+ }
12
+
13
+ /**
14
+ * Emit a structured error as JSON to stdout for `--json` callers, so a script
15
+ * parsing stdout always gets a parseable object (`{ ok: false, error }`) rather
16
+ * than empty stdout + a human log line on stderr. The caller still signals
17
+ * failure with a non-zero exit code.
18
+ */
19
+ export function outputJsonError(message: string): void {
20
+ outputJson({ ok: false, error: message });
21
+ }