@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.
- package/CHANGELOG.md +44 -0
- package/README.md +55 -0
- package/dist/commands/config.js +113 -0
- package/dist/commands/reflect.js +141 -20
- package/dist/program.js +20 -17
- package/dist/utils/banner.js +33 -12
- package/dist/utils/colors.js +43 -2
- package/dist/utils/config-schema.js +88 -0
- package/dist/utils/config.js +4 -2
- package/dist/utils/global-flags.js +21 -0
- package/dist/utils/logger.js +16 -1
- package/dist/utils/reflect.js +170 -1
- package/package.json +1 -1
- package/src/commands/config.ts +112 -0
- package/src/commands/reflect.ts +156 -20
- package/src/program.ts +21 -19
- package/src/utils/banner.ts +32 -13
- package/src/utils/colors.test.ts +51 -0
- package/src/utils/colors.ts +50 -3
- package/src/utils/config-schema.test.ts +111 -0
- package/src/utils/config-schema.ts +112 -0
- package/src/utils/config.ts +4 -1
- package/src/utils/global-flags.test.ts +44 -0
- package/src/utils/global-flags.ts +28 -0
- package/src/utils/logger.test.ts +36 -0
- package/src/utils/logger.ts +11 -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
|
@@ -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 {
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
.
|
|
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);
|
package/src/utils/banner.ts
CHANGED
|
@@ -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(
|
|
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
|
+
});
|
package/src/utils/colors.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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
|
+
});
|