@nemus-cli/nemus 0.2.9 → 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,90 @@ 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
+
27
+ ## [0.2.10] - 2026-08-27
28
+
29
+ ### Security
30
+
31
+ - **Eliminated the shell-injection surface in git/shell operations.** Every
32
+ `exec`/`execSync` call that interpolated a value into a shell string was
33
+ migrated to `execFile`/argv (no shell). Most notably, the MCP `branch_create`
34
+ and `switch_branch` tools built `git checkout -b <name>` by unquoted
35
+ interpolation with no branch-name validation, so a crafted (but valid) git ref
36
+ could run arbitrary commands; branch names are now passed as argv elements and
37
+ can never be interpreted by a shell. Also migrated `ghq`/clone, workspace
38
+ cleanup (`du`, `git clean`), disk-usage health checks, agent-availability
39
+ probing, and the shell-integration/MCP installers.
40
+ - **`w delete` no longer deletes arbitrary directories.** The `--workspace` flag
41
+ path validated names through `safeWorkspacePath()` (allowlist + containment)
42
+ and skips unknown/invalid names instead of `fs.rm`-ing a guessed path; the
43
+ interactive path is guarded the same way.
44
+
45
+ ### Added
46
+
47
+ - **Monorepo (npm workspaces).** The repo is now an npm-workspaces monorepo:
48
+ the published CLI stays at the root (`@nemus-cli/nemus`, unchanged) and new
49
+ packages live under `packages/*`.
50
+ - **`@nemus-cli/cloud` (private, P1).** An optional, vendor-neutral cloud/IaC
51
+ package — not required for local Nemus. Two seams so far: the **forge-auth**
52
+ seam (`ForgeTokenSource`) with `pat` and a dependency-free `github-app` source
53
+ that mints least-privilege, auto-refreshing installation tokens; and the
54
+ **execution** seam (`Runner`/`Provisioner` + neutral `TaskSpec`/
55
+ `TargetDescriptor`/`Capabilities` + a name registry) with an in-box
56
+ **Docker runner** (needs no cloud account); a **`SecretSource`** seam
57
+ (`env`/`dotenv`/`gh` + `resolveSecretsToEnv`); and a **`GitForge`** seam
58
+ (`openPR`/`getChecks`/`comment`) with a dependency-free GitHub implementation;
59
+ and the **in-image agent orchestrator** (runner-image env contract, versioned
60
+ `result.json` schema, and `runAgentTask`: clone all → run the agent once over
61
+ the workspace → open a PR per changed repo, with per-repo error isolation);
62
+ the **OCI image + `nemus-cloud-agent` entrypoint** (a Dockerfile with node +
63
+ git + gh + pi/claude); and the **provisioning** seam (P2, in progress): a
64
+ generic `OpenTofuProvisioner` (delegates to `tofu`/`terraform`, maps a module's
65
+ `target` output → `TargetDescriptor`) + registry, plus a first **AWS Fargate**
66
+ OpenTofu module (`iac/fargate/`, validated with real `tofu validate`); and the
67
+ matching **`aws-fargate` Runner** (dependency-free, shells the `aws` CLI:
68
+ `register-task-definition` → `run-task`, `describe-tasks` → status, CloudWatch
69
+ `logs tail` streaming, `stop-task`); and the **`nemus-cloud` CLI** (own bin,
70
+ dependency-free) — `up`/`down` drive the provisioner, `run` launches the agent
71
+ image on a target (`--follow` logs, `--wait` for the exit code). This
72
+ completes P2. **P3 (in progress):** a bounded **CI-loop** (`runCiLoop`) that
73
+ drives a PR to green over `GitForge` — poll checks, run a fix pass on failure,
74
+ commit/push, repeat; anti-runaway guards (max iterations, no-change → stuck,
75
+ poll-budget → timeout) + a best-effort "needs a human" give-up comment; and
76
+ (P4) a vendor-neutral **notification seam** — `Notifier` with Slack (incoming
77
+ webhook) + generic webhook sinks (`notifierFromEnv`), wired into the CI-loop as
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.
92
+ Design: `docs/plans/2026-08-26-cloud-iac.md`.
93
+
10
94
  ## [0.2.9] - 2026-08-26
11
95
 
12
96
  ### Fixed
package/README.md CHANGED
@@ -3,46 +3,82 @@
3
3
  </p>
4
4
 
5
5
  <p align="center">
6
- <b>Multi-repo workspaces for the AI-agent era.</b><br/>
7
- Create, sync, and operate across dozens of Git repositories with a single command
8
- and wire them up to your favorite coding agent.
6
+ <b>Work on many Git repos as if they were one project.</b><br/>
7
+ Nemus pulls the repos you're working on into a single folder, runs commands across
8
+ all of them at once, and gives your AI coding agent the whole picture.
9
9
  </p>
