@nemus-cli/nemus 0.10.0 → 0.12.0

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,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.12.0] - 2026-09-02
11
+
12
+ ### Added
13
+
14
+ - **`nemus version` subcommand** — a companion to the `-V/--version` flag for
15
+ people who type `nemus version`. `--json` additionally reports the Node.js
16
+ version, platform, and arch (handy for bug reports), as a single JSON document
17
+ to stdout. (#74)
18
+ - **`NEMUS_NO_UPDATE_CHECK`** — opt out of the background "update available"
19
+ check entirely (no cache read, no network). Also honors the de-facto
20
+ `NO_UPDATE_NOTIFIER`. An explicit falsey value (`0`/`false`/empty) does not
21
+ disable it. Documented in the README env-var table. (#76)
22
+
23
+ ## [0.11.0] - 2026-09-02
24
+
25
+ ### Added
26
+
27
+ - **`nemus prune`** — bulk-delete workspaces with no recent activity, **safe by
28
+ default.** It selects workspaces whose most recent agent session (or, absent
29
+ any session, whose `createdAt`) is older than `--days` (default 30), and
30
+ **holds back any workspace with uncommitted or unpushed changes** — listing
31
+ each protected one and why. Preview with `--dry-run` or `--json` (both never
32
+ delete); otherwise it deletes after a confirmation (`-y/--yes` to skip).
33
+ `--include-dirty` overrides the safety guard. Deletions go through the same
34
+ validated `safeWorkspacePath()` choke point as `nemus delete`. The
35
+ stale/protected decision logic is pure and unit-tested.
36
+
10
37
  ## [0.10.0] - 2026-09-01
11
38
 
12
39
  ### Changed
package/README.md CHANGED
@@ -202,9 +202,22 @@ nemus create # (c) interactive create
202
202
  nemus list # (l) list workspaces
203
203
  nemus update # (u) add repos to a workspace
204
204
  nemus delete # (del) delete a workspace
205
+ nemus prune # delete workspaces with no recent activity (safe by default)
205
206
  nemus go [name] # jump to a workspace directory
206
207
  ```
207
208
 
209
+ > **`prune`** clears out workspaces you've finished with. It selects those with
210
+ > no agent session (or, failing that, no `createdAt`) in the last **N days**
211
+ > (`--days`, default 30) and — crucially — **protects any workspace with
212
+ > uncommitted or unpushed work**, listing why it was skipped. Preview with
213
+ > `--dry-run` (or `--json`), then delete after a confirmation (`--yes` to skip).
214
+ > `--include-dirty` overrides the safety guard. Example:
215
+ >
216
+ > ```bash
217
+ > nemus prune --days 30 --dry-run # see what would go, nothing deleted
218
+ > nemus prune --days 30 # prune stale + clean workspaces, with a prompt
219
+ > ```
220
+
208
221
  ### Operate across all repos
209
222
 
210
223
  ```bash
@@ -275,6 +288,7 @@ Everything Nemus reads from the environment (all optional):
275
288
  | `NEMUS_JUDGE_TIMEOUT_MS` | Timeout for the `reflect` judge call. |
276
289
  | `NEMUS_BUG_REPORT_REPO` | Repo that `report-bug` files issues against. |
277
290
  | `NEMUS_SKIP_CONFIGURE` | Skip the one-time post-install `configure` prompt. |
291
+ | `NEMUS_NO_UPDATE_CHECK` | Disable the background "update available" check (also honors `NO_UPDATE_NOTIFIER`). |
278
292
  | `WORKSPACE_CLONE_TIMEOUT_MS` | Git clone timeout (default 15 min). |
279
293
  | `NO_COLOR` / `FORCE_COLOR` | Disable / force ANSI color (see [Global flags](#global-flags)). |
280
294
  | `VISUAL` / `EDITOR` | Editor launched by `nemus config edit`. |
@@ -0,0 +1,196 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.registerPruneCommand = registerPruneCommand;
37
+ exports.handlePrune = handlePrune;
38
+ const fs = __importStar(require("fs/promises"));
39
+ const validation_1 = require("../utils/validation");
40
+ const workspace_meta_1 = require("../utils/workspace-meta");
41
+ const claude_sessions_1 = require("../utils/claude-sessions");
42
+ const git_status_1 = require("../utils/git-status");
43
+ const logger_1 = require("../utils/logger");
44
+ const colors_1 = require("../utils/colors");
45
+ const prompt_1 = require("../utils/prompt");
46
+ const command_helpers_1 = require("../utils/command-helpers");
47
+ const output_1 = require("../utils/output");
48
+ const prune_1 = require("../utils/prune");
49
+ const DEFAULT_DAYS = 30;
50
+ function registerPruneCommand(parent) {
51
+ parent
52
+ .command('prune')
53
+ .description('Delete workspaces with no recent activity (safe by default)')
54
+ .option('-d, --days <n>', `Consider a workspace stale after N days of inactivity (default ${DEFAULT_DAYS})`)
55
+ .option('--include-dirty', 'Also prune workspaces with uncommitted/unpushed changes (default: protected)')
56
+ .option('--dry-run', 'Show what would be pruned without deleting anything')
57
+ .option('-y, --yes', 'Skip the confirmation prompt')
58
+ .option('--json', 'Output the prune plan as JSON (never deletes)')
59
+ .action(async (opts, cmd) => {
60
+ const globalOpts = (0, command_helpers_1.getGlobalOpts)(cmd);
61
+ await handlePrune({ ...opts, ...globalOpts });
62
+ });
63
+ }
64
+ function parseDays(raw) {
65
+ if (raw === undefined)
66
+ return DEFAULT_DAYS;
67
+ const n = Number(raw);
68
+ if (!Number.isFinite(n) || n < 0)
69
+ return null;
70
+ return Math.floor(n);
71
+ }
72
+ async function handlePrune(opts) {
73
+ const json = !!opts.json;
74
+ const days = parseDays(opts.days);
75
+ if (days === null) {
76
+ if (json)
77
+ (0, output_1.outputJsonError)('--days must be a non-negative number');
78
+ else
79
+ (0, logger_1.logError)('--days must be a non-negative number');
80
+ process.exit(1);
81
+ }
82
+ try {
83
+ const [workspaces, sessions] = await Promise.all([(0, workspace_meta_1.listWorkspaces)(), (0, claude_sessions_1.getWorkspaceSessions)()]);
84
+ const sessionMap = new Map(sessions.map((s) => [s.workspaceName, s]));
85
+ const now = Date.now();
86
+ const candidates = workspaces.map((ws) => {
87
+ const session = sessionMap.get(ws.name);
88
+ const createdRaw = ws.metadata?.createdAt ? Date.parse(ws.metadata.createdAt) : NaN;
89
+ const forPrune = {
90
+ name: ws.name,
91
+ path: ws.path,
92
+ repoDirNames: (ws.metadata?.repositories ?? []).map((r) => r.directoryName),
93
+ lastActiveAt: session ? session.lastActiveAt.getTime() : 0,
94
+ createdAt: Number.isFinite(createdRaw) ? createdRaw : 0,
95
+ };
96
+ return (0, prune_1.toCandidate)(forPrune, now);
97
+ });
98
+ const stale = candidates.filter((c) => (0, prune_1.isStale)(c, days));
99
+ if (stale.length === 0) {
100
+ if (json) {
101
+ (0, output_1.outputJson)({ ok: true, days, prunable: [], protected: [], scanned: workspaces.length });
102
+ }
103
+ else {
104
+ (0, logger_1.logInfo)(`No workspaces inactive for ${days}+ days (scanned ${workspaces.length}).`);
105
+ }
106
+ return;
107
+ }
108
+ // Compute the plan. The git safety check only runs for stale workspaces.
109
+ const plan = await (0, prune_1.planPrune)(stale, (c) => (0, git_status_1.getAllReposStatus)(c.path, c.repoDirNames, 3), !!opts.includeDirty);
110
+ if (json) {
111
+ (0, output_1.outputJson)({
112
+ ok: true,
113
+ days,
114
+ scanned: workspaces.length,
115
+ prunable: plan.prunable.map((c) => ({ name: c.name, path: c.path, ageDays: c.ageDays, repos: c.repoDirNames.length })),
116
+ protected: plan.protected.map((p) => ({ name: p.candidate.name, ageDays: p.candidate.ageDays, reason: p.reason })),
117
+ });
118
+ return;
119
+ }
120
+ // Human report.
121
+ console.log('\n' + '='.repeat(60));
122
+ console.log((0, colors_1.colorize)(`Prune — workspaces inactive for ${days}+ days`, 'bright'));
123
+ console.log('='.repeat(60) + '\n');
124
+ if (plan.protected.length > 0) {
125
+ (0, logger_1.logWarning)(`Protected (${plan.protected.length}) — skipped due to unsaved work:`);
126
+ for (const p of plan.protected) {
127
+ console.log(` ${(0, colors_1.colorize)('•', 'yellow')} ${(0, colors_1.colorize)(p.candidate.name, 'cyan')} — ${p.reason} ${(0, colors_1.colorize)(`(${ageLabel(p.candidate)})`, 'gray')}`);
128
+ }
129
+ console.log('');
130
+ }
131
+ if (plan.prunable.length === 0) {
132
+ (0, logger_1.logInfo)('Nothing safe to prune.');
133
+ if (plan.protected.length > 0)
134
+ (0, logger_1.logInfo)('Re-run with --include-dirty to include the protected ones (careful).');
135
+ return;
136
+ }
137
+ console.log(`${(0, colors_1.colorize)('Prunable', 'bright')} (${plan.prunable.length}):`);
138
+ for (const c of plan.prunable) {
139
+ const repoLabel = c.repoDirNames.length === 1 ? '1 repo' : `${c.repoDirNames.length} repos`;
140
+ console.log(` ${(0, colors_1.colorize)('✗', 'red')} ${(0, colors_1.colorize)(c.name, 'cyan')} ${(0, colors_1.colorize)(`(${ageLabel(c)}, ${repoLabel})`, 'gray')}`);
141
+ }
142
+ console.log('');
143
+ if (opts.dryRun) {
144
+ (0, logger_1.logInfo)(`Dry run — nothing deleted. ${plan.prunable.length} workspace(s) would be pruned.`);
145
+ return;
146
+ }
147
+ (0, logger_1.logWarning)('This permanently deletes the selected workspaces and every cloned repo inside them!');
148
+ if (!opts.yes) {
149
+ const confirmed = await (0, prompt_1.confirm)({
150
+ message: plan.prunable.length === 1
151
+ ? `Prune workspace ${plan.prunable[0].name}?`
152
+ : `Prune these ${plan.prunable.length} workspaces?`,
153
+ default: false,
154
+ });
155
+ if (!confirmed) {
156
+ (0, logger_1.logInfo)('Prune cancelled');
157
+ return;
158
+ }
159
+ }
160
+ let deleted = 0;
161
+ for (const c of plan.prunable) {
162
+ let target;
163
+ try {
164
+ // Re-validate through the same choke point delete uses: enforces the
165
+ // name allowlist and pins the path inside WORKSPACES_DIR.
166
+ target = (0, validation_1.safeWorkspacePath)(c.name);
167
+ }
168
+ catch (error) {
169
+ (0, logger_1.logError)(error instanceof Error ? error.message : `Invalid workspace name "${c.name}"`);
170
+ continue;
171
+ }
172
+ try {
173
+ await fs.rm(target, { recursive: true, force: true });
174
+ (0, logger_1.logSuccess)(`Pruned "${(0, colors_1.colorize)(c.name, 'cyan')}"`);
175
+ deleted++;
176
+ }
177
+ catch (error) {
178
+ (0, logger_1.logError)(`Failed to prune "${c.name}"`);
179
+ if (error instanceof Error)
180
+ (0, logger_1.logError)(error.message);
181
+ }
182
+ }
183
+ (0, logger_1.logStep)(`Pruned ${deleted} of ${plan.prunable.length} workspace(s).`);
184
+ }
185
+ catch (error) {
186
+ if (json)
187
+ (0, output_1.outputJsonError)(error instanceof Error ? error.message : 'prune failed');
188
+ else
189
+ (0, logger_1.logError)(error instanceof Error ? error.message : 'prune failed');
190
+ process.exit(1);
191
+ }
192
+ }
193
+ function ageLabel(c) {
194
+ const base = c.ageDays === 1 ? '1 day' : `${c.ageDays} days`;
195
+ return c.fromSession ? `${base} since last session` : `${base} since created, no sessions`;
196
+ }
@@ -0,0 +1,37 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildVersionInfo = buildVersionInfo;
4
+ exports.registerVersionCommand = registerVersionCommand;
5
+ const output_1 = require("../utils/output");
6
+ /**
7
+ * Build the version payload. Pure and process-injectable so the JSON shape is
8
+ * unit-testable without reading the real runtime.
9
+ */
10
+ function buildVersionInfo(version, proc = process) {
11
+ return {
12
+ version,
13
+ node: proc.versions.node,
14
+ platform: proc.platform,
15
+ arch: proc.arch,
16
+ };
17
+ }
18
+ /**
19
+ * `nemus version` — a subcommand companion to the `-V/--version` flag, for
20
+ * people who type `nemus version`. `--json` also reports the Node/OS runtime
21
+ * (handy for bug reports), emitting a single JSON document to stdout.
22
+ */
23
+ function registerVersionCommand(program, version) {
24
+ program
25
+ .command('version')
26
+ .description('Print the Nemus version (with --json for version + runtime info)')
27
+ .option('--json', 'Output version + runtime info as JSON')
28
+ .action((opts) => {
29
+ const info = buildVersionInfo(version);
30
+ if (opts.json) {
31
+ (0, output_1.outputJson)(info);
32
+ }
33
+ else {
34
+ process.stdout.write(`nemus ${info.version}\n`);
35
+ }
36
+ });
37
+ }
package/dist/program.js CHANGED
@@ -40,6 +40,7 @@ const fs = __importStar(require("fs"));
40
40
  const colors_1 = require("./utils/colors");
41
41
  const banner_1 = require("./utils/banner");
42
42
  const global_flags_1 = require("./utils/global-flags");
43
+ const version_1 = require("./commands/version");
43
44
  // Read version from package.json
44
45
  const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8'));
45
46
  // --no-color must be applied BEFORE commander parses so it reaches the help
@@ -66,10 +67,12 @@ exports.program
66
67
  // also pre-scanned above.
67
68
  exports.program.hook('preAction', () => (0, global_flags_1.applyGlobalFlags)(exports.program.opts()));
68
69
  // Register top-level commands
70
+ (0, version_1.registerVersionCommand)(exports.program, pkg.version);
69
71
  const create_1 = require("./commands/create");
70
72
  const list_1 = require("./commands/list");
71
73
  const update_1 = require("./commands/update");
72
74
  const delete_1 = require("./commands/delete");
75
+ const prune_1 = require("./commands/prune");
73
76
  const sync_1 = require("./commands/sync");
74
77
  const status_1 = require("./commands/status");
75
78
  const diff_1 = require("./commands/diff");
@@ -96,6 +99,7 @@ const reflect_1 = require("./commands/reflect");
96
99
  (0, list_1.registerListCommand)(exports.program);
97
100
  (0, update_1.registerUpdateCommand)(exports.program);
98
101
  (0, delete_1.registerDeleteCommand)(exports.program);
102
+ (0, prune_1.registerPruneCommand)(exports.program);
99
103
  (0, sync_1.registerSyncCommand)(exports.program);
100
104
  (0, status_1.registerStatusCommand)(exports.program);
101
105
  (0, diff_1.registerDiffCommand)(exports.program);
@@ -0,0 +1,70 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.toCandidate = toCandidate;
4
+ exports.isStale = isStale;
5
+ exports.protectionReason = protectionReason;
6
+ exports.planPrune = planPrune;
7
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
8
+ /** Build a dated candidate for a workspace. `now` is injected for testability. */
9
+ function toCandidate(ws, now) {
10
+ const referenceAt = ws.lastActiveAt > 0 ? ws.lastActiveAt : ws.createdAt;
11
+ const undatable = !(referenceAt > 0);
12
+ const ageDays = undatable ? 0 : Math.floor((now - referenceAt) / MS_PER_DAY);
13
+ return {
14
+ name: ws.name,
15
+ path: ws.path,
16
+ repoDirNames: ws.repoDirNames,
17
+ referenceAt,
18
+ fromSession: ws.lastActiveAt > 0,
19
+ ageDays,
20
+ undatable,
21
+ };
22
+ }
23
+ /**
24
+ * A workspace is a prune candidate when it is datable and its age meets the
25
+ * threshold. Undatable workspaces are never auto-selected — we won't delete
26
+ * something we can't put a date on. A future `referenceAt` (clock skew) yields
27
+ * a negative age and is therefore not stale.
28
+ */
29
+ function isStale(c, days) {
30
+ return !c.undatable && c.ageDays >= days;
31
+ }
32
+ /**
33
+ * Why a stale workspace should be held back from deletion, or null if it's safe.
34
+ * Unsafe = any repo has uncommitted changes (`!clean`) or unpushed commits
35
+ * (`ahead > 0`). With `includeDirty`, nothing is held back. An empty workspace
36
+ * (no repos) is always safe.
37
+ */
38
+ function protectionReason(statuses, includeDirty) {
39
+ if (includeDirty)
40
+ return null;
41
+ const dirty = statuses.filter((s) => !s.clean).length;
42
+ const unpushed = statuses.filter((s) => s.ahead > 0).length;
43
+ if (dirty === 0 && unpushed === 0)
44
+ return null;
45
+ const parts = [];
46
+ if (dirty > 0)
47
+ parts.push(`${dirty} repo${dirty === 1 ? '' : 's'} with uncommitted changes`);
48
+ if (unpushed > 0)
49
+ parts.push(`${unpushed} repo${unpushed === 1 ? '' : 's'} with unpushed commits`);
50
+ return parts.join(', ');
51
+ }
52
+ /**
53
+ * Partition stale candidates into prunable vs. protected, given a resolver that
54
+ * returns each workspace's per-repo git status. The resolver is only invoked
55
+ * for workspaces that actually have repos, so empty stale workspaces cost no git
56
+ * calls. Injecting the resolver keeps this function pure and unit-testable.
57
+ */
58
+ async function planPrune(staleCandidates, getStatuses, includeDirty) {
59
+ const prunable = [];
60
+ const protectedList = [];
61
+ for (const c of staleCandidates) {
62
+ const statuses = c.repoDirNames.length > 0 ? await getStatuses(c) : [];
63
+ const reason = protectionReason(statuses, includeDirty);
64
+ if (reason)
65
+ protectedList.push({ candidate: c, reason });
66
+ else
67
+ prunable.push(c);
68
+ }
69
+ return { prunable, protected: protectedList };
70
+ }
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.checkForUpdate = checkForUpdate;
37
+ exports.updateCheckDisabled = updateCheckDisabled;
37
38
  const fs = __importStar(require("fs/promises"));
38
39
  const path = __importStar(require("path"));
39
40
  const child_process_1 = require("child_process");
@@ -89,6 +90,11 @@ async function fetchLatestVersion() {
89
90
  * This is designed to be non-blocking and best-effort — failures are silent.
90
91
  */
91
92
  async function checkForUpdate() {
93
+ // Opt-out: skip the check entirely (no cache read, no network) when the user
94
+ // asks for it. NEMUS_NO_UPDATE_CHECK is ours; NO_UPDATE_NOTIFIER is the
95
+ // de-facto convention several Node CLIs honor.
96
+ if (updateCheckDisabled(process.env))
97
+ return null;
92
98
  try {
93
99
  const currentVersion = (0, config_1.getPackageVersion)();
94
100
  const cache = await loadCache();
@@ -116,6 +122,15 @@ async function checkForUpdate() {
116
122
  return null;
117
123
  }
118
124
  }
125
+ /**
126
+ * Whether the update check is opted out via env. A value is "set" unless it is
127
+ * empty or an explicit falsey token (`0`/`false`), so `NEMUS_NO_UPDATE_CHECK=0`
128
+ * does NOT disable the check. Pure + exported for testing.
129
+ */
130
+ function updateCheckDisabled(env) {
131
+ const isSet = (v) => v !== undefined && v !== '' && v !== '0' && v.toLowerCase() !== 'false';
132
+ return isSet(env.NEMUS_NO_UPDATE_CHECK) || isSet(env.NO_UPDATE_NOTIFIER);
133
+ }
119
134
  function formatUpdateMessage(current, latest) {
120
135
  return `\x1b[33m[nemus] Update available: ${current} -> ${latest}. Run: npm install -g @nemus-cli/nemus@latest\x1b[0m`;
121
136
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nemus-cli/nemus",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "workspaces": [
5
5
  "packages/*"
6
6
  ],
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env node
2
+ // Emit GitHub Release notes for a given version by extracting that version's
3
+ // section from CHANGELOG.md (newest-first "Keep a Changelog" format), prepended
4
+ // with an install snippet and followed by a compare link to the previous tag.
5
+ //
6
+ // Usage: node scripts/release-notes.mjs <version> [repo]
7
+ // repo defaults to $GITHUB_REPOSITORY or "me-public/nemus".
8
+ //
9
+ // If the version has no CHANGELOG section, prints a minimal fallback (so a
10
+ // release is never blocked on a missing entry). Dependency-free; prints to
11
+ // stdout so the workflow can redirect it into `gh release --notes-file`.
12
+ import { readFileSync } from 'node:fs';
13
+
14
+ const version = (process.argv[2] || '').trim();
15
+ const repo = (process.argv[3] || process.env.GITHUB_REPOSITORY || 'me-public/nemus').trim();
16
+ if (!version) {
17
+ process.stderr.write('usage: release-notes.mjs <version> [repo]\n');
18
+ process.exit(2);
19
+ }
20
+
21
+ const install = `\`\`\`bash\nnpm install -g @nemus-cli/nemus@${version}\n\`\`\``;
22
+
23
+ let body = '';
24
+ let prev = null;
25
+ try {
26
+ const changelog = readFileSync(new URL('../CHANGELOG.md', import.meta.url), 'utf8');
27
+ const lines = changelog.split('\n');
28
+ // Collect version headers in file order (newest first) with their line index.
29
+ const heads = [];
30
+ lines.forEach((line, i) => {
31
+ const m = line.match(/^## \[(\d+\.\d+\.\d+)\]/);
32
+ if (m) heads.push({ version: m[1], line: i });
33
+ });
34
+ const idx = heads.findIndex((h) => h.version === version);
35
+ if (idx !== -1) {
36
+ const start = heads[idx].line + 1;
37
+ const end = idx + 1 < heads.length ? heads[idx + 1].line : lines.length;
38
+ body = lines.slice(start, end).join('\n').trim();
39
+ // Newest-first: the NEXT header in the file is the previous release.
40
+ prev = idx + 1 < heads.length ? heads[idx + 1].version : null;
41
+ }
42
+ } catch {
43
+ // fall through to fallback
44
+ }
45
+
46
+ const compare = prev
47
+ ? `[\`v${prev}...v${version}\`](https://github.com/${repo}/compare/v${prev}...v${version})`
48
+ : `[\`v${version}\`](https://github.com/${repo}/releases/tag/v${version})`;
49
+
50
+ const parts = [`## Nemus v${version}`, '', install];
51
+ if (body) parts.push('', body);
52
+ parts.push('', '---', '', `**Full diff:** ${compare} · [Full changelog](https://github.com/${repo}/blob/main/CHANGELOG.md)`);
53
+
54
+ process.stdout.write(parts.join('\n') + '\n');
@@ -0,0 +1,17 @@
1
+ ---
2
+ name: config
3
+ description: Read and write Nemus configuration non-interactively (get/set/unset/list/path/edit)
4
+ ---
5
+
6
+ Non-interactive configuration in `~/.nemus/config.json` (no wizard):
7
+ ```bash
8
+ nemus config list # all keys + resolved values (alias: ls; --json)
9
+ nemus config get <key> # print one value (--json)
10
+ nemus config set <key> <value> # set + validate + persist
11
+ nemus config unset <key> # revert a key to its default
12
+ nemus config path # print the config file path
13
+ ```
14
+
15
+ - Values are validated/coerced per key: booleans accept `true/false/yes/no/on/off/1/0`; enums (e.g. `cloneProtocol=ssh|https`) are checked. Invalid key/value exits non-zero.
16
+ - Common keys: `workspacesDir`, `githubOrg`, `cloneProtocol`, `aiAgent`, `primaryAgent`. Run `nemus config list` to see all.
17
+ - For a guided first-time setup, use `nemus configure` instead.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: nemus
3
3
  description: Manage multi-repo development workspaces — create, sync, branch, diff, clone, archive, analyze dependencies, run commands across repos, check status, clean up, delete, list org repos, search repos, list suites, remove repo, cache, suite management, sessions, history, generate docs, configure, MCP server, AI prompt. Use when working with workspaces, repos, branches, git operations across multiple repos, or the `nemus` CLI tool. NEVER use `git clone` directly — always use `nemus update` to add repos and `nemus create` to create workspaces.
4
- bashPattern: "\\bnemus\\s+(create|list|update|delete|sync|status|diff|run|go|doctor|analyze-deps|history|cleanup|remove-repo|archive|sessions|generate-docs|configure|configure-claude|ghq-status|tui|dashboard|dash|branch|suite|cache|mcp|--)\\b"
4
+ bashPattern: "\\bnemus\\s+(create|list|update|delete|prune|sync|status|diff|run|go|doctor|analyze-deps|reflect|retro|history|cleanup|remove-repo|archive|sessions|save-context|ctx|generate-docs|configure|config|configure-claude|ghq-status|completion|tui|dashboard|dash|branch|suite|cache|mcp|--)\\b"
5
5
  ---
6
6
 
7
7
  # Nemus
@@ -33,6 +33,7 @@ Global flags: `-f/--force-refresh` (skip repo cache), `-y/--yes` (skip prompts),
33
33
  | Create new workspace | [create-workspace](references/create-workspace.md) | `nemus create` | `c` |
34
34
  | Add repos to workspace | [update-workspace](references/update-workspace.md) | `nemus update` | `u` |
35
35
  | Delete workspace permanently | [delete-workspace](references/delete-workspace.md) | `nemus delete` | `d` |
36
+ | Prune inactive workspaces (safe) | [prune](references/prune.md) | `nemus prune` | — |
36
37
  | Archive / unarchive workspace | [archive-workspace](references/archive-workspace.md) | `nemus archive` | `a` |
37
38
  | List all workspaces | [list-workspaces](references/list-workspaces.md) | `nemus list` | `l` |
38
39
  | Navigate to workspace | [go](references/go.md) | `nemus go [name]` | — |
@@ -88,14 +89,18 @@ Global flags: `-f/--force-refresh` (skip repo cache), `-y/--yes` (skip prompts),
88
89
  | Resume Claude session | [sessions](references/sessions.md) | `nemus sessions` | `ses` |
89
90
  | Agent management dashboard | [dashboard](references/dashboard.md) | `nemus dashboard` | `dash` |
90
91
  | View operation history | [history](references/history.md) | `nemus history` | `h` |
92
+ | Save progress/context to a workspace | [save-context](references/save-context.md) | `nemus save-context` | `ctx` |
93
+ | Retrospective on recent sessions | [reflect](references/reflect.md) | `nemus reflect` | `retro` |
91
94
 
92
95
  ### Configuration & MCP
93
96
 
94
97
  | Intent | Reference | CLI |
95
98
  |---|---|---|
96
- | Configure Nemus | [configure](references/configure.md) | `nemus configure` |
99
+ | Configure Nemus (interactive wizard) | [configure](references/configure.md) | `nemus configure` |
100
+ | Get/set config non-interactively | [config](references/config.md) | `nemus config get\|set\|list\|edit` |
97
101
  | Configure Claude integration | [configure-claude](references/configure-claude.md) | `nemus configure-claude` |
98
102
  | Check ghq status | [ghq-status](references/ghq-status.md) | `nemus ghq-status` |
103
+ | Shell completions (bash/zsh/fish) | [completion](references/completion.md) | `nemus completion <shell>` |
99
104
  | Install / manage MCP server | [mcp](references/mcp.md) | `nemus mcp install\|status\|upgrade\|uninstall` |
100
105
 
101
106
  ### AI Assistant & TUI
@@ -0,0 +1,22 @@
1
+ # Shell Completions
2
+
3
+ `nemus completion <shell>` prints a completion script for `bash`, `zsh`, or
4
+ `fish`. It completes subcommands and, for workspace-scoped commands, live
5
+ workspace names (the script calls back into `nemus completion --workspaces`, so
6
+ completions stay fresh without regenerating). Registered for both `nemus` and
7
+ `nem`.
8
+
9
+ Install (pick your shell):
10
+ ```bash
11
+ # bash
12
+ nemus completion bash > /etc/bash_completion.d/nemus # or: >> ~/.bashrc
13
+
14
+ # zsh — save on your $fpath as _nemus
15
+ nemus completion zsh > "${fpath[1]}/_nemus"
16
+
17
+ # fish
18
+ nemus completion fish > ~/.config/fish/completions/nemus.fish
19
+ ```
20
+
21
+ Then restart the shell (or `source` the file). Requires a shell argument —
22
+ one of `bash|zsh|fish`.
@@ -0,0 +1,32 @@
1
+ # Config — non-interactive configuration
2
+
3
+ `nemus config` reads and writes `~/.nemus/config.json` without the interactive
4
+ `nemus configure` wizard. Ideal for scripting and for setting one value.
5
+
6
+ ```bash
7
+ nemus config list # show all keys + resolved values (alias: ls)
8
+ nemus config list --json # machine-readable
9
+ nemus config get <key> # print one value (--json for structured)
10
+ nemus config set <key> <value> # set + validate + persist
11
+ nemus config unset <key> # remove a key (revert to default)
12
+ nemus config path # print the config file path
13
+ nemus config edit # open the file in $VISUAL/$EDITOR (needs a TTY)
14
+ ```
15
+
16
+ Values are validated and coerced per key: booleans accept
17
+ `true/false/yes/no/on/off/1/0`; enums (e.g. `cloneProtocol` = `ssh|https`) are
18
+ checked. An unknown key or invalid value exits non-zero with a clear message.
19
+
20
+ Common keys: `workspacesDir`, `githubOrg`, `cloneProtocol`, `aiAgent`,
21
+ `primaryAgent`, `autoLaunchClaude`, `generateClaudeContext`, `installMcp`.
22
+ Run `nemus config list` to see them all.
23
+
24
+ ```bash
25
+ # examples
26
+ nemus config set githubOrg acme
27
+ nemus config set cloneProtocol https
28
+ nemus config get workspacesDir --json
29
+ ```
30
+
31
+ Pairs well with `--quiet` for scripts. For a guided first-time setup, use
32
+ `nemus configure` instead.
@@ -0,0 +1,44 @@
1
+ # Prune Inactive Workspaces
2
+
3
+ Bulk-delete workspaces with no recent activity. **Safe by default** — it holds
4
+ back any workspace with uncommitted or unpushed work.
5
+
6
+ 1. **Always preview first.** Show the user exactly what would be deleted (and
7
+ what is protected) before removing anything:
8
+ ```bash
9
+ nemus prune --days <n> --dry-run
10
+ ```
11
+ A workspace is *stale* when its most recent agent session — or, if it has no
12
+ session, its `createdAt` — is older than `--days` (default 30). Workspaces
13
+ with no date at all are never selected.
14
+
15
+ 2. **Review the two lists.** `prune` prints:
16
+ - **Protected** — stale workspaces skipped because a repo has uncommitted
17
+ changes or unpushed commits (with the reason). These are NOT deleted.
18
+ - **Prunable** — stale workspaces that are safe to remove.
19
+
20
+ 3. **Confirm with the user, then prune.** This permanently deletes the
21
+ workspace directories and every cloned repo inside them:
22
+ ```bash
23
+ nemus prune --days <n> # prompts for confirmation (default: No)
24
+ nemus prune --days <n> --yes # non-interactive (only when the user is sure)
25
+ ```
26
+
27
+ 4. **`--json`** gives a machine-readable plan (`{ prunable, protected }`) and,
28
+ like `--dry-run`, never deletes.
29
+
30
+ Deletions go through the same validated path as `nemus delete` (name allowlist +
31
+ path pinned inside the workspaces directory).
32
+
33
+ ## Flags
34
+
35
+ | Flag | Short | Description |
36
+ |---|---|---|
37
+ | `--days <n>` | `-d` | Stale after N days of inactivity (default 30) |
38
+ | `--dry-run` | | Show the plan without deleting anything |
39
+ | `--json` | | Output the plan as JSON (never deletes) |
40
+ | `--yes` | `-y` | Skip the confirmation prompt |
41
+ | `--include-dirty` | | Also prune workspaces with uncommitted/unpushed work (overrides the safety guard — use with care) |
42
+
43
+ > **Only** pass `--include-dirty` when the user has explicitly accepted losing
44
+ > uncommitted/unpushed work in the protected workspaces.