@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.
- package/CHANGELOG.md +46 -0
- package/README.md +52 -0
- package/dist/commands/config.js +195 -0
- package/dist/commands/reflect.js +141 -20
- package/dist/program.js +2 -0
- package/dist/utils/config-schema.js +145 -0
- package/dist/utils/config.js +4 -2
- package/dist/utils/editor.js +35 -0
- package/dist/utils/reflect.js +170 -1
- package/package.json +1 -1
- package/src/commands/config.ts +163 -0
- package/src/commands/reflect.ts +156 -20
- package/src/program.ts +2 -0
- package/src/utils/config-schema.test.ts +160 -0
- package/src/utils/config-schema.ts +177 -0
- package/src/utils/config.ts +4 -1
- package/src/utils/editor.test.ts +37 -0
- package/src/utils/editor.ts +48 -0
- package/src/utils/reflect.test.ts +129 -2
- package/src/utils/reflect.ts +201 -0
package/src/commands/reflect.ts
CHANGED
|
@@ -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
|
|
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
|
-
.
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
console.log(
|
|
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
|
+
}
|
package/src/utils/config.ts
CHANGED
|
@@ -67,7 +67,7 @@ export interface UserConfig {
|
|
|
67
67
|
autoReportBugs: boolean;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
const
|
|
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
|
+
});
|