10
10
 
11
11
  <p align="center">
12
+ <a href="https://me-public.github.io/nemus/"><img src="https://img.shields.io/badge/website-nemus-3FB950.svg" alt="Website" /></a>
12
13
  <a href="https://github.com/me-public/nemus/actions/workflows/ci.yml"><img src="https://github.com/me-public/nemus/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
13
14
  <a href="https://www.npmjs.com/package/@nemus-cli/nemus"><img src="https://img.shields.io/npm/v/@nemus-cli/nemus.svg" alt="npm" /></a>
14
15
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License: MIT" /></a>
15
16
  <a href="https://www.npmjs.com/package/@nemus-cli/nemus"><img src="https://img.shields.io/npm/dm/@nemus-cli/nemus.svg" alt="npm downloads" /></a>
16
17
  <img src="https://img.shields.io/badge/node-%3E%3D22-brightgreen.svg" alt="Node >= 22" />
18
+ <a href="https://x.com/nemus_cli"><img src="https://img.shields.io/badge/follow-%40nemus__cli-000000.svg?logo=x&logoColor=white" alt="Follow @nemus_cli on X" /></a>
17
19
  </p>
18
20
 
19
21
  <p align="center">
20
- 📦 <b><a href="https://www.npmjs.com/package/@nemus-cli/nemus">@nemus-cli/nemus</a></b> on npm &nbsp;—&nbsp; <code>npm install -g @nemus-cli/nemus</code>
22
+ 🌐 <b><a href="https://me-public.github.io/nemus/">me-public.github.io/nemus</a></b> &nbsp;—&nbsp; 📦 <b><a href="https://www.npmjs.com/package/@nemus-cli/nemus">@nemus-cli/nemus</a></b> on npm &nbsp;—&nbsp; 𝕏 <b><a href="https://x.com/nemus_cli">@nemus_cli</a></b> &nbsp;—&nbsp; <code>npm install -g @nemus-cli/nemus</code>
21
23
  </p>
22
24
 
25
+ <p align="center">
26
+ <a href="https://raw.githubusercontent.com/me-public/nemus/main/docs/assets/nemus-cli-demo.mp4"><img src="https://raw.githubusercontent.com/me-public/nemus/main/docs/assets/nemus-cli-demo.gif" alt="Nemus terminal demo — describe a workspace in plain English, Nemus clones the repos and writes the agent context, then run across every repo at once" width="820" /></a>
27
+ </p>
28
+ <p align="center"><sub>A real recording — <code>nemus -- "…"</code> turns a sentence into a ready workspace. <a href="https://raw.githubusercontent.com/me-public/nemus/main/docs/assets/nemus-cli-demo.mp4">▶ watch as MP4</a></sub></p>
29
+
23
30
  ---
24
31
 
25
- ## The name
32
+ ## What is Nemus?
26
33
 
27
- **Nemus** (_NEH-mus_) is Latin for a **grove** a small wood of trees sharing
28
- soil and roots. It's a fitting picture of what the tool manages: a cluster of
29
- repositories, each its own tree, growing together in one workspace. It also nods
30
- to the branch-and-commit shape of Git itself.
34
+ Your product's code is probably split across **many separate Git repos** frontend,
35
+ backend, auth, payments, shared libraries. Working on one feature means cloning,
36
+ pulling, branching, and testing each repo by hand, one at a time and your AI coding
37
+ agent only ever sees the single repo you happened to open.
38
+
39
+ **Nemus lets you pull the repos you're working on into one folder and treat them as a
40
+ single project:**
41
+
42
+ - **One command clones them all** — and keeps them in sync.
43
+ - **Run any git or shell command across every repo at once** — `status`, `pull`,
44
+ `branch`, `npm test`, anything.
45
+ - **Your AI agent sees the whole set at once** — so a prompt like *"add idempotency
46
+ keys to the payments flow"* works even when that flow spans five repos, not one.
47
+
48
+ > **In short:** a **monorepo-style workflow across your many repos — without merging them.**
49
+
50
+ ### Before vs. after
51
+
52
+ **Without Nemus** — clone each repo by hand, `cd` into every one, pull, branch, run
53
+ tests, and repeat. Your agent only sees whichever repo you opened.
54
+
55
+ **With Nemus:**
56
+
57
+ ```bash
58
+ nemus create -w payments -r api,web,ledger,gateway,workers # clone all 5 into one workspace
59
+ nemus run payments "npm test" # run the tests in all 5 at once
60
+ nemus -- "add idempotency keys to the payments flow" # let an agent work across all 5
61
+ ```
31
62
 
