@nemus-cli/nemus 0.5.0 → 0.9.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.
@@ -1,15 +1,21 @@
1
1
  import { Command } from 'commander';
2
- import { logError, logInfo, logStep } from '../utils/logger';
2
+ import { logError, logInfo, logStep, logWarning } from '../utils/logger';
3
3
  import { outputJson, outputJsonError } from '../utils/output';
4
4
  import { colorize } from '../utils/colors';
5
5
  import {
6
6
  gatherReflectionCorpus,
7
7
  parseReflectionReport,
8
8
  saveReflectionReport,
9
+ renderReportMarkdown,
10
+ severitySummary,
11
+ groupRecommendations,
12
+ listSavedReports,
13
+ findSavedMatches,
9
14
  REFLECT_SCHEMA,
10
15
  ReflectionReport,
11
16
  ReflectProgress,
12
17
  Recommendation,
18
+ GroupBy,
13
19
  } from '../utils/reflect';
14
20
  import { analyzeCorpus, buildAnalysisPrompt } from '../utils/reflect-analyze';
15
21
  import { runAgentJsonAsync } from '../utils/agent-judge';
@@ -18,31 +24,63 @@ export function registerReflectCommand(parent: Command) {
18
24
  parent
19
25
  .command('reflect')
20
26
  .alias('retro')
21
- .description('Analyze your recent workspace sessions and suggest skill/prompt/context improvements (LLM-as-a-judge)')
27
+ .description('Analyze recent sessions for improvements, or review saved reports ("history"/"show")')
28
+ .argument('[subcommand]', '"history" or "show" — omit to run a new analysis')
29
+ .argument('[id]', 'report id when using "show" (default: latest)')
22
30
  .option('-n, --limit <n>', 'How many recent workspaces to analyze', '10')
23
31
  .option('-w, --workspace <name>', 'Analyze a single workspace by name (ignores --limit)')
24
32
  .option('--model <model>', 'Judge model override (agent-native pattern/id)')
25
33
  .option('--thinking <level>', 'Judge thinking level for pi: off|minimal|low|medium|high|xhigh|max')
26
34
  .option('--json', 'Output the report as JSON')
35
+ .option('--markdown', 'Output the report as Markdown (paste into an issue/PR)')
36
+ .option('--group-by <how>', 'Group recommendations by: priority | kind', 'priority')
27
37
  .option('--no-save', 'Do not save the report to ~/.nemus/reflect/')
28
38
  .option('--dry-run', 'Print the assembled corpus + judge prompt without calling the agent')
29
- .action(async (opts) => {
30
- await handleReflect(opts);
39
+ .addHelpText(
40
+ 'after',
41
+ '\nSaved reports:\n nemus reflect history List saved reports (newest first)\n nemus reflect show [id] Print a saved report (default: latest)\n',
42
+ )
43
+ // history/show are positional (not commander subcommands) on purpose: as
44
+ // subcommands they would share --json/--markdown/--group-by with this parent
45
+ // command, and the only commander fix (enablePositionalOptions on the root)
46
+ // breaks global flags placed after a subcommand (e.g. `nemus list --quiet`).
47
+ .action(async (subcommand: string | undefined, id: string | undefined, opts) => {
48
+ if (subcommand === 'history') return handleHistory(opts);
49
+ if (subcommand === 'show') return handleShow(id, opts);
50
+ if (subcommand !== undefined) {
51
+ const msg = `Unknown reflect subcommand "${subcommand}" (expected "history" or "show").`;
52
+ if (opts.json) outputJsonError(msg);
53
+ else logError(msg);
54
+ process.exit(1);
55
+ }
56
+ return handleReflect(opts);
31
57
  });
32
58
  }
33
59
 
60
+ /** Validate --group-by; returns the value or exits non-zero with a clear error. */
61
+ function resolveGroupBy(raw: string | undefined, json?: boolean): GroupBy {
62
+ if (raw === undefined || raw === 'priority' || raw === 'kind') return (raw ?? 'priority') as GroupBy;
63
+ const msg = `--group-by must be "priority" or "kind"; got "${raw}"`;
64
+ if (json) outputJsonError(msg);
65
+ else logError(msg);
66
+ process.exit(1);
67
+ }
68
+
34
69
  async function handleReflect(opts: {
35
70
  limit?: string;
36
71
  workspace?: string;
37
72
  model?: string;
38
73
  thinking?: string;
39
74
  json?: boolean;
75
+ markdown?: boolean;
76
+ groupBy?: string;
40
77
  save?: boolean; // commander sets `save: false` for --no-save
41
78
  dryRun?: boolean;
42
79
  }) {
43
80
  const limit = Math.max(1, Number.parseInt(opts.limit ?? '10', 10) || 10);
81
+ const groupBy = resolveGroupBy(opts.groupBy, opts.json);
44
82
  try {
45
- const showProgress = !opts.json && !opts.dryRun;
83
+ const showProgress = !opts.json && !opts.markdown && !opts.dryRun;
46
84
  if (showProgress) {
47
85
  logStep(
48
86
  opts.workspace
@@ -85,7 +123,7 @@ async function handleReflect(opts: {
85
123
  const timeoutMs = Number.parseInt(process.env.NEMUS_JUDGE_TIMEOUT_MS ?? '', 10) || undefined;
86
124
  const model = opts.model ?? process.env.NEMUS_JUDGE_MODEL ?? undefined;
87
125
  const thinking = opts.thinking ?? process.env.NEMUS_JUDGE_THINKING ?? undefined;
88
- const stopSpinner = opts.json
126
+ const stopSpinner = opts.json || opts.markdown
89
127
  ? () => {}
90
128
  : startSpinner(`Judging ${withSessions} session(s) with your configured agent (this can take a minute)…`);
91
129
  let parsed: unknown;
@@ -114,7 +152,25 @@ async function handleReflect(opts: {
114
152
  outputJson({ analyzed: withSessions, workspaces: corpus.workspaces.length, ...report, savedTo });
115
153
  return;
116
154
  }
117
- printReport(report, corpus.workspaces.length, withSessions);
155
+ if (opts.markdown) {
156
+ // DATA channel: markdown to stdout, nothing else (a 'Saved report' note
157
+ // would corrupt a redirected .md file), so surface the path on stderr.
158
+ process.stdout.write(
159
+ renderReportMarkdown(
160
+ report,
161
+ {
162
+ analyzed: withSessions,
163
+ workspaces: corpus.workspaces.length,
164
+ workspace: opts.workspace,
165
+ generatedAt: new Date().toISOString(),
166
+ },
167
+ groupBy,
168
+ ),
169
+ );
170
+ if (savedTo) logInfo(`Saved report to ${colorize(savedTo, 'dim')}`);
171
+ return;
172
+ }
173
+ printReport(report, corpus.workspaces.length, withSessions, groupBy);
118
174
  if (savedTo) logInfo(`Saved report to ${colorize(savedTo, 'dim')}`);
119
175
  } catch (error) {
120
176
  const msg = error instanceof Error ? error.message : 'reflect failed';
@@ -183,7 +239,7 @@ function priorityBadge(p: Recommendation['priority']): string {
183
239
  return colorize('● low', 'gray');
184
240
  }
185
241
 
186
- function printReport(report: ReflectionReport, workspaces: number, analyzed: number) {
242
+ function printReport(report: ReflectionReport, workspaces: number, analyzed: number, groupBy: GroupBy = 'priority') {
187
243
  console.log('');
188
244
  console.log(colorize(' Reflection', 'bright') + colorize(` (${analyzed} sessions across ${workspaces} workspaces)`, 'dim'));
189
245
  console.log(colorize(' ' + '─'.repeat(56), 'dim'));
@@ -196,19 +252,99 @@ function printReport(report: ReflectionReport, workspaces: number, analyzed: num
196
252
  return;
197
253
  }
198
254
 
199
- // High priority first.
200
- const order = { high: 0, medium: 1, low: 2 };
201
- const recs = [...report.recommendations].sort((a, b) => order[a.priority] - order[b.priority]);
255
+ console.log('\n ' + colorize(severitySummary(report.recommendations), 'dim'));
202
256
 
203
- console.log('');
204
- for (const r of recs) {
205
- const target = r.target ? colorize(` [${r.target}]`, 'cyan') : '';
206
- console.log(` ${priorityBadge(r.priority)} ${colorize(KIND_LABEL[r.kind], 'bright')} ${r.title}${target}`);
207
- if (r.detail) console.log(` ${r.detail.replace(/\n/g, '\n ')}`);
208
- if (r.example) {
209
- console.log(colorize(' example:', 'dim'));
210
- console.log(colorize(r.example.replace(/^/gm, ' '), 'dim'));
257
+ for (const group of groupRecommendations(report.recommendations, groupBy)) {
258
+ console.log('\n ' + colorize(group.heading, 'bright'));
259
+ for (const r of group.recs) {
260
+ const target = r.target ? colorize(` [${r.target}]`, 'cyan') : '';
261
+ // Under a kind heading show the priority badge; under a priority heading
262
+ // show the kind label (the heading conveys the other axis).
263
+ const lead = groupBy === 'kind' ? priorityBadge(r.priority) : colorize(KIND_LABEL[r.kind], 'bright');
264
+ console.log(` ${lead} ${r.title}${target}`);
265
+ if (r.detail) console.log(` ${r.detail.replace(/\n/g, '\n ')}`);
266
+ if (r.example) {
267
+ console.log(colorize(' example:', 'dim'));
268
+ console.log(colorize(r.example.replace(/^/gm, ' '), 'dim'));
269
+ }
211
270
  }
212
- console.log('');
213
271
  }
272
+ console.log('');
273
+ }
274
+
275
+ async function handleHistory(opts: { json?: boolean }) {
276
+ const reports = await listSavedReports();
277
+ if (opts.json) {
278
+ outputJson({
279
+ count: reports.length,
280
+ reports: reports.map((r) => ({
281
+ id: r.id,
282
+ generatedAt: r.generatedAt,
283
+ analyzed: r.analyzed,
284
+ workspaces: r.workspaces,
285
+ workspace: r.workspace,
286
+ recommendations: r.report.recommendations.length,
287
+ severity: severitySummary(r.report.recommendations),
288
+ })),
289
+ });
290
+ return;
291
+ }
292
+ if (reports.length === 0) {
293
+ logInfo('No saved reflection reports yet. Run `nemus reflect` to create one.');
294
+ return;
295
+ }
296
+ console.log('');
297
+ console.log(colorize(' Saved reflections', 'bright') + colorize(` (${reports.length})`, 'dim'));
298
+ console.log(colorize(' ' + '─'.repeat(56), 'dim'));
299
+ for (const r of reports) {
300
+ const when = r.generatedAt ? new Date(r.generatedAt).toLocaleString() : r.id;
301
+ const scope = r.workspace ? colorize(` ${r.workspace}`, 'cyan') : colorize(` ${r.analyzed} sessions`, 'dim');
302
+ const sev = r.report.recommendations.length
303
+ ? colorize(` ${severitySummary(r.report.recommendations)}`, 'dim')
304
+ : colorize(' no recs', 'green');
305
+ console.log(` ${colorize(r.id, 'bright')}${scope}${sev}`);
306
+ console.log(colorize(` ${when}`, 'dim'));
307
+ }
308
+ console.log('');
309
+ console.log(colorize(' nemus reflect show <id> (or `latest`)', 'dim'));
310
+ }
311
+
312
+ async function handleShow(
313
+ id: string | undefined,
314
+ opts: { json?: boolean; markdown?: boolean; groupBy?: string },
315
+ ) {
316
+ const groupBy = resolveGroupBy(opts.groupBy, opts.json);
317
+ const matches = findSavedMatches(await listSavedReports(), id);
318
+ const saved = matches[0];
319
+ if (!saved) {
320
+ const msg = id && id !== 'latest'
321
+ ? `No saved report matching "${id}". Run "nemus reflect history" to list them.`
322
+ : 'No saved reflection reports yet. Run "nemus reflect" to create one.';
323
+ if (opts.json) outputJsonError(msg);
324
+ else logError(msg);
325
+ process.exit(1);
326
+ }
327
+ // An id-prefix that matches several reports resolves to the newest — say so
328
+ // (stderr only, so --json/--markdown stdout stays clean) rather than quietly
329
+ // showing a possibly-unintended report. An exact id / "latest" never multi-matches.
330
+ if (matches.length > 1 && !opts.json) {
331
+ logWarning(
332
+ `"${id}" matched ${matches.length} reports; showing the newest (${saved.id}). Use a longer id to disambiguate.`,
333
+ );
334
+ }
335
+ const meta = {
336
+ analyzed: saved.analyzed,
337
+ workspaces: saved.workspaces,
338
+ workspace: saved.workspace,
339
+ generatedAt: saved.generatedAt,
340
+ };
341
+ if (opts.json) {
342
+ outputJson({ id: saved.id, ...meta, ...saved.report });
343
+ return;
344
+ }
345
+ if (opts.markdown) {
346
+ process.stdout.write(renderReportMarkdown(saved.report, meta, groupBy));
347
+ return;
348
+ }
349
+ printReport(saved.report, saved.workspaces, saved.analyzed, groupBy);
214
350
  }
package/src/program.ts CHANGED
@@ -53,6 +53,7 @@ import { registerArchiveCommand } from './commands/archive';
53
53
  import { registerSessionsCommand } from './commands/sessions';
54
54
  import { registerGenerateDocsCommand } from './commands/generate-docs';
55
55
  import { registerConfigureCommand } from './commands/configure';
56
+ import { registerConfigCommand } from './commands/config';
56
57
  import { registerConfigureClaudeCommand } from './commands/configure-claude';
57
58
  import { registerGhqStatusCommand } from './commands/ghq-status';
58
59
  import { registerSaveContextCommand } from './commands/save-context';
@@ -79,6 +80,7 @@ registerArchiveCommand(program);
79
80
  registerSessionsCommand(program);
80
81
  registerGenerateDocsCommand(program);
81
82
  registerConfigureCommand(program);
83
+ registerConfigCommand(program);
82
84
  registerConfigureClaudeCommand(program);
83
85
  registerGhqStatusCommand(program);
84
86
  registerSaveContextCommand(program);
@@ -0,0 +1,160 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { CONFIG_DEFAULTS, UserConfig } from './config';
3
+ import {
4
+ CONFIG_KEYS,
5
+ CONFIG_SCHEMA,
6
+ isConfigKey,
7
+ parseConfigValue,
8
+ applyConfigSet,
9
+ applyConfigUnset,
10
+ validateTypedValue,
11
+ reviewConfigFileText,
12
+ formatConfigValue,
13
+ } from './config-schema';
14
+
15
+ describe('config schema coverage', () => {
16
+ it('describes every UserConfig key exactly once', () => {
17
+ expect(CONFIG_KEYS.slice().sort()).toEqual(Object.keys(CONFIG_DEFAULTS).sort());
18
+ });
19
+ it('is sorted for stable listing', () => {
20
+ expect(CONFIG_KEYS).toEqual(CONFIG_KEYS.slice().sort());
21
+ });
22
+ });
23
+
24
+ describe('isConfigKey', () => {
25
+ it('accepts known keys, rejects unknown / prototype keys', () => {
26
+ expect(isConfigKey('githubOrg')).toBe(true);
27
+ expect(isConfigKey('nope')).toBe(false);
28
+ expect(isConfigKey('toString')).toBe(false); // not an own property
29
+ expect(isConfigKey('')).toBe(false);
30
+ });
31
+ });
32
+
33
+ describe('parseConfigValue', () => {
34
+ it('coerces booleans from common words (case-insensitive)', () => {
35
+ for (const t of ['true', '1', 'YES', 'on', 'y']) {
36
+ expect(parseConfigValue('autoReportBugs', t)).toEqual({ ok: true, value: true });
37
+ }
38
+ for (const f of ['false', '0', 'No', 'off', 'n']) {
39
+ expect(parseConfigValue('autoReportBugs', f)).toEqual({ ok: true, value: false });
40
+ }
41
+ });
42
+ it('rejects non-boolean words with a helpful error', () => {
43
+ const r = parseConfigValue('installMcp', 'maybe');
44
+ expect(r.ok).toBe(false);
45
+ if (!r.ok) expect(r.error).toMatch(/boolean/);
46
+ });
47
+ it('validates enums', () => {
48
+ expect(parseConfigValue('cloneProtocol', 'https')).toEqual({ ok: true, value: 'https' });
49
+ const r = parseConfigValue('cloneProtocol', 'ftp');
50
+ expect(r.ok).toBe(false);
51
+ if (!r.ok) expect(r.error).toMatch(/ssh, https/);
52
+ });
53
+ it('normalizes enum case + whitespace to the canonical value', () => {
54
+ expect(parseConfigValue('cloneProtocol', 'HTTPS')).toEqual({ ok: true, value: 'https' });
55
+ expect(parseConfigValue('cloneProtocol', ' ssh ')).toEqual({ ok: true, value: 'ssh' });
56
+ expect(parseConfigValue('aiAgent', 'Both')).toEqual({ ok: true, value: 'both' });
57
+ });
58
+ it('trims surrounding whitespace on strings (case preserved)', () => {
59
+ expect(parseConfigValue('githubOrg', ' Acme-Corp ')).toEqual({ ok: true, value: 'Acme-Corp' });
60
+ expect(parseConfigValue('githubOrg', ' ')).toEqual({ ok: true, value: '' }); // trims to empty (allowed)
61
+ expect(parseConfigValue('workspacesDir', ' ').ok).toBe(false); // required, empty after trim
62
+ });
63
+ it('accepts agent enum values incl. auto/both', () => {
64
+ expect(parseConfigValue('aiAgent', 'both').ok).toBe(true);
65
+ expect(parseConfigValue('primaryAgent', 'both').ok).toBe(false); // no "both" for primary
66
+ expect(parseConfigValue('primaryAgent', 'pi').ok).toBe(true);
67
+ });
68
+ it('rejects empty required strings but allows empty githubOrg', () => {
69
+ expect(parseConfigValue('workspacesDir', '').ok).toBe(false);
70
+ expect(parseConfigValue('githubOrg', '')).toEqual({ ok: true, value: '' });
71
+ });
72
+ });
73
+
74
+ describe('applyConfigSet / applyConfigUnset (pure, immutable)', () => {
75
+ const base: UserConfig = { ...CONFIG_DEFAULTS, githubOrg: 'acme' };
76
+
77
+ it('returns a new object and does not mutate the input', () => {
78
+ const r = applyConfigSet(base, 'githubOrg', 'octocat');
79
+ expect(r.ok).toBe(true);
80
+ if (r.ok) {
81
+ expect(r.next.githubOrg).toBe('octocat');
82
+ expect(r.value).toBe('octocat');
83
+ }
84
+ expect(base.githubOrg).toBe('acme'); // unchanged
85
+ });
86
+
87
+ it('rejects unknown keys', () => {
88
+ const r = applyConfigSet(base, 'bogus', 'x');
89
+ expect(r.ok).toBe(false);
90
+ if (!r.ok) expect(r.error).toMatch(/Unknown config key/);
91
+ });
92
+
93
+ it('unset resets to the default value', () => {
94
+ const r = applyConfigUnset(base, 'githubOrg');
95
+ expect(r.ok).toBe(true);
96
+ if (r.ok) expect(r.next.githubOrg).toBe(CONFIG_DEFAULTS.githubOrg);
97
+ });
98
+ });
99
+
100
+ describe('validateTypedValue (already-typed, as from the JSON file)', () => {
101
+ it('accepts correctly-typed values', () => {
102
+ expect(validateTypedValue('autoReportBugs', true)).toEqual({ ok: true });
103
+ expect(validateTypedValue('cloneProtocol', 'https')).toEqual({ ok: true });
104
+ expect(validateTypedValue('githubOrg', '')).toEqual({ ok: true }); // allowEmpty
105
+ expect(validateTypedValue('workspacesDir', '/x')).toEqual({ ok: true });
106
+ });
107
+ it('rejects wrong types and bad enum/empty values', () => {
108
+ expect(validateTypedValue('autoReportBugs', 'yes').ok).toBe(false); // string, not boolean
109
+ expect(validateTypedValue('cloneProtocol', 'ftp').ok).toBe(false);
110
+ expect(validateTypedValue('cloneProtocol', 42 as unknown).ok).toBe(false);
111
+ expect(validateTypedValue('workspacesDir', ' ').ok).toBe(false); // required, blank
112
+ expect(validateTypedValue('installMcp', 1 as unknown).ok).toBe(false);
113
+ });
114
+ });
115
+
116
+ describe('reviewConfigFileText (for `config edit`)', () => {
117
+ it('flags unparseable JSON', () => {
118
+ const r = reviewConfigFileText('{ not json');
119
+ expect(r.parseError).toBe(true);
120
+ expect(r.ok).toBe(false);
121
+ });
122
+ it('flags non-object JSON (null / array / scalar) without throwing', () => {
123
+ for (const t of ['null', '42', '"str"', '[1,2]']) {
124
+ const r = reviewConfigFileText(t);
125
+ expect(r.notObject).toBe(true);
126
+ expect(r.ok).toBe(false);
127
+ expect(r.unknownKeys).toEqual([]); // no numeric-index "keys" from an array
128
+ }
129
+ });
130
+ it('reports unknown keys but stays ok if known values are valid', () => {
131
+ const r = reviewConfigFileText(JSON.stringify({ githubOrg: 'acme', bogus: 1, nope: true }));
132
+ expect(r.unknownKeys.sort()).toEqual(['bogus', 'nope']);
133
+ expect(r.ok).toBe(true);
134
+ });
135
+ it('catches invalid VALUES the same way config set would', () => {
136
+ const r = reviewConfigFileText(JSON.stringify({ cloneProtocol: 'ftp', autoReportBugs: 'yes' }));
137
+ expect(r.ok).toBe(false);
138
+ expect(r.invalid.join('\n')).toMatch(/cloneProtocol must be one of/);
139
+ expect(r.invalid.join('\n')).toMatch(/autoReportBugs must be a boolean/);
140
+ });
141
+ it('accepts a clean object', () => {
142
+ const r = reviewConfigFileText(JSON.stringify({ cloneProtocol: 'https', installMcp: true }));
143
+ expect(r).toMatchObject({ parseError: false, notObject: false, unknownKeys: [], invalid: [], ok: true });
144
+ });
145
+ });
146
+
147
+ describe('formatConfigValue', () => {
148
+ it('renders booleans and empty strings predictably', () => {
149
+ expect(formatConfigValue(true)).toBe('true');
150
+ expect(formatConfigValue(false)).toBe('false');
151
+ expect(formatConfigValue('')).toBe('');
152
+ expect(formatConfigValue('ssh')).toBe('ssh');
153
+ });
154
+ });
155
+
156
+ describe('CONFIG_SCHEMA describe text', () => {
157
+ it('every key has a non-empty description', () => {
158
+ for (const k of CONFIG_KEYS) expect(CONFIG_SCHEMA[k].describe.length).toBeGreaterThan(0);
159
+ });
160
+ });
@@ -0,0 +1,177 @@
1
+ import { UserConfig, CONFIG_DEFAULTS } from './config';
2
+
3
+ /**
4
+ * Declarative schema for the non-interactive `nemus config get/set` command.
5
+ * Every writable UserConfig field is described here with its type + allowed
6
+ * values, so `set` can validate/coerce a string argument and `get`/`list` can
7
+ * enumerate keys. Kept as pure data + pure functions so it's fully unit-tested
8
+ * without touching disk. Keep this in sync with the UserConfig interface — the
9
+ * `satisfies` check below fails the build if a key is misspelled.
10
+ */
11
+ export type ConfigKey = keyof UserConfig;
12
+
13
+ type FieldSpec =
14
+ | { type: 'string'; allowEmpty?: boolean; describe: string }
15
+ | { type: 'boolean'; describe: string }
16
+ | { type: 'enum'; values: readonly string[]; describe: string };
17
+
18
+ const AGENT_VALUES = ['claude', 'pi', 'opencode', 'codex', 'gemini'] as const;
19
+
20
+ export const CONFIG_SCHEMA = {
21
+ workspacesDir: { type: 'string', describe: 'Directory where workspaces are created' },
22
+ githubOrg: { type: 'string', allowEmpty: true, describe: 'Default GitHub org for repo lookups' },
23
+ cloneProtocol: { type: 'enum', values: ['ssh', 'https'], describe: 'Protocol used to clone repos' },
24
+ aiAgent: {
25
+ type: 'enum',
26
+ values: [...AGENT_VALUES, 'both', 'auto'],
27
+ describe: 'AI agent(s) to integrate with',
28
+ },
29
+ primaryAgent: {
30
+ type: 'enum',
31
+ values: [...AGENT_VALUES, 'auto'],
32
+ describe: 'Agent launched when opening a workspace',
33
+ },
34
+ autoLaunchClaude: { type: 'boolean', describe: 'Auto-launch the agent after creating a workspace' },
35
+ generateClaudeContext: { type: 'boolean', describe: 'Generate agent context files (AGENTS.md)' },
36
+ installMcp: { type: 'boolean', describe: 'Install the MCP server during configure' },
37
+ piWorkspaceInputStatus: { type: 'boolean', describe: "Show workspace status in Pi's input area" },
38
+ claudeWorkspaceStatusLine: { type: 'boolean', describe: "Show workspace table in Claude's status line" },
39
+ autoReportBugs: { type: 'boolean', describe: 'Auto-file a GitHub issue when a command crashes' },
40
+ } satisfies Record<ConfigKey, FieldSpec>;
41
+
42
+ export const CONFIG_KEYS = Object.keys(CONFIG_SCHEMA).sort() as ConfigKey[];
43
+
44
+ const TRUE_WORDS = new Set(['true', '1', 'yes', 'on', 'y']);
45
+ const FALSE_WORDS = new Set(['false', '0', 'no', 'off', 'n']);
46
+
47
+ /** True if `key` is a writable config key. */
48
+ export function isConfigKey(key: string): key is ConfigKey {
49
+ return Object.prototype.hasOwnProperty.call(CONFIG_SCHEMA, key);
50
+ }
51
+
52
+ export type ParseResult =
53
+ | { ok: true; value: UserConfig[ConfigKey] }
54
+ | { ok: false; error: string };
55
+
56
+ /** Validate + coerce a raw string for `key` into the field's typed value. */
57
+ export function parseConfigValue(key: ConfigKey, raw: string): ParseResult {
58
+ const spec: FieldSpec = CONFIG_SCHEMA[key];
59
+ if (spec.type === 'boolean') {
60
+ const v = raw.trim().toLowerCase();
61
+ if (TRUE_WORDS.has(v)) return { ok: true, value: true };
62
+ if (FALSE_WORDS.has(v)) return { ok: true, value: false };
63
+ return { ok: false, error: `${key} expects a boolean (true/false); got "${raw}"` };
64
+ }
65
+ if (spec.type === 'enum') {
66
+ // Enum values are all lowercase, so normalize input like booleans do —
67
+ // `HTTPS` / ` https ` should resolve to the canonical value, not fail.
68
+ const v = raw.trim().toLowerCase();
69
+ if ((spec.values as readonly string[]).includes(v)) {
70
+ return { ok: true, value: v as UserConfig[ConfigKey] };
71
+ }
72
+ return { ok: false, error: `${key} must be one of: ${spec.values.join(', ')}; got "${raw}"` };
73
+ }
74
+ // string: trim surrounding whitespace (a stray space in a path/org is almost
75
+ // always a mistake), but preserve case.
76
+ const trimmed = raw.trim();
77
+ if (!spec.allowEmpty && trimmed === '') {
78
+ return { ok: false, error: `${key} cannot be empty` };
79
+ }
80
+ return { ok: true, value: trimmed };
81
+ }
82
+
83
+ /**
84
+ * Validate an already-typed value (as it appears in the JSON config file) for
85
+ * `key` against its field spec. This is the parse-free sibling of
86
+ * parseConfigValue (which coerces a CLI string): it checks that a boolean field
87
+ * holds a boolean, an enum holds an allowed string, and a string field holds a
88
+ * (non-empty, unless allowEmpty) string. Used by `config edit` so a hand-edit
89
+ * is validated the same way `config set` validates. Pure + unit-tested.
90
+ */
91
+ export function validateTypedValue(key: ConfigKey, value: unknown): { ok: true } | { ok: false; error: string } {
92
+ const spec: FieldSpec = CONFIG_SCHEMA[key];
93
+ if (spec.type === 'boolean') {
94
+ return typeof value === 'boolean' ? { ok: true } : { ok: false, error: `${key} must be a boolean` };
95
+ }
96
+ if (spec.type === 'enum') {
97
+ return typeof value === 'string' && (spec.values as readonly string[]).includes(value)
98
+ ? { ok: true }
99
+ : { ok: false, error: `${key} must be one of: ${spec.values.join(', ')}` };
100
+ }
101
+ if (typeof value !== 'string') return { ok: false, error: `${key} must be a string` };
102
+ if (!spec.allowEmpty && value.trim() === '') return { ok: false, error: `${key} cannot be empty` };
103
+ return { ok: true };
104
+ }
105
+
106
+ /** Apply a `set` to a config object, returning a NEW config or an error. Pure. */
107
+ export function applyConfigSet(
108
+ current: UserConfig,
109
+ key: string,
110
+ raw: string,
111
+ ): { ok: true; next: UserConfig; value: UserConfig[ConfigKey] } | { ok: false; error: string } {
112
+ if (!isConfigKey(key)) {
113
+ return { ok: false, error: `Unknown config key "${key}". Run "nemus config list" to see valid keys.` };
114
+ }
115
+ const parsed = parseConfigValue(key, raw);
116
+ if (!parsed.ok) return parsed;
117
+ return { ok: true, next: { ...current, [key]: parsed.value }, value: parsed.value };
118
+ }
119
+
120
+ /** Reset a key to its default value, returning a NEW config or an error. Pure. */
121
+ export function applyConfigUnset(
122
+ current: UserConfig,
123
+ key: string,
124
+ ): { ok: true; next: UserConfig; value: UserConfig[ConfigKey] } | { ok: false; error: string } {
125
+ if (!isConfigKey(key)) {
126
+ return { ok: false, error: `Unknown config key "${key}". Run "nemus config list" to see valid keys.` };
127
+ }
128
+ const value = CONFIG_DEFAULTS[key];
129
+ return { ok: true, next: { ...current, [key]: value }, value };
130
+ }
131
+
132
+ /** Render a config value for plain (scriptable) stdout output. */
133
+ export function formatConfigValue(value: unknown): string {
134
+ return typeof value === 'boolean' ? String(value) : String(value ?? '');
135
+ }
136
+
137
+ export interface ConfigFileReview {
138
+ /** JSON.parse failed. */
139
+ parseError: boolean;
140
+ /** Parsed, but not a plain object (null / array / scalar). */
141
+ notObject: boolean;
142
+ /** Keys present in the file that aren't recognized config keys. */
143
+ unknownKeys: string[];
144
+ /** Validation error messages for known keys holding invalid values. */
145
+ invalid: string[];
146
+ /** True when the file is a usable config object (may still have warnings). */
147
+ ok: boolean;
148
+ }
149
+
150
+ /**
151
+ * Review the raw text of a hand-edited config file the same way `config set`
152
+ * validates: it must parse, be a plain object, only use known keys, and every
153
+ * known key present must hold a schema-valid value. Pure so `config edit` can be
154
+ * fully unit-tested without spawning an editor.
155
+ */
156
+ export function reviewConfigFileText(text: string): ConfigFileReview {
157
+ const base = { parseError: false, notObject: false, unknownKeys: [] as string[], invalid: [] as string[] };
158
+ let raw: unknown;
159
+ try {
160
+ raw = JSON.parse(text);
161
+ } catch {
162
+ return { ...base, parseError: true, ok: false };
163
+ }
164
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
165
+ return { ...base, notObject: true, ok: false };
166
+ }
167
+ const obj = raw as Record<string, unknown>;
168
+ const known = new Set<string>(CONFIG_KEYS);
169
+ const unknownKeys = Object.keys(obj).filter((k) => !known.has(k));
170
+ const invalid: string[] = [];
171
+ for (const key of CONFIG_KEYS) {
172
+ if (!(key in obj)) continue;
173
+ const res = validateTypedValue(key, obj[key]);
174
+ if (!res.ok) invalid.push(res.error);
175
+ }
176
+ return { parseError: false, notObject: false, unknownKeys, invalid, ok: invalid.length === 0 };
177
+ }
@@ -67,7 +67,7 @@ export interface UserConfig {
67
67
  autoReportBugs: boolean;
68
68
  }
69
69
 
70
- const DEFAULTS: UserConfig = {
70
+ export const CONFIG_DEFAULTS: UserConfig = {
71
71
  workspacesDir: path.join(HOME_DIR, 'workspaces'),
72
72
  githubOrg: '',
73
73
  autoLaunchClaude: true,
@@ -81,6 +81,9 @@ const DEFAULTS: UserConfig = {
81
81
  autoReportBugs: false,
82
82
  };
83
83
 
84
+ // Internal alias retained for the many references below.
85
+ const DEFAULTS = CONFIG_DEFAULTS;
86
+
84
87
  function loadConfigFileSync(): Partial<UserConfig> {
85
88
  try {
86
89
  const content = fs.readFileSync(CONFIG_FILE, 'utf-8');
@@ -0,0 +1,37 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { resolveEditor, openInEditor } from './editor';
3
+
4
+ describe('resolveEditor', () => {
5
+ it('prefers $VISUAL over $EDITOR', () => {
6
+ expect(resolveEditor({ VISUAL: 'code --wait', EDITOR: 'vim' }, 'darwin')).toEqual(['code', '--wait']);
7
+ });
8
+ it('falls back to $EDITOR, splitting flags', () => {
9
+ expect(resolveEditor({ EDITOR: 'emacs -nw' }, 'linux')).toEqual(['emacs', '-nw']);
10
+ });
11
+ it('platform default when neither is set', () => {
12
+ expect(resolveEditor({}, 'win32')).toEqual(['notepad']);
13
+ expect(resolveEditor({}, 'linux')).toEqual(['vi']);
14
+ expect(resolveEditor({ EDITOR: ' ' }, 'darwin')).toEqual(['vi']); // blank ignored
15
+ });
16
+ });
17
+
18
+ describe('openInEditor', () => {
19
+ it('launches editor argv[0] + flags + file, inheriting stdio', () => {
20
+ const spawn = vi.fn().mockReturnValue({ status: 0 }) as any;
21
+ const res = openInEditor('/tmp/config.json', { spawn, env: { EDITOR: 'code --wait' }, platform: 'darwin' });
22
+ expect(spawn).toHaveBeenCalledWith('code', ['--wait', '/tmp/config.json'], { stdio: 'inherit' });
23
+ expect(res).toEqual({ ok: true, editor: 'code', code: 0 });
24
+ });
25
+
26
+ it('reports a non-zero editor exit as not-ok', () => {
27
+ const spawn = vi.fn().mockReturnValue({ status: 1 }) as any;
28
+ expect(openInEditor('/f', { spawn, env: { EDITOR: 'vi' } }).ok).toBe(false);
29
+ });
30
+
31
+ it('reports a missing editor (ENOENT) with a clear message', () => {
32
+ const spawn = vi.fn().mockReturnValue({ error: Object.assign(new Error('x'), { code: 'ENOENT' }) }) as any;
33
+ const res = openInEditor('/f', { spawn, env: { EDITOR: 'nope' } });
34
+ expect(res.ok).toBe(false);
35
+ expect(res.error).toMatch(/not found/);
36
+ });
37
+ });