@nemus-cli/nemus 0.4.0 → 0.8.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
@@ -1,28 +1,20 @@
1
1
  import { Command } from 'commander';
2
2
  import * as path from 'path';
3
3
  import * as fs from 'fs';
4
- import { colors } from './utils/colors';
4
+ import { setColorEnabled } from './utils/colors';
5
+ import { renderHelpBanner } from './utils/banner';
6
+ import { applyGlobalFlags } from './utils/global-flags';
5
7
 
6
8
  // Read version from package.json
7
9
  const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8'));
8
10
 
9
- const g = colors.green;
10
- const d = colors.dim;
11
- const b = colors.bright;
12
- const r = colors.reset;
13
-
14
- const INNER = 38;
15
- const titleLine = `>_ Nemus`;
16
- const titlePad = ' '.repeat(Math.max(0, INNER - 2 - titleLine.length));
17
- const versionLine = `v${pkg.version} · multi-repo workspaces`;
18
- const versionPad = ' '.repeat(Math.max(0, INNER - 7 - versionLine.length));
19
- const bar = '─'.repeat(INNER);
20
- const bannerText = `
21
- ${d} ╭${bar}╮${r}
22
- ${d} │${r} ${g}>_${r} ${b}Nemus${r}${titlePad}${d}│${r}
23
- ${d} │${r} ${d}${versionLine}${r}${versionPad}${d}│${r}
24
- ${d} ╰${bar}╯${r}
25
- `;
11
+ // --no-color must be applied BEFORE commander parses so it reaches the help
12
+ // banner (a preAction hook is too late for help output, and ES imports run
13
+ // before hooks). It's a long flag, so it can't be bundled — an argv scan is
14
+ // sufficient here. colors.ts already applied NO_COLOR / non-TTY at import.
15
+ // --quiet is handled in the preAction hook below (it only affects command
16
+ // logs, never help), which also catches bundled short forms like `-yq`.
17
+ if (process.argv.includes('--no-color')) setColorEnabled(false);
26
18
 
27
19
  export const program = new Command();
28
20
 
@@ -32,7 +24,15 @@ program
32
24
  .version(pkg.version, '-V, --version')
33
25
  .option('-f, --force-refresh', 'Force refresh GitHub repos (skip cache)')
34
26
  .option('-y, --yes', 'Skip confirmations')
35
- .addHelpText('before', bannerText);
27
+ .option('--no-color', 'Disable colored output (also honors NO_COLOR)')
28
+ .option('-q, --quiet', 'Suppress progress logs (keep warnings + errors)')
29
+ .addHelpText('before', () => renderHelpBanner(pkg.version));
30
+
31
+ // Apply global --quiet / --color from commander's PARSED options (robust to
32
+ // bundled short flags like `-yq` that a raw argv scan misses). Runs before every
33
+ // command action; help output doesn't reach here, which is why --no-color is
34
+ // also pre-scanned above.
35
+ program.hook('preAction', () => applyGlobalFlags(program.opts()));
36
36
 
37
37
  // Register top-level commands
38
38
  import { registerCreateCommand } from './commands/create';
@@ -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);
@@ -1,17 +1,5 @@
1
1
  import { colors } from './colors';
2
2
 
3
- const g = colors.green;
4
- const d = colors.dim;
5
- const b = colors.bright;
6
- const r = colors.reset;
7
-
8
- export const BANNER = `
9
- ${d} ╭──────────────────────────────────────╮${r}
10
- ${d} │${r} ${g}>_${r} ${b}Nemus${r} ${d}│${r}
11
- ${d} │${r} ${d}multi-repo workspaces${r} ${d}│${r}
12
- ${d} ╰──────────────────────────────────────╯${r}
13
- `;
14
-
15
3
  export const BANNER_PLAIN = `
16
4
  ╭──────────────────────────────────────╮
17
5
  │ >_ Nemus │
@@ -19,6 +7,37 @@ export const BANNER_PLAIN = `
19
7
  ╰──────────────────────────────────────╯