32
- ## Why Nemus?
63
+ > Prefer to point-and-pick? Just run `nemus create` for an interactive repo picker with
64
+ > fuzzy search, or `nemus -- "create a workspace with the payments repos"` in plain English.
33
65
 
34
- Modern products span many repositories. Nemus keeps a **workspace** — a named folder
35
- of related repos — in sync and lets you act across all of them at once: clone, pull,
36
- branch, run commands, check status, and diff. Then it connects that workspace to a
37
- coding agent (Claude Code, pi, OpenCode, Codex, Gemini) with generated context files,
38
- skills, and an MCP server, so "work on the payments stack" becomes a single prompt.
66
+ ## What you get
39
67
 
40
- - 🌳 **Workspaces** — group repos, clone them all, jump in.
68
+ - 🌳 **Workspaces** — group repos, clone them all in parallel, jump in.
41
69
  - 🔁 **Operate in bulk** — status, sync, diff, run, branch across every repo.
42
70
  - 📦 **Suites & snapshots** — save reusable repo collections and exact states.
43
- - 🤖 **Agent-native** — first-class integration with multiple coding agents.
71
+ - 🤖 **Agent-native** — first-class integration with multiple coding agents
72
+ (Claude Code, pi, OpenCode, Codex, Gemini) via context files, skills, and an MCP server.
44
73
  - 🩺 **Healthy by default** — health checks, dependency analysis, retries.
45
74
 
75
+ ## The name
76
+
77
+ **Nemus** (_NEH-mus_) is Latin for a **grove** — a small wood of trees sharing
78
+ soil and roots. It's a fitting picture of what the tool manages: a cluster of
79
+ repositories, each its own tree, growing together in one workspace. It also nods
80
+ to the branch-and-commit shape of Git itself.
81
+
46
82
  ## A full clone per workspace — on purpose
47
83
 
48
84
  This is the core design decision, so it's worth being explicit: **every workspace
@@ -187,6 +223,21 @@ web feature/x ⚠ 2 modified ↑1 2 files
187
223
  shared-lib main ✓ Clean ↓3 -
188
224
  ```
189
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
+
190
241
  ### Suites (reusable repo collections)
191
242
 
192
243
  ```bash
@@ -50,7 +50,7 @@ const logger_1 = require("../utils/logger");
50
50
  const colors_1 = require("../utils/colors");
51
51
  const agent_config_1 = require("../utils/agent-config");
52
52
  const validation_1 = require("../utils/validation");
53
- const execAsync = (0, util_1.promisify)(child_process_1.exec);
53
+ const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
54
54
  exports.AI_PROMPT_FILE = path.join(os.homedir(), '.workspace-ai-prompt');
