@nemus-cli/nemus 0.2.12 → 0.3.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.
@@ -0,0 +1,345 @@
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.REFLECT_SCHEMA = void 0;
37
+ exports.distillTranscript = distillTranscript;
38
+ exports.findLatestTranscriptFile = findLatestTranscriptFile;
39
+ exports.gatherReflectionCorpus = gatherReflectionCorpus;
40
+ exports.buildJudgePrompt = buildJudgePrompt;
41
+ exports.parseReflectionReport = parseReflectionReport;
42
+ const fs = __importStar(require("fs/promises"));
43
+ const path = __importStar(require("path"));
44
+ const workspace_meta_1 = require("./workspace-meta");
45
+ const agent_config_1 = require("./agent-config");
46
+ const claude_sessions_1 = require("./claude-sessions");
47
+ // --------------------------------------------------------- transcript distill
48
+ const MAX_PROMPTS = 25;
49
+ const MAX_ERRORS = 25;
50
+ const PROMPT_CHARS = 600;
51
+ const ERROR_CHARS = 300;
52
+ /** Flatten a message `content` (string or content-block array) to plain text. */
53
+ function contentToText(content) {
54
+ if (typeof content === 'string')
55
+ return content;
56
+ if (Array.isArray(content)) {
57
+ return content
58
+ .map((b) => (b && b.type === 'text' && typeof b.text === 'string' ? b.text : ''))
59
+ .filter(Boolean)
60
+ .join('\n');
61
+ }
62
+ return '';
63
+ }
64
+ // Only used as a FALLBACK when a tool result carries no explicit error flag.
65
+ // Kept to strong failure signals so a successful grep/log line for the word
66
+ // "error", or a passing test named “…error…”, isn't mistaken for a failure.
67
+ const ERROR_RE = /\b(fatal|failed|failure|exception|traceback|denied|not a git repository|timed out|exit code\s+[1-9])\b/i;
68
+ /**
69
+ * Decide whether a tool result is a failure: trust the explicit `isError` flag
70
+ * when present (true => failure, false => success), and only guess from the
71
+ * text when there's no flag at all. This keeps the judge's “evidence” to real
72
+ * failures instead of any output that happens to contain the word “error”.
73
+ */
74
+ function isToolFailure(flag, text) {
75
+ if (flag === true)
76
+ return true;
77
+ if (flag === false)
78
+ return false;
79
+ return ERROR_RE.test(text);
80
+ }
81
+ /**
82
+ * Distill a raw `.jsonl` transcript into the signals a judge needs: the human
83
+ * prompts, tool failures, and which tools ran. Pure (operates on file content),
84
+ * defensive about the several line shapes Claude/pi emit, and bounded so a huge
85
+ * transcript can't blow the prompt budget.
86
+ */
87
+ function distillTranscript(raw, meta) {
88
+ const userPrompts = [];
89
+ const errors = [];
90
+ const tools = new Set();
91
+ let turns = 0;
92
+ for (const line of raw.split('\n')) {
93
+ const trimmed = line.trim();
94
+ if (!trimmed)
95
+ continue;
96
+ let obj;
97
+ try {
98
+ obj = JSON.parse(trimmed);
99
+ }
100
+ catch {
101
+ continue;
102
+ }
103
+ const msg = obj.message ?? obj;
104
+ const role = msg?.role ?? obj?.type;
105
+ const content = msg?.content;
106
+ // Assistant turn + tool uses. Claude uses `tool_use` blocks; pi uses `toolCall`.
107
+ if (role === 'assistant') {
108
+ turns++;
109
+ if (Array.isArray(content)) {
110
+ for (const b of content) {
111
+ if (b && (b.type === 'tool_use' || b.type === 'toolCall') && typeof b.name === 'string')
112
+ tools.add(b.name);
113
+ }
114
+ }
115
+ }
116
+ // Tool results, two shapes:
117
+ // - pi: a top-level message with role 'toolResult' (+ toolName, content).
118
+ // - Claude: a `tool_result` block inside a user message's content array.
119
+ if (role === 'toolResult') {
120
+ if (typeof msg.toolName === 'string')
121
+ tools.add(msg.toolName);
122
+ const text = contentToText(content);
123
+ if (isToolFailure(msg.isError ?? msg.is_error, text) && text.trim() && errors.length < MAX_ERRORS) {
124
+ errors.push(text.trim().slice(0, ERROR_CHARS));
125
+ }
126
+ }
127
+ if (Array.isArray(content)) {
128
+ for (const b of content) {
129
+ if (b && b.type === 'tool_result') {
130
+ const text = contentToText(b.content);
131
+ if (isToolFailure(b.is_error, text) && text.trim() && errors.length < MAX_ERRORS) {
132
+ errors.push(text.trim().slice(0, ERROR_CHARS));
133
+ }
134
+ }
135
+ }
136
+ }
137
+ // Human prompts: a user message that carries actual text (not a tool_result echo).
138
+ if (role === 'user') {
139
+ const isToolResultOnly = Array.isArray(content) && content.length > 0 && content.every((b) => b?.type === 'tool_result');
140
+ if (!isToolResultOnly) {
141
+ const text = contentToText(content).trim();
142
+ if (text && !text.startsWith('<') && userPrompts.length < MAX_PROMPTS) {
143
+ userPrompts.push(text.slice(0, PROMPT_CHARS));
144
+ }
145
+ }
146
+ }
147
+ }
148
+ return { sessionId: meta.sessionId, agentType: meta.agentType, turns, userPrompts, errors, tools: [...tools] };
149
+ }
150
+ // --------------------------------------------------------- corpus gathering
151
+ /** Locate the most recent `.jsonl` transcript for a workspace under an agent. */
152
+ async function findLatestTranscriptFile(sessionProjectsDir, workspacePath, agentType) {
153
+ if (agentType !== 'claude' && agentType !== 'pi')
154
+ return null;
155
+ const projDir = path.join(sessionProjectsDir, (0, claude_sessions_1.pathToProjectDirName)(workspacePath, agentType));
156
+ let entries;
157
+ try {
158
+ entries = await fs.readdir(projDir);
159
+ }
160
+ catch {
161
+ return null;
162
+ }
163
+ const jsonl = entries.filter((f) => f.endsWith('.jsonl'));
164
+ if (jsonl.length === 0)
165
+ return null;
166
+ const stats = await Promise.all(jsonl.map(async (f) => {
167
+ try {
168
+ return { f, mtime: (await fs.stat(path.join(projDir, f))).mtime.getTime() };
169
+ }
170
+ catch {
171
+ return null;
172
+ }
173
+ }));
174
+ const best = stats.filter((s) => !!s).sort((a, b) => b.mtime - a.mtime)[0];
175
+ return best ? path.join(projDir, best.f) : null;
176
+ }
177
+ const MAX_TRANSCRIPT_BYTES = 4 * 1024 * 1024;
178
+ /** Read + distill the transcript for a specific discovered session. Prefers the
179
+ * exact `<sessionId>.jsonl`; falls back to the latest transcript in that
180
+ * project dir if the exact file has been rotated away. */
181
+ async function readDigestForSession(s) {
182
+ if (s.agentType !== 'claude' && s.agentType !== 'pi')
183
+ return null;
184
+ const projectsDir = (0, agent_config_1.getAgentPaths)(s.agentType).sessionProjectsDir;
185
+ const exact = path.join(projectsDir, (0, claude_sessions_1.pathToProjectDirName)(s.workspacePath, s.agentType), `${s.sessionId}.jsonl`);
186
+ let file = exact;
187
+ try {
188
+ await fs.access(exact);
189
+ }
190
+ catch {
191
+ file = await findLatestTranscriptFile(projectsDir, s.workspacePath, s.agentType);
192
+ }
193
+ if (!file)
194
+ return null;
195
+ let raw;
196
+ try {
197
+ raw = await fs.readFile(file, 'utf-8');
198
+ }
199
+ catch {
200
+ return null;
201
+ }
202
+ if (raw.length > MAX_TRANSCRIPT_BYTES)
203
+ raw = raw.slice(raw.length - MAX_TRANSCRIPT_BYTES); // keep the tail (most recent)
204
+ return distillTranscript(raw, { sessionId: s.sessionId, agentType: s.agentType });
205
+ }
206
+ async function listAvailableSkills() {
207
+ const names = new Set();
208
+ for (const dir of (0, agent_config_1.getSkillsTargetDirs)()) {
209
+ try {
210
+ for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
211
+ if (entry.isDirectory())
212
+ names.add(entry.name);
213
+ else if (entry.name.endsWith('.md'))
214
+ names.add(entry.name.replace(/\.md$/, ''));
215
+ }
216
+ }
217
+ catch {
218
+ /* dir may not exist */
219
+ }
220
+ }
221
+ return [...names].sort();
222
+ }
223
+ async function contextFilesFor(workspacePath) {
224
+ const present = [];
225
+ for (const name of (0, agent_config_1.getAllKnownContextFileNames)()) {
226
+ try {
227
+ await fs.access(path.join(workspacePath, name));
228
+ present.push(name);
229
+ }
230
+ catch {
231
+ /* not present */
232
+ }
233
+ }
234
+ return present;
235
+ }
236
+ /**
237
+ * Build the corpus the judge reasons over: the `limit` most **recently active**
238
+ * workspaces (by their latest agent session, not creation date — a retrospective
239
+ * is about recent *work*), each with its repos, context files, and distilled
240
+ * session, plus the globally-available skills.
241
+ */
242
+ async function gatherReflectionCorpus(limit) {
243
+ const [sessions, workspaces, availableSkills] = await Promise.all([
244
+ (0, claude_sessions_1.getWorkspaceSessions)(), // already sorted by last-active, one per workspace
245
+ (0, workspace_meta_1.listWorkspaces)(false),
246
+ listAvailableSkills(),
247
+ ]);
248
+ const metaByName = new Map(workspaces.map((w) => [w.name, w]));
249
+ const recent = sessions.slice(0, limit);
250
+ const digests = [];
251
+ for (const s of recent) {
252
+ const meta = metaByName.get(s.workspaceName);
253
+ digests.push({
254
+ name: s.workspaceName,
255
+ repoCount: meta?.metadata?.repositories?.length ?? 0,
256
+ repos: (meta?.metadata?.repositories ?? []).map((r) => r.name),
257
+ contextFiles: await contextFilesFor(s.workspacePath),
258
+ session: await readDigestForSession(s),
259
+ });
260
+ }
261
+ return { generatedAt: new Date().toISOString(), availableSkills, workspaces: digests };
262
+ }
263
+ // ------------------------------------------------------------- judge prompt
264
+ /** JSON schema for `claude --json-schema` (best-effort; other agents ignore it). */
265
+ exports.REFLECT_SCHEMA = JSON.stringify({
266
+ type: 'object',
267
+ properties: {
268
+ summary: { type: 'string' },
269
+ recommendations: {
270
+ type: 'array',
271
+ items: {
272
+ type: 'object',
273
+ properties: {
274
+ kind: { type: 'string', enum: ['skill', 'context', 'test', 'prompt', 'connectivity', 'workflow', 'other'] },
275
+ title: { type: 'string' },
276
+ detail: { type: 'string' },
277
+ target: { type: 'string' },
278
+ priority: { type: 'string', enum: ['high', 'medium', 'low'] },
279
+ example: { type: 'string' },
280
+ },
281
+ required: ['kind', 'title', 'detail', 'priority'],
282
+ },
283
+ },
284
+ },
285
+ required: ['summary', 'recommendations'],
286
+ });
287
+ /**
288
+ * Build the LLM-as-a-judge prompt. The judge sees distilled recent sessions and
289
+ * is asked to recommend concrete improvements to the user's SETUP (skills,
290
+ * AGENTS.md/context rules, connectivity/tests, prompt habits, workflow) — not to
291
+ * redo the tasks. Output is strict JSON matching REFLECT_SCHEMA.
292
+ */
293
+ function buildJudgePrompt(corpus) {
294
+ const lines = [];
295
+ lines.push('You are an expert reviewer ("LLM as a judge") analyzing an engineer\'s recent AI coding-agent sessions.', 'Goal: recommend concrete improvements to their SETUP so the agent works better next time —', 'which skills to add and WHERE, which AGENTS.md/context rules are missing, missing connectivity/', 'smoke tests, and prompt habits to change. Judge the setup, do NOT redo the tasks.', '', 'Base every recommendation on evidence in the sessions below (repeated failures, retries, vague', 'prompts, missing context). Prefer a few high-signal, actionable items over many generic ones.', 'When you suggest a skill or an AGENTS.md rule, include a short concrete `example` snippet.', '', `Globally installed skills (don't re-suggest these; suggest genuinely missing ones): ${corpus.availableSkills.join(', ') || '(none)'}`, '', `Recent workspaces (${corpus.workspaces.length}):`);
296
+ for (const ws of corpus.workspaces) {
297
+ lines.push(`\n## ${ws.name}`);
298
+ lines.push(`repos: ${ws.repos.join(', ') || '(none)'} | context files: ${ws.contextFiles.join(', ') || 'NONE'}`);
299
+ if (!ws.session) {
300
+ lines.push('session: (no recent agent session found)');
301
+ continue;
302
+ }
303
+ lines.push(`session: ${ws.session.turns} turns, tools used: ${ws.session.tools.join(', ') || '(none)'}`);
304
+ if (ws.session.userPrompts.length) {
305
+ lines.push('user prompts:');
306
+ for (const p of ws.session.userPrompts)
307
+ lines.push(` - ${p.replace(/\n/g, ' ')}`);
308
+ }
309
+ if (ws.session.errors.length) {
310
+ lines.push('errors/failures observed:');
311
+ for (const e of ws.session.errors)
312
+ lines.push(` - ${e.replace(/\n/g, ' ')}`);
313
+ }
314
+ }
315
+ lines.push('', 'Respond with ONLY a JSON object of this shape (no prose, no markdown fence):', '{"summary": string, "recommendations": [{"kind":"skill|context|test|prompt|connectivity|workflow|other",', '"title": string, "detail": string, "target": string(optional workspace/repo/path),', '"priority":"high|medium|low", "example": string(optional snippet)}]}');
316
+ return lines.join('\n');
317
+ }
318
+ // ----------------------------------------------------------- response parse
319
+ const KINDS = ['skill', 'context', 'test', 'prompt', 'connectivity', 'workflow', 'other'];
320
+ const PRIORITIES = ['high', 'medium', 'low'];
321
+ /** Validate + normalize the judge's parsed JSON into a ReflectionReport. */
322
+ function parseReflectionReport(parsed) {
323
+ const obj = (parsed ?? {});
324
+ const summary = typeof obj.summary === 'string' ? obj.summary : '';
325
+ const rawRecs = Array.isArray(obj.recommendations) ? obj.recommendations : [];
326
+ const recommendations = rawRecs
327
+ .map((r) => {
328
+ if (!r || typeof r !== 'object')
329
+ return null;
330
+ const title = typeof r.title === 'string' ? r.title : '';
331
+ const detail = typeof r.detail === 'string' ? r.detail : '';
332
+ if (!title && !detail)
333
+ return null;
334
+ const kind = KINDS.includes(r.kind) ? r.kind : 'other';
335
+ const priority = PRIORITIES.includes(r.priority) ? r.priority : 'medium';
336
+ const rec = { kind, title, detail, priority };
337
+ if (typeof r.target === 'string' && r.target.trim())
338
+ rec.target = r.target.trim();
339
+ if (typeof r.example === 'string' && r.example.trim())
340
+ rec.example = r.example.trim();
341
+ return rec;
342
+ })
343
+ .filter((r) => r !== null);
344
+ return { summary, recommendations };
345
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nemus-cli/nemus",
3
- "version": "0.2.12",
3
+ "version": "0.3.0",
4
4
  "workspaces": [
5
5
  "packages/*"
6
6
  ],
@@ -1,4 +1,5 @@
1
1
  import { spawn, execFile, execFileSync } from 'child_process';
2
+ import { parseAgentJson } from '../utils/agent-judge';
2
3
  import { promisify } from 'util';
3
4
  import * as fs from 'fs';
4
5
  import * as path from 'path';
@@ -259,32 +260,21 @@ export async function extractIntent(prompt: string): Promise<ExtractedIntent> {
259
260
  result = runExtraction('pi', [...piLean, ...piCore], piCore);
260
261
  }
261
262
 
262
- // Strip markdown code fences if present (Pi may wrap JSON in ```json...```)
263
- let jsonStr = result.trim();
264
- const fenceMatch = jsonStr.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
265
- if (fenceMatch) {
266
- jsonStr = fenceMatch[1].trim();
267
- }
268
-
269
- const parsed = JSON.parse(jsonStr);
270
-
271
- // Handle different output formats:
272
- // - Claude: { structured_output: {...} } or { result: "..." }
273
- // - Pi: may return the object directly or wrap it
263
+ // Unwrap the agent's reply (code fences + the structured_output / result-string
264
+ // / result-object / bare-object envelopes) with the shared parser, so this and
265
+ // the reflect judge can't drift when a new agent shape is learned. The
266
+ // extraction-specific INVOCATION (lean flags + tailored timeout/auth errors)
267
+ // deliberately stays here — those messages are part of the `nemus --` UX.
274
268
  let intent: ExtractedIntent | undefined;
275
- if (parsed.structured_output) {
276
- intent = parsed.structured_output;
277
- } else if (typeof parsed.result === 'string' && parsed.result) {
278
- try { intent = JSON.parse(parsed.result); } catch { /* ignore */ }
279
- } else if (typeof parsed.result === 'object' && parsed.result !== null) {
280
- intent = parsed.result;
281
- } else if (parsed.workspaceName || parsed.repos || parsed.remainingIntent !== undefined) {
282
- // Pi may return the extracted object directly
283
- intent = parsed;
269
+ try {
270
+ const parsed = parseAgentJson(result) as any;
271
+ if (parsed && typeof parsed === 'object') intent = parsed;
272
+ } catch {
273
+ throw new Error(`Could not extract intent from agent response: ${result.slice(0, 200)}`);
284
274
  }
285
275
 
286
276
  if (!intent) {
287
- throw new Error(`Could not extract intent from agent response. Parsed: ${JSON.stringify(parsed).slice(0, 200)}`);
277
+ throw new Error(`Could not extract intent from agent response: ${result.slice(0, 200)}`);
288
278
  }
289
279
 
290
280
  // Coerce/validate field types so malformed model output can't crash the
@@ -0,0 +1,60 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { Command } from 'commander';
3
+ import { generateCompletion, specsFromProgram, CommandSpec } from './completion';
4
+
5
+ const specs: CommandSpec[] = [
6
+ { name: 'list', aliases: ['l'], takesWorkspace: false, description: 'List workspaces' },
7
+ { name: 'status', aliases: ['st'], takesWorkspace: true, description: "Show a repo's git status" },
8
+ { name: 'doctor', aliases: ['doc'], takesWorkspace: true, description: 'Health checks' },
9
+ ];
10
+
11
+ describe('generateCompletion — bash', () => {
12
+ const s = generateCompletion('bash', specs);
13
+ it('resets COMPREPLY, lists tokens, and registers both bins', () => {
14
+ expect(s).toContain('COMPREPLY=()'); // guards the stale-completion leak
15
+ expect(s).toContain('local commands="list l status st doctor doc"');
16
+ expect(s).toContain('local ws_commands="status st doctor doc"');
17
+ expect(s).toContain('complete -F _nemus_complete nemus');
18
+ expect(s).toContain('complete -F _nemus_complete nem');
19
+ });
20
+ it('calls back into the invoked bin for workspace names', () => {
21
+ expect(s).toContain('"$bin" completion --workspaces');
22
+ });
23
+ });
24
+
25
+ describe('generateCompletion — zsh', () => {
26
+ const s = generateCompletion('zsh', specs);
27
+ it('is an autoloadable #compdef script with the tokens + callback', () => {
28
+ expect(s.startsWith('#compdef nemus nem')).toBe(true);
29
+ expect(s).toContain("_nemus_commands=('list' 'l' 'status' 'st' 'doctor' 'doc')");
30
+ expect(s).toContain('_nemus_ws_commands="status st doctor doc"');
31
+ expect(s).toContain('completion --workspaces');
32
+ });
33
+ });
34
+
35
+ describe('generateCompletion — fish', () => {
36
+ const s = generateCompletion('fish', specs);
37
+ it('emits subcommand + workspace completions for both bins with escaped descriptions', () => {
38
+ expect(s).toContain("complete -c nemus -n __fish_use_subcommand -a 'list' -d 'List workspaces'");
39
+ expect(s).toContain("complete -c nem -n __fish_use_subcommand -a 'status'");
40
+ // apostrophe in the description is escaped for fish's single-quoted string
41
+ expect(s).toContain("Show a repo'\\''s git status");
42
+ expect(s).toContain("-n '__fish_seen_subcommand_from status st doctor doc' -a '(nemus completion --workspaces)'");
43
+ });
44
+ });
45
+
46
+ describe('specsFromProgram', () => {
47
+ it('detects workspace args + aliases from a commander program', () => {
48
+ const program = new Command();
49
+ program.command('list').alias('l').description('list');
50
+ program.command('status [workspace]').alias('st').description('status');
51
+ program.command('create').description('create');
52
+
53
+ const out = specsFromProgram(program);
54
+ const byName = Object.fromEntries(out.map((s) => [s.name, s]));
55
+ expect(byName.status.takesWorkspace).toBe(true);
56
+ expect(byName.status.aliases).toEqual(['st']);
57
+ expect(byName.list.takesWorkspace).toBe(false);
58
+ expect(byName.create.takesWorkspace).toBe(false);
59
+ });
60
+ });
@@ -0,0 +1,160 @@
1
+ import { Command } from 'commander';
2
+ import { listWorkspaces } from '../utils/workspace-meta';
3
+ import { logError } from '../utils/logger';
4
+
5
+ /** Binaries that get completion registered (the CLI's bins). */
6
+ export const COMPLETION_BINS = ['nemus', 'nem'];
7
+
8
+ export type Shell = 'bash' | 'zsh' | 'fish';
9
+
10
+ /** One top-level command, distilled to what a completion script needs. */
11
+ export interface CommandSpec {
12
+ name: string;
13
+ aliases: string[];
14
+ /** True if its first positional argument is a workspace name. */
15
+ takesWorkspace: boolean;
16
+ description: string;
17
+ }
18
+
19
+ /** Every token (name + aliases) that should complete as a subcommand. */
20
+ function allTokens(cmds: CommandSpec[]): string[] {
21
+ return cmds.flatMap((c) => [c.name, ...c.aliases]);
22
+ }
23
+
24
+ /** Tokens (names + aliases) of the commands that take a workspace argument. */
25
+ function workspaceTokens(cmds: CommandSpec[]): string[] {
26
+ return cmds.filter((c) => c.takesWorkspace).flatMap((c) => [c.name, ...c.aliases]);
27
+ }
28
+
29
+ /** Escape a description for a fish single-quoted string. */
30
+ function fishDesc(s: string): string {
31
+ return s.replace(/\n/g, ' ').replace(/'/g, "'\\''");
32
+ }
33
+
34
+ /**
35
+ * Generate a shell completion script. Pure (no I/O) so it's unit-tested. The
36
+ * generated script completes subcommands at position 1, and for a subcommand
37
+ * that takes a workspace it completes workspace names by calling back into the
38
+ * CLI: `<bin> completion --workspaces`. Dynamic values stay fresh without
39
+ * regenerating the script.
40
+ */
41
+ export function generateCompletion(shell: Shell, cmds: CommandSpec[], bins: string[] = COMPLETION_BINS): string {
42
+ const commands = allTokens(cmds).join(' ');
43
+ const wsCommands = workspaceTokens(cmds).join(' ');
44
+
45
+ if (shell === 'bash') {
46
+ return `# nemus bash completion. Install: nemus completion bash > /etc/bash_completion.d/nemus
47
+ # (or: nemus completion bash >> ~/.bashrc)
48
+ _nemus_complete() {
49
+ local cur bin sub
50
+ # bash does not clear COMPREPLY between completions; reset so a stale result
51
+ # from a previous TAB can't leak when we return without setting it.
52
+ COMPREPLY=()
53
+ cur="\${COMP_WORDS[COMP_CWORD]}"
54
+ bin="\${COMP_WORDS[0]}"
55
+ local commands="${commands}"
56
+ local ws_commands="${wsCommands}"
57
+ if [ "\$COMP_CWORD" -eq 1 ]; then
58
+ COMPREPLY=( \$(compgen -W "\$commands" -- "\$cur") )
59
+ return 0
60
+ fi
61
+ if [ "\$COMP_CWORD" -eq 2 ]; then
62
+ sub="\${COMP_WORDS[1]}"
63
+ if [[ " \$ws_commands " == *" \$sub "* ]]; then
64
+ local names
65
+ names="\$("\$bin" completion --workspaces 2>/dev/null)"
66
+ COMPREPLY=( \$(compgen -W "\$names" -- "\$cur") )
67
+ return 0
68
+ fi
69
+ fi
70
+ return 0
71
+ }
72
+ ${bins.map((b) => `complete -F _nemus_complete ${b}`).join('\n')}
73
+ `;
74
+ }
75
+
76
+ if (shell === 'zsh') {
77
+ // Autoloaded form: save as a file named `_nemus` on your $fpath.
78
+ return `#compdef ${bins.join(' ')}
79
+ # nemus zsh completion. Install: nemus completion zsh > "\${fpath[1]}/_nemus"
80
+ local -a _nemus_commands
81
+ _nemus_commands=(${allTokens(cmds).map((t) => `'${t}'`).join(' ')})
82
+ local _nemus_ws_commands="${wsCommands}"
83
+ if (( CURRENT == 2 )); then
84
+ compadd -- $_nemus_commands
85
+ return
86
+ fi
87
+ if (( CURRENT == 3 )); then
88
+ local sub=\${words[2]}
89
+ if [[ " $_nemus_ws_commands " == *" $sub "* ]]; then
90
+ local -a _nemus_names
91
+ _nemus_names=(\${(f)"$(\${words[1]} completion --workspaces 2>/dev/null)"})
92
+ compadd -- $_nemus_names
93
+ fi
94
+ fi
95
+ `;
96
+ }
97
+
98
+ // fish
99
+ const lines: string[] = ['# nemus fish completion. Install: nemus completion fish > ~/.config/fish/completions/nemus.fish'];
100
+ for (const bin of bins) {
101
+ lines.push(`complete -c ${bin} -f`);
102
+ for (const c of cmds) {
103
+ for (const tok of [c.name, ...c.aliases]) {
104
+ lines.push(`complete -c ${bin} -n __fish_use_subcommand -a '${tok}' -d '${fishDesc(c.description)}'`);
105
+ }
106
+ }
107
+ const wsToks = workspaceTokens(cmds).join(' ');
108
+ if (wsToks) {
109
+ lines.push(
110
+ `complete -c ${bin} -n '__fish_seen_subcommand_from ${wsToks}' -a '(${bin} completion --workspaces)'`,
111
+ );
112
+ }
113
+ }
114
+ return lines.join('\n') + '\n';
115
+ }
116
+
117
+ /** Distill the program's top-level commands into CommandSpecs. */
118
+ export function specsFromProgram(program: Command): CommandSpec[] {
119
+ return program.commands
120
+ .map((c) => {
121
+ const args = (c as any).registeredArguments ?? [];
122
+ const firstArg: string | undefined = args[0]?.name?.();
123
+ return {
124
+ name: c.name(),
125
+ aliases: c.aliases(),
126
+ takesWorkspace: typeof firstArg === 'string' && firstArg.toLowerCase().includes('workspace'),
127
+ description: c.description() ?? '',
128
+ };
129
+ })
130
+ // The completion command itself and any hidden helper needn't clutter, but
131
+ // keeping them is harmless; only drop entries with no name.
132
+ .filter((s) => s.name);
133
+ }
134
+
135
+ export function registerCompletionCommand(program: Command) {
136
+ program
137
+ .command('completion [shell]')
138
+ .description('Output a shell completion script (bash|zsh|fish)')
139
+ .option('--workspaces', 'Print workspace names (used internally by completion scripts)')
140
+ .action(async (shell: string | undefined, opts: { workspaces?: boolean }) => {
141
+ // Data helper the generated scripts call back into.
142
+ if (opts.workspaces) {
143
+ try {
144
+ const workspaces = await listWorkspaces(false);
145
+ for (const ws of workspaces) process.stdout.write(ws.name + '\n');
146
+ } catch {
147
+ // Silent: completion must never error out the user's shell.
148
+ }
149
+ return;
150
+ }
151
+
152
+ const shells: Shell[] = ['bash', 'zsh', 'fish'];
153
+ if (!shell || !shells.includes(shell as Shell)) {
154
+ logError(`completion: specify a shell — one of ${shells.join(', ')}`);
155
+ logError('e.g. nemus completion bash');
156
+ process.exit(1);
157
+ }
158
+ process.stdout.write(generateCompletion(shell as Shell, specsFromProgram(program)));
159
+ });
160
+ }