20
8
  `;
21
9
 
10
+ // Rendered live (not captured at import) so --no-color / NO_COLOR applied before
11
+ // this is called produce a plain banner. With color off, every colors.* is ''.
12
+ export const renderBanner = (): string => {
13
+ const { green: g, dim: d, bright: b, reset: r } = colors;
14
+ return `
15
+ ${d} ╭──────────────────────────────────────╮${r}
16
+ ${d} │${r} ${g}>_${r} ${b}Nemus${r} ${d}│${r}
17
+ ${d} │${r} ${d}multi-repo workspaces${r} ${d}│${r}
18
+ ${d} ╰──────────────────────────────────────╯${r}
19
+ `;
20
+ };
21
+
22
22
  export const printBanner = (): void => {
23
- console.log(BANNER);
23
+ console.log(renderBanner());
24
+ };
25
+
26
+ // The `--help` banner: a boxed splash with the version + tagline, width-fitted.
27
+ // Lives here alongside renderBanner() so the two renderers don't drift; also
28
+ // rendered live so --no-color / NO_COLOR yields a plain box.
29
+ export const renderHelpBanner = (version: string): string => {
30
+ const { green: g, dim: d, bright: b, reset: r } = colors;
31
+ const INNER = 38;
32
+ const titleLine = `>_ Nemus`;
33
+ const titlePad = ' '.repeat(Math.max(0, INNER - 2 - titleLine.length));
34
+ const versionLine = `v${version} · multi-repo workspaces`;
35
+ const versionPad = ' '.repeat(Math.max(0, INNER - 7 - versionLine.length));
36
+ const bar = '─'.repeat(INNER);
37
+ return `
38
+ ${d} ╭${bar}╮${r}
39
+ ${d} │${r} ${g}>_${r} ${b}Nemus${r}${titlePad}${d}│${r}
40
+ ${d} │${r} ${d}${versionLine}${r}${versionPad}${d}│${r}
41
+ ${d} ╰${bar}╯${r}
42
+ `;
24
43
  };
@@ -0,0 +1,51 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import { colors, colorize, setColorEnabled, isColorEnabled, detectColorEnabled } from './colors';
3
+
4
+ // Tests toggle global color state; restore a known state afterward.
5
+ afterEach(() => setColorEnabled(true));
6
+
7
+ describe('setColorEnabled / colorize', () => {
8
+ it('wraps with ANSI when on, and is plain when off (same call sites)', () => {
9
+ setColorEnabled(true);
10
+ expect(isColorEnabled()).toBe(true);
11
+ const on = colorize('hi', 'green');
12
+ expect(on).toContain('\x1b[32m');
13
+ expect(on).toContain('\x1b[0m');
14
+ expect(on).toContain('hi');
15
+
16
+ setColorEnabled(false);
17
+ expect(isColorEnabled()).toBe(false);
18
+ expect(colorize('hi', 'green')).toBe('hi'); // no codes at all
19
+ });
20
+
21
+ it('empties inline colors.* codes in place when disabled', () => {
22
+ setColorEnabled(false);
23
+ expect(colors.gray).toBe('');
24
+ expect(colors.reset).toBe('');
25
+ setColorEnabled(true);
26
+ expect(colors.gray).toBe('\x1b[90m');
27
+ });
28
+ });
29
+
30
+ describe('detectColorEnabled', () => {
31
+ it('NO_COLOR disables regardless of value (even empty)', () => {
32
+ expect(detectColorEnabled({ NO_COLOR: '1' }, true)).toBe(false);
33
+ expect(detectColorEnabled({ NO_COLOR: '' }, true)).toBe(false);
34
+ });
35
+
36
+ it('FORCE_COLOR forces on even without a TTY (but 0/false do not)', () => {
37
+ expect(detectColorEnabled({ FORCE_COLOR: '1' }, false)).toBe(true);
38
+ expect(detectColorEnabled({ FORCE_COLOR: '0' }, false)).toBe(false);
39
+ expect(detectColorEnabled({ FORCE_COLOR: 'false' }, false)).toBe(false);
40
+ });
41
+
42
+ it('NO_COLOR beats FORCE_COLOR', () => {
43
+ expect(detectColorEnabled({ NO_COLOR: '1', FORCE_COLOR: '1' }, true)).toBe(false);
44
+ });
45
+
46
+ it('otherwise follows the TTY, and TERM=dumb disables', () => {
47
+ expect(detectColorEnabled({}, true)).toBe(true);
48
+ expect(detectColorEnabled({}, false)).toBe(false);
49
+ expect(detectColorEnabled({ TERM: 'dumb' }, true)).toBe(false);
50
+ });
51
+ });
@@ -1,4 +1,7 @@
1
- export const colors = {
1
+ // ANSI escape codes. `colors` starts as a copy of these but is emptied in place
2
+ // when color is disabled (--no-color / NO_COLOR / non-TTY), so BOTH `colorize()`
3
+ // and inline `colors.x` template usage go plain without touching call sites.
4
+ const ANSI = {
2
5
  reset: '\x1b[0m',
3
6
  bright: '\x1b[1m',
4
7
  dim: '\x1b[2m',
@@ -18,8 +21,52 @@ export const colors = {
18
21
  bgGreen: '\x1b[42m',
19
22
  bgYellow: '\x1b[43m',
20
23
  bgBlue: '\x1b[44m',
21
- };
24
+ } as const;
25
+
26
+ export type ColorName = keyof typeof ANSI;
27
+
28
+ // Live map read at every use. Mutated in place by setColorEnabled so previously
29
+ // imported references (e.g. `colors.gray` inside a template) see the change.
30
+ export const colors: Record<ColorName, string> = { ...ANSI };
31
+
32
+ let enabled = true;
22
33
 
23
- export const colorize = (text: string, color: keyof typeof colors): string => {
34
+ /** Whether colored output is currently on. */
35
+ export const isColorEnabled = (): boolean => enabled;
36
+
37
+ /** Turn color on/off. When off, every `colors.*` code becomes '' so output is
38
+ * plain; when on, the ANSI codes are restored. */
39
+ export function setColorEnabled(on: boolean): void {
40
+ enabled = on;
41
+ for (const key of Object.keys(ANSI) as ColorName[]) {
42
+ colors[key] = on ? ANSI[key] : '';
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Decide whether to use color by default, following the common conventions:
48
+ * - `NO_COLOR` (any value, even empty) disables — see https://no-color.org
49
+ * - `FORCE_COLOR` (and not "0"/"false") forces it on, even when not a TTY
50
+ * - otherwise on only when the stdout stream is a TTY and `TERM` isn't `dumb`
51
+ */
52
+ export function detectColorEnabled(
53
+ env: NodeJS.ProcessEnv = process.env,
54
+ isTTY: boolean = !!process.stdout.isTTY,
55
+ ): boolean {
56
+ if ('NO_COLOR' in env) return false;
57
+ const force = env.FORCE_COLOR;
58
+ if (force !== undefined && force !== '' && force !== '0' && force.toLowerCase() !== 'false') {
59
+ return true;
60
+ }
61
+ if (env.TERM === 'dumb') return false;
62
+ return isTTY;
63
+ }
64
+
65
+ export const colorize = (text: string, color: ColorName): string => {
24
66
  return `${colors[color]}${text}${colors.reset}`;
25
67
  };
68
+
69
+ // Apply the environment default at import time so NO_COLOR / a non-TTY pipe is
70
+ // respected even before any CLI flag is parsed (an explicit --no-color / --color
71
+ // flag overrides this later).
72
+ setColorEnabled(detectColorEnabled());
@@ -0,0 +1,111 @@
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
+ formatConfigValue,
11
+ } from './config-schema';
12
+
13
+ describe('config schema coverage', () => {
14
+ it('describes every UserConfig key exactly once', () => {
15
+ expect(CONFIG_KEYS.slice().sort()).toEqual(Object.keys(CONFIG_DEFAULTS).sort());
16
+ });
17
+ it('is sorted for stable listing', () => {
18
+ expect(CONFIG_KEYS).toEqual(CONFIG_KEYS.slice().sort());
19
+ });
20
+ });
21
+
22
+ describe('isConfigKey', () => {
23
+ it('accepts known keys, rejects unknown / prototype keys', () => {
24
+ expect(isConfigKey('githubOrg')).toBe(true);
25
+ expect(isConfigKey('nope')).toBe(false);
26
+ expect(isConfigKey('toString')).toBe(false); // not an own property
27
+ expect(isConfigKey('')).toBe(false);
28
+ });
29
+ });
30
+
31
+ describe('parseConfigValue', () => {
32
+ it('coerces booleans from common words (case-insensitive)', () => {
33
+ for (const t of ['true', '1', 'YES', 'on', 'y']) {
34
+ expect(parseConfigValue('autoReportBugs', t)).toEqual({ ok: true, value: true });
35
+ }
36
+ for (const f of ['false', '0', 'No', 'off', 'n']) {
37
+ expect(parseConfigValue('autoReportBugs', f)).toEqual({ ok: true, value: false });
38
+ }
39
+ });
40
+ it('rejects non-boolean words with a helpful error', () => {
41
+ const r = parseConfigValue('installMcp', 'maybe');
42
+ expect(r.ok).toBe(false);
43
+ if (!r.ok) expect(r.error).toMatch(/boolean/);
44
+ });
45
+ it('validates enums', () => {
46
+ expect(parseConfigValue('cloneProtocol', 'https')).toEqual({ ok: true, value: 'https' });
47
+ const r = parseConfigValue('cloneProtocol', 'ftp');
48
+ expect(r.ok).toBe(false);
49
+ if (!r.ok) expect(r.error).toMatch(/ssh, https/);
50
+ });
51
+ it('normalizes enum case + whitespace to the canonical value', () => {
52
+ expect(parseConfigValue('cloneProtocol', 'HTTPS')).toEqual({ ok: true, value: 'https' });
53
+ expect(parseConfigValue('cloneProtocol', ' ssh ')).toEqual({ ok: true, value: 'ssh' });
54
+ expect(parseConfigValue('aiAgent', 'Both')).toEqual({ ok: true, value: 'both' });
55
+ });
56
+ it('trims surrounding whitespace on strings (case preserved)', () => {
57
+ expect(parseConfigValue('githubOrg', ' Acme-Corp ')).toEqual({ ok: true, value: 'Acme-Corp' });
58
+ expect(parseConfigValue('githubOrg', ' ')).toEqual({ ok: true, value: '' }); // trims to empty (allowed)
59
+ expect(parseConfigValue('workspacesDir', ' ').ok).toBe(false); // required, empty after trim
60
+ });
61
+ it('accepts agent enum values incl. auto/both', () => {
62
+ expect(parseConfigValue('aiAgent', 'both').ok).toBe(true);
63
+ expect(parseConfigValue('primaryAgent', 'both').ok).toBe(false); // no "both" for primary
64
+ expect(parseConfigValue('primaryAgent', 'pi').ok).toBe(true);
65
+ });
66
+ it('rejects empty required strings but allows empty githubOrg', () => {
67
+ expect(parseConfigValue('workspacesDir', '').ok).toBe(false);
68
+ expect(parseConfigValue('githubOrg', '')).toEqual({ ok: true, value: '' });
69
+ });
70
+ });
71
+
72
+ describe('applyConfigSet / applyConfigUnset (pure, immutable)', () => {
73
+ const base: UserConfig = { ...CONFIG_DEFAULTS, githubOrg: 'acme' };
74
+
75
+ it('returns a new object and does not mutate the input', () => {
76
+ const r = applyConfigSet(base, 'githubOrg', 'octocat');
77
+ expect(r.ok).toBe(true);
78
+ if (r.ok) {
79
+ expect(r.next.githubOrg).toBe('octocat');
80
+ expect(r.value).toBe('octocat');
81
+ }
82
+ expect(base.githubOrg).toBe('acme'); // unchanged
83
+ });
84
+
85
+ it('rejects unknown keys', () => {
86
+ const r = applyConfigSet(base, 'bogus', 'x');
87
+ expect(r.ok).toBe(false);
88
+ if (!r.ok) expect(r.error).toMatch(/Unknown config key/);
89
+ });
90
+
91
+ it('unset resets to the default value', () => {
92
+ const r = applyConfigUnset(base, 'githubOrg');
93
+ expect(r.ok).toBe(true);
94
+ if (r.ok) expect(r.next.githubOrg).toBe(CONFIG_DEFAULTS.githubOrg);
95
+ });
96
+ });
97
+
98
+ describe('formatConfigValue', () => {
99
+ it('renders booleans and empty strings predictably', () => {
100
+ expect(formatConfigValue(true)).toBe('true');
101
+ expect(formatConfigValue(false)).toBe('false');
102
+ expect(formatConfigValue('')).toBe('');
103
+ expect(formatConfigValue('ssh')).toBe('ssh');
104
+ });
105
+ });
106
+
107
+ describe('CONFIG_SCHEMA describe text', () => {
108
+ it('every key has a non-empty description', () => {
109
+ for (const k of CONFIG_KEYS) expect(CONFIG_SCHEMA[k].describe.length).toBeGreaterThan(0);
110
+ });
111
+ });