@nemus-cli/nemus 0.2.10 → 0.2.11

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,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.11] - 2026-08-27
11
+
12
+ ### Added
13
+
14
+ - **`--json` output** for the read-only reporting commands `list`, `status`, and
15
+ `doctor`. Emits exactly one JSON document to stdout (no table, no interactive
16
+ prompt), so `nemus list --json | jq …` and CI/scripts get stable,
17
+ machine-readable output. `status --json` / `doctor --json` require an explicit
18
+ workspace name (they never prompt).
19
+
20
+ ### Changed
21
+
22
+ - **stdout/stderr hygiene.** Diagnostic logs (info/success/error/warning/step)
23
+ now go to **stderr**, leaving **stdout** for a command's actual data. This is
24
+ what makes `--json` pipe cleanly and lets non-TTY consumers separate data from
25
+ progress. Human-facing table/plain output is unchanged on stdout.
26
+
10
27
  ## [0.2.10] - 2026-08-27
11
28
 
12
29
  ### Security
@@ -59,6 +76,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
59
76
  (P4) a vendor-neutral **notification seam** — `Notifier` with Slack (incoming
60
77
  webhook) + generic webhook sinks (`notifierFromEnv`), wired into the CI-loop as
61
78
  optional out-of-band report-back.
79
+ **Code-host breadth:** the `GitForge` seam gained a dependency-free **GitLab**
80
+ implementation (`GitLabForge` — merge requests, commit statuses, MR notes;
81
+ self-managed via `GITLAB_API_URL`) alongside GitHub, plus a **forge registry**
82
+ (`createForge`/`registerForge`/`registeredForges` + `NEMUS_FORGE_HOST`) so a
83
+ run targets GitHub or GitLab — or a custom "bring your own backend" host
84
+ (Gitea, Bitbucket, …) — with no code change. Docs in `packages/cloud/README.md`.
85
+ **CI-loop + notifier wired into the agent image:** a second container entry
86
+ mode `NEMUS_MODE=fix-pr` (`runFixPr`/`parseFixPrEnv`, CLI `nemus-cloud fix-pr
87
+ --repo --pr --branch`) drives an *existing* PR to green with the bounded
88
+ CI-loop (P3) + optional Slack/webhook notifications (P4) — clone, checkout PR
89
+ head, `runCiLoop`, then write the same versioned `result.json` with
90
+ `mode: 'fix-pr'` + a compact `ci` summary. So P3+P4 are usable end-to-end in a
91
+ container, on GitHub or GitLab.
62
92
  Design: `docs/plans/2026-08-26-cloud-iac.md`.
63
93
 
64
94
  ## [0.2.9] - 2026-08-26
package/README.md CHANGED
@@ -223,6 +223,21 @@ web feature/x ⚠ 2 modified ↑1 2 files
223
223
  shared-lib main ✓ Clean ↓3 -
224
224
  ```
225
225
 
226
+ ### Scripting: `--json`
227
+
228
+ `list`, `status`, and `doctor` accept `--json` for stable, machine-readable
229
+ output. Diagnostics go to stderr, so stdout is a single JSON document you can
230
+ pipe straight into `jq` or a CI step:
231
+
232
+ ```bash
233
+ nemus list --json | jq -r '.workspaces[].name'
234
+ nemus status my-workspace --json | jq '.clean'
235
+ nemus doctor my-workspace --json | jq '.score'
236
+ ```
237
+
238
+ `status`/`doctor` with `--json` need an explicit workspace name (they never
239
+ prompt).
240
+
226
241
  ### Suites (reusable repo collections)
227
242
 
228
243
  ```bash
@@ -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
  }
@@ -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
  }
@@ -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.11",
4
4
  "workspaces": [
5
5
  "packages/*"
6
6
  ],
@@ -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
  }
@@ -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
  }
@@ -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
+ }