55
55
  const EXTRACT_SCHEMA = JSON.stringify({
56
56
  type: 'object',
@@ -129,7 +129,7 @@ function buildInvestigationPreamble(workspaceName, task, opts = {}) {
129
129
  async function isPrimaryAgentAvailable() {
130
130
  const agent = (0, agent_config_1.getPrimaryAgent)();
131
131
  try {
132
- await execAsync(`which ${agent.launchCommand}`);
132
+ await execFileAsync('which', [agent.launchCommand]);
133
133
  return true;
134
134
  }
135
135
  catch {
@@ -148,7 +148,7 @@ async function handleConfigure() {
148
148
  (0, logger_1.logInfo)('Installing MCP server...');
149
149
  try {
150
150
  const mcpInstallScript = path.join(__dirname, '..', '..', 'dist', 'mcp', 'install.js');
151
- (0, child_process_1.execSync)(`node "${mcpInstallScript}" install`, { stdio: 'inherit' });
151
+ (0, child_process_1.execFileSync)('node', [mcpInstallScript, 'install'], { stdio: 'inherit' });
152
152
  mcpInstalled = true;
153
153
  }
154
154
  catch {
@@ -198,7 +198,7 @@ function installShellIntegration() {
198
198
  return;
199
199
  }
200
200
  try {
201
- (0, child_process_1.execSync)(`bash "${scriptPath}" ${shellType}`, { stdio: 'inherit' });
201
+ (0, child_process_1.execFileSync)('bash', [scriptPath, shellType], { stdio: 'inherit' });
202
202
  }
203
203
  catch {
204
204
  (0, logger_1.logWarning)('Shell integration install failed — you can run it manually:');
@@ -39,8 +39,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.registerDeleteCommand = registerDeleteCommand;
40
40
  exports.main = main;
41
41
  const fs = __importStar(require("fs/promises"));
42
- const config_1 = require("../utils/config");
43
- const path = __importStar(require("path"));
42
+ const validation_1 = require("../utils/validation");
44
43
  const workspace_meta_1 = require("../utils/workspace-meta");
45
44
  const prompts_1 = require("../utils/prompts");
46
45
  const logger_1 = require("../utils/logger");
@@ -67,9 +66,33 @@ async function handleDelete(opts) {
67
66
  if (opts.workspace) {
68
67
  const selectedNames = (0, command_helpers_1.parseList)(opts.workspace);
69
68
  const workspaces = await (0, workspace_meta_1.listWorkspaces)();
69
+ const known = new Map(workspaces.map(ws => [ws.name, ws]));
70
+ // Resolve to validated, existing targets. safeWorkspacePath() both
71
+ // enforces the name allowlist and pins the path inside WORKSPACES_DIR, so
72
+ // a name like "../../etc" can never reach fs.rm; unknown names are skipped
73
+ // rather than deleted at a guessed path.
74
+ const targets = [];
70
75
  for (const name of selectedNames) {
71
- const workspace = workspaces.find(ws => ws.name === name);
72
- const workspacePath = path.join(config_1.WORKSPACES_DIR, name);
76
+ const workspace = known.get(name);
77
+ if (!workspace) {
78
+ (0, logger_1.logError)(`Workspace "${name}" not found — skipping`);
79
+ continue;
80
+ }
81
+ let workspacePath;
82
+ try {
83
+ workspacePath = (0, validation_1.safeWorkspacePath)(name);
84
+ }
85
+ catch (error) {
86
+ (0, logger_1.logError)(error instanceof Error ? error.message : `Invalid workspace name "${name}"`);
87
+ continue;
88
+ }
89
+ targets.push({ name, path: workspacePath, workspace });
90
+ }
91
+ if (targets.length === 0) {
92
+ (0, logger_1.logInfo)('Nothing to delete');
93
+ return;
94
+ }
95
+ for (const { name, path: workspacePath, workspace } of targets) {
73
96
  if (workspace?.metadata) {
74
97
  console.log(`${(0, colors_1.colorize)(name, 'cyan')}`);
75
98
  console.log(` Repositories: ${workspace.metadata.repositories.length}`);
@@ -86,9 +109,9 @@ async function handleDelete(opts) {
86
109
  {
87
110
  type: 'confirm',
88
111
  name: 'confirmed',
89
- message: selectedNames.length === 1
90
- ? `Delete workspace ${selectedNames[0]}?`
91
- : `Delete these ${selectedNames.length} workspaces?`,
112
+ message: targets.length === 1
113
+ ? `Delete workspace ${targets[0].name}?`
114
+ : `Delete these ${targets.length} workspaces?`,
92
115
  default: true,
93
116
  },
94
117
  ]);
@@ -97,8 +120,7 @@ async function handleDelete(opts) {
97
120
  process.exit(0);
98
121
  }
99
122
  }
100
- for (const name of selectedNames) {
101
- const workspacePath = path.join(config_1.WORKSPACES_DIR, name);
123
+ for (const { name, path: workspacePath } of targets) {
102
124
  try {
103
125
  await fs.rm(workspacePath, { recursive: true, force: true });
104
126
  (0, logger_1.logSuccess)(`Deleted "${(0, colors_1.colorize)(name, 'cyan')}"`);
@@ -119,44 +141,55 @@ async function handleDelete(opts) {
119
141
  break;
120
142
  }
121
143
  const selectedNames = await (0, prompts_1.promptMultiWorkspaceSelection)(workspaces);
144
+ // Resolve + validate paths once. Names come from disk, but safeWorkspacePath
145
+ // must not throw mid-flow and crash the interactive session, so skip any
146
+ // name that fails the allowlist rather than aborting.
147
+ const resolved = [];
122
148
  for (const name of selectedNames) {
123
- const workspace = workspaces.find(ws => ws.name === name);
124
- const workspacePath = path.join(config_1.WORKSPACES_DIR, name);
125
- if (workspace?.metadata) {
126
- console.log(`${(0, colors_1.colorize)(name, 'cyan')}`);
127
- console.log(` Repositories: ${workspace.metadata.repositories.length}`);
128
- console.log(` Created: ${new Date(workspace.metadata.createdAt).toLocaleString()}`);
129
- console.log(` Path: ${workspacePath}`);
149
+ try {
150
+ resolved.push({ name, path: (0, validation_1.safeWorkspacePath)(name), workspace: workspaces.find(ws => ws.name === name) });
130
151
  }
131
- else {
132
- console.log(`${(0, colors_1.colorize)(name, 'cyan')}`);
133
- console.log(` Path: ${workspacePath}`);
152
+ catch (error) {
153
+ (0, logger_1.logError)(error instanceof Error ? error.message : `Invalid workspace name "${name}"`);
134
154
  }
135
155
  }
136
- console.log('');
137
- (0, logger_1.logWarning)('This will permanently delete all cloned repositories in the selected workspaces!');
138
- const confirmMessage = selectedNames.length === 1
139
- ? `Delete workspace ${selectedNames[0]}?`
140
- : `Delete these ${selectedNames.length} workspaces?`;
141
- const { confirmed } = await inquirer_1.default.prompt([
142
- {
143
- type: 'confirm',
144
- name: 'confirmed',
145
- message: confirmMessage,
146
- default: true,
147
- },
148
- ]);
149
- if (confirmed) {
150
- for (const name of selectedNames) {
151
- const workspacePath = path.join(config_1.WORKSPACES_DIR, name);
152
- try {
153
- await fs.rm(workspacePath, { recursive: true, force: true });
154
- (0, logger_1.logSuccess)(`Deleted "${(0, colors_1.colorize)(name, 'cyan')}"`);
156
+ if (resolved.length > 0) {
157
+ for (const { name, path: workspacePath, workspace } of resolved) {
158
+ if (workspace?.metadata) {
159
+ console.log(`${(0, colors_1.colorize)(name, 'cyan')}`);
160
+ console.log(` Repositories: ${workspace.metadata.repositories.length}`);
161
+ console.log(` Created: ${new Date(workspace.metadata.createdAt).toLocaleString()}`);
162
+ console.log(` Path: ${workspacePath}`);
163
+ }
164
+ else {
165
+ console.log(`${(0, colors_1.colorize)(name, 'cyan')}`);
166
+ console.log(` Path: ${workspacePath}`);
155
167
  }
156
- catch (error) {
157
- (0, logger_1.logError)(`Failed to delete "${name}"`);
158
- if (error instanceof Error)
159
- (0, logger_1.logError)(error.message);
168
+ }
169
+ console.log('');
170
+ (0, logger_1.logWarning)('This will permanently delete all cloned repositories in the selected workspaces!');
171
+ const confirmMessage = resolved.length === 1
172
+ ? `Delete workspace ${resolved[0].name}?`
173
+ : `Delete these ${resolved.length} workspaces?`;
174
+ const { confirmed } = await inquirer_1.default.prompt([
175
+ {
176
+ type: 'confirm',
177
+ name: 'confirmed',
178
+ message: confirmMessage,
179
+ default: true,
180
+ },
181
+ ]);
182
+ if (confirmed) {
183
+ for (const { name, path: workspacePath } of resolved) {
184
+ try {
185
+ await fs.rm(workspacePath, { recursive: true, force: true });
186
+ (0, logger_1.logSuccess)(`Deleted "${(0, colors_1.colorize)(name, 'cyan')}"`);
187
+ }
188
+ catch (error) {
189
+ (0, logger_1.logError)(`Failed to delete "${name}"`);
190
+ if (error instanceof Error)
191
+ (0, logger_1.logError)(error.message);
192
+ }
160
193
  }
161
194
  }
162
195
  }
@@ -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
  }
@@ -70,7 +70,7 @@ function installShellIntegration() {
70
70
  return;
71
71
  }
72
72
  try {
73
- (0, child_process_1.execSync)(`bash "${scriptPath}" ${shellType}`, { stdio: 'inherit' });
73
+ (0, child_process_1.execFileSync)('bash', [scriptPath, shellType], { stdio: 'inherit' });
74
74
  (0, logger_1.logSuccess)('Shell integration installed (auto-CD for w/workspace commands)');
75
75
  }
76
76
  catch {
@@ -294,7 +294,7 @@ async function install() {
294
294
  const serverPath = getMcpServerPath();
295
295
  (0, logger_1.logInfo)(`MCP server path: ${(0, colors_1.colorize)(serverPath, 'cyan')}`);
296
296
  try {
297
- (0, child_process_1.execSync)(`claude mcp add nemus -s user -- node "${serverPath}"`, { stdio: 'pipe' });
297
+ (0, child_process_1.execFileSync)('claude', ['mcp', 'add', 'nemus', '-s', 'user', '--', 'node', serverPath], { stdio: 'pipe' });
298
298
  (0, logger_1.logSuccess)('MCP server registered globally with Claude Code');
299
299
  console.log('\nYou can now use nemus tools in any Claude Code session.');
300
300
  console.log('Try asking: "list my workspaces" or "what\'s the status of my-workspace"');