@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
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CONFIG_KEYS = exports.CONFIG_SCHEMA = void 0;
|
|
4
|
+
exports.isConfigKey = isConfigKey;
|
|
5
|
+
exports.parseConfigValue = parseConfigValue;
|
|
6
|
+
exports.applyConfigSet = applyConfigSet;
|
|
7
|
+
exports.applyConfigUnset = applyConfigUnset;
|
|
8
|
+
exports.formatConfigValue = formatConfigValue;
|
|
9
|
+
const config_1 = require("./config");
|
|
10
|
+
const AGENT_VALUES = ['claude', 'pi', 'opencode', 'codex', 'gemini'];
|
|
11
|
+
exports.CONFIG_SCHEMA = {
|
|
12
|
+
workspacesDir: { type: 'string', describe: 'Directory where workspaces are created' },
|
|
13
|
+
githubOrg: { type: 'string', allowEmpty: true, describe: 'Default GitHub org for repo lookups' },
|
|
14
|
+
cloneProtocol: { type: 'enum', values: ['ssh', 'https'], describe: 'Protocol used to clone repos' },
|
|
15
|
+
aiAgent: {
|
|
16
|
+
type: 'enum',
|
|
17
|
+
values: [...AGENT_VALUES, 'both', 'auto'],
|
|
18
|
+
describe: 'AI agent(s) to integrate with',
|
|
19
|
+
},
|
|
20
|
+
primaryAgent: {
|
|
21
|
+
type: 'enum',
|
|
22
|
+
values: [...AGENT_VALUES, 'auto'],
|
|
23
|
+
describe: 'Agent launched when opening a workspace',
|
|
24
|
+
},
|
|
25
|
+
autoLaunchClaude: { type: 'boolean', describe: 'Auto-launch the agent after creating a workspace' },
|
|
26
|
+
generateClaudeContext: { type: 'boolean', describe: 'Generate agent context files (AGENTS.md)' },
|
|
27
|
+
installMcp: { type: 'boolean', describe: 'Install the MCP server during configure' },
|
|
28
|
+
piWorkspaceInputStatus: { type: 'boolean', describe: "Show workspace status in Pi's input area" },
|
|
29
|
+
claudeWorkspaceStatusLine: { type: 'boolean', describe: "Show workspace table in Claude's status line" },
|
|
30
|
+
autoReportBugs: { type: 'boolean', describe: 'Auto-file a GitHub issue when a command crashes' },
|
|
31
|
+
};
|
|
32
|
+
exports.CONFIG_KEYS = Object.keys(exports.CONFIG_SCHEMA).sort();
|
|
33
|
+
const TRUE_WORDS = new Set(['true', '1', 'yes', 'on', 'y']);
|
|
34
|
+
const FALSE_WORDS = new Set(['false', '0', 'no', 'off', 'n']);
|
|
35
|
+
/** True if `key` is a writable config key. */
|
|
36
|
+
function isConfigKey(key) {
|
|
37
|
+
return Object.prototype.hasOwnProperty.call(exports.CONFIG_SCHEMA, key);
|
|
38
|
+
}
|
|
39
|
+
/** Validate + coerce a raw string for `key` into the field's typed value. */
|
|
40
|
+
function parseConfigValue(key, raw) {
|
|
41
|
+
const spec = exports.CONFIG_SCHEMA[key];
|
|
42
|
+
if (spec.type === 'boolean') {
|
|
43
|
+
const v = raw.trim().toLowerCase();
|
|
44
|
+
if (TRUE_WORDS.has(v))
|
|
45
|
+
return { ok: true, value: true };
|
|
46
|
+
if (FALSE_WORDS.has(v))
|
|
47
|
+
return { ok: true, value: false };
|
|
48
|
+
return { ok: false, error: `${key} expects a boolean (true/false); got "${raw}"` };
|
|
49
|
+
}
|
|
50
|
+
if (spec.type === 'enum') {
|
|
51
|
+
// Enum values are all lowercase, so normalize input like booleans do —
|
|
52
|
+
// `HTTPS` / ` https ` should resolve to the canonical value, not fail.
|
|
53
|
+
const v = raw.trim().toLowerCase();
|
|
54
|
+
if (spec.values.includes(v)) {
|
|
55
|
+
return { ok: true, value: v };
|
|
56
|
+
}
|
|
57
|
+
return { ok: false, error: `${key} must be one of: ${spec.values.join(', ')}; got "${raw}"` };
|
|
58
|
+
}
|
|
59
|
+
// string: trim surrounding whitespace (a stray space in a path/org is almost
|
|
60
|
+
// always a mistake), but preserve case.
|
|
61
|
+
const trimmed = raw.trim();
|
|
62
|
+
if (!spec.allowEmpty && trimmed === '') {
|
|
63
|
+
return { ok: false, error: `${key} cannot be empty` };
|
|
64
|
+
}
|
|
65
|
+
return { ok: true, value: trimmed };
|
|
66
|
+
}
|
|
67
|
+
/** Apply a `set` to a config object, returning a NEW config or an error. Pure. */
|
|
68
|
+
function applyConfigSet(current, key, raw) {
|
|
69
|
+
if (!isConfigKey(key)) {
|
|
70
|
+
return { ok: false, error: `Unknown config key "${key}". Run "nemus config list" to see valid keys.` };
|
|
71
|
+
}
|
|
72
|
+
const parsed = parseConfigValue(key, raw);
|
|
73
|
+
if (!parsed.ok)
|
|
74
|
+
return parsed;
|
|
75
|
+
return { ok: true, next: { ...current, [key]: parsed.value }, value: parsed.value };
|
|
76
|
+
}
|
|
77
|
+
/** Reset a key to its default value, returning a NEW config or an error. Pure. */
|
|
78
|
+
function applyConfigUnset(current, key) {
|
|
79
|
+
if (!isConfigKey(key)) {
|
|
80
|
+
return { ok: false, error: `Unknown config key "${key}". Run "nemus config list" to see valid keys.` };
|
|
81
|
+
}
|
|
82
|
+
const value = config_1.CONFIG_DEFAULTS[key];
|
|
83
|
+
return { ok: true, next: { ...current, [key]: value }, value };
|
|
84
|
+
}
|
|
85
|
+
/** Render a config value for plain (scriptable) stdout output. */
|
|
86
|
+
function formatConfigValue(value) {
|
|
87
|
+
return typeof value === 'boolean' ? String(value) : String(value ?? '');
|
|
88
|
+
}
|
package/dist/utils/config.js
CHANGED
|
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.config = exports.CLONE_MAX_BUFFER = exports.CLONE_TIMEOUT_MS = exports.CONFIG_PATH = exports.META_FILENAME = exports.SUITES_FILE = exports.HISTORY_FILE = exports.CACHE_DIR = exports.WORKSPACES_DIR = void 0;
|
|
36
|
+
exports.config = exports.CLONE_MAX_BUFFER = exports.CLONE_TIMEOUT_MS = exports.CONFIG_PATH = exports.META_FILENAME = exports.SUITES_FILE = exports.HISTORY_FILE = exports.CACHE_DIR = exports.WORKSPACES_DIR = exports.CONFIG_DEFAULTS = void 0;
|
|
37
37
|
exports.getUserConfig = getUserConfig;
|
|
38
38
|
exports.getPackageVersion = getPackageVersion;
|
|
39
39
|
exports.getCloneUrl = getCloneUrl;
|
|
@@ -77,7 +77,7 @@ const CACHE_DIR_RESOLVED = process.env.NEMUS_CACHE_DIR || process.env.WORKSPACE_
|
|
|
77
77
|
migrateLegacyCacheDir(CACHE_DIR_RESOLVED);
|
|
78
78
|
// Config file lives inside the cache dir.
|
|
79
79
|
const CONFIG_FILE = path.join(CACHE_DIR_RESOLVED, 'config.json');
|
|
80
|
-
|
|
80
|
+
exports.CONFIG_DEFAULTS = {
|
|
81
81
|
workspacesDir: path.join(HOME_DIR, 'workspaces'),
|
|
82
82
|
githubOrg: '',
|
|
83
83
|
autoLaunchClaude: true,
|
|
@@ -90,6 +90,8 @@ const DEFAULTS = {
|
|
|
90
90
|
claudeWorkspaceStatusLine: true,
|
|
91
91
|
autoReportBugs: false,
|
|
92
92
|
};
|
|
93
|
+
// Internal alias retained for the many references below.
|
|
94
|
+
const DEFAULTS = exports.CONFIG_DEFAULTS;
|
|
93
95
|
function loadConfigFileSync() {
|
|
94
96
|
try {
|
|
95
97
|
const content = fs.readFileSync(CONFIG_FILE, 'utf-8');
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.applyGlobalFlags = applyGlobalFlags;
|
|
4
|
+
const colors_1 = require("./colors");
|
|
5
|
+
const logger_1 = require("./logger");
|
|
6
|
+
/**
|
|
7
|
+
* Apply parsed global flags to process-wide state. Kept pure over injected
|
|
8
|
+
* setters so it's testable without commander — the `preAction` hook in
|
|
9
|
+
* program.ts is a one-line call into this. Only ever turns features OFF here:
|
|
10
|
+
* color defaults on (and env/TTY detection already ran at import), so we act
|
|
11
|
+
* solely on an explicit `--no-color` (`color === false`).
|
|
12
|
+
*/
|
|
13
|
+
function applyGlobalFlags(opts, deps = {
|
|
14
|
+
setColorEnabled: colors_1.setColorEnabled,
|
|
15
|
+
setQuiet: logger_1.setQuiet,
|
|
16
|
+
}) {
|
|
17
|
+
if (opts.color === false)
|
|
18
|
+
deps.setColorEnabled(false);
|
|
19
|
+
if (opts.quiet)
|
|
20
|
+
deps.setQuiet(true);
|
|
21
|
+
}
|
package/dist/utils/logger.js
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.logStep = exports.logWarning = exports.logError = exports.logSuccess = exports.logInfo = void 0;
|
|
3
|
+
exports.logStep = exports.logWarning = exports.logError = exports.logSuccess = exports.logInfo = exports.isQuiet = exports.setQuiet = void 0;
|
|
4
4
|
const colors_1 = require("./colors");
|
|
5
5
|
// Diagnostics (info/success/error/warn/step) go to STDERR so stdout carries only
|
|
6
6
|
// a command's actual data — required for clean `--json` piping (nemus list
|
|
7
7
|
// --json | jq …) and for non-TTY consumers. Use `outputJson`/stdout for data.
|
|
8
8
|
const logStream = (line) => console.error(line);
|
|
9
|
+
// `--quiet` silences routine progress (info/success/step) while KEEPING warnings
|
|
10
|
+
// and errors, which a script or human still needs to see.
|
|
11
|
+
let quiet = false;
|
|
12
|
+
const setQuiet = (on) => {
|
|
13
|
+
quiet = on;
|
|
14
|
+
};
|
|
15
|
+
exports.setQuiet = setQuiet;
|
|
16
|
+
const isQuiet = () => quiet;
|
|
17
|
+
exports.isQuiet = isQuiet;
|
|
9
18
|
const getTimestamp = () => {
|
|
10
19
|
const now = new Date();
|
|
11
20
|
return now.toLocaleTimeString('en-US', {
|
|
@@ -16,10 +25,14 @@ const getTimestamp = () => {
|
|
|
16
25
|
});
|
|
17
26
|
};
|
|
18
27
|
const logInfo = (message) => {
|
|
28
|
+
if (quiet)
|
|
29
|
+
return;
|
|
19
30
|
logStream(`${colors_1.colors.gray}[${getTimestamp()}]${colors_1.colors.reset} ${message}`);
|
|
20
31
|
};
|
|
21
32
|
exports.logInfo = logInfo;
|
|
22
33
|
const logSuccess = (message) => {
|
|
34
|
+
if (quiet)
|
|
35
|
+
return;
|
|
23
36
|
logStream(`${colors_1.colors.gray}[${getTimestamp()}]${colors_1.colors.reset} ${(0, colors_1.colorize)('✓', 'green')} ${message}`);
|
|
24
37
|
};
|
|
25
38
|
exports.logSuccess = logSuccess;
|
|
@@ -32,6 +45,8 @@ const logWarning = (message) => {
|
|
|
32
45
|
};
|
|
33
46
|
exports.logWarning = logWarning;
|
|
34
47
|
const logStep = (stepOrMessage, total, message) => {
|
|
48
|
+
if (quiet)
|
|
49
|
+
return;
|
|
35
50
|
if (typeof stepOrMessage === 'string') {
|
|
36
51
|
// Single parameter version: just a message
|
|
37
52
|
logStream(`${colors_1.colors.gray}[${getTimestamp()}]${colors_1.colors.reset} ${(0, colors_1.colorize)('▸', 'cyan')} ${stepOrMessage}`);
|
package/dist/utils/reflect.js
CHANGED
|
@@ -33,7 +33,11 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.REFLECT_REPORTS_DIR = exports.REFLECT_SCHEMA = void 0;
|
|
36
|
+
exports.REFLECT_REPORTS_DIR = exports.REFLECT_SCHEMA = exports.PRIORITY_HEADING = exports.KIND_LABEL = void 0;
|
|
37
|
+
exports.severityCounts = severityCounts;
|
|
38
|
+
exports.severitySummary = severitySummary;
|
|
39
|
+
exports.groupRecommendations = groupRecommendations;
|
|
40
|
+
exports.renderReportMarkdown = renderReportMarkdown;
|
|
37
41
|
exports.isCorrectionPrompt = isCorrectionPrompt;
|
|
38
42
|
exports.distillTranscript = distillTranscript;
|
|
39
43
|
exports.classifyAgentsMd = classifyAgentsMd;
|
|
@@ -41,12 +45,117 @@ exports.findLatestTranscriptFile = findLatestTranscriptFile;
|
|
|
41
45
|
exports.gatherReflectionCorpus = gatherReflectionCorpus;
|
|
42
46
|
exports.parseReflectionReport = parseReflectionReport;
|
|
43
47
|
exports.saveReflectionReport = saveReflectionReport;
|
|
48
|
+
exports.listSavedReports = listSavedReports;
|
|
49
|
+
exports.findSavedMatches = findSavedMatches;
|
|
50
|
+
exports.loadSavedReport = loadSavedReport;
|
|
44
51
|
const fs = __importStar(require("fs/promises"));
|
|
45
52
|
const path = __importStar(require("path"));
|
|
46
53
|
const config_1 = require("./config");
|
|
47
54
|
const workspace_meta_1 = require("./workspace-meta");
|
|
48
55
|
const agent_config_1 = require("./agent-config");
|
|
49
56
|
const claude_sessions_1 = require("./claude-sessions");
|
|
57
|
+
/** Count recommendations by priority. */
|
|
58
|
+
function severityCounts(recs) {
|
|
59
|
+
const counts = { high: 0, medium: 0, low: 0 };
|
|
60
|
+
for (const r of recs)
|
|
61
|
+
counts[r.priority]++;
|
|
62
|
+
return counts;
|
|
63
|
+
}
|
|
64
|
+
/** "3 high · 2 medium · 1 low", omitting zero buckets; '' when there are none. */
|
|
65
|
+
function severitySummary(recs) {
|
|
66
|
+
const c = severityCounts(recs);
|
|
67
|
+
return ['high', 'medium', 'low']
|
|
68
|
+
.filter((p) => c[p] > 0)
|
|
69
|
+
.map((p) => `${c[p]} ${p}`)
|
|
70
|
+
.join(' · ');
|
|
71
|
+
}
|
|
72
|
+
exports.KIND_LABEL = {
|
|
73
|
+
skill: 'Skill',
|
|
74
|
+
context: 'Context/AGENTS.md',
|
|
75
|
+
test: 'Test',
|
|
76
|
+
prompt: 'Prompt',
|
|
77
|
+
connectivity: 'Connectivity',
|
|
78
|
+
workflow: 'Workflow',
|
|
79
|
+
other: 'Other',
|
|
80
|
+
};
|
|
81
|
+
// Back-compat alias for existing references.
|
|
82
|
+
const MD_KIND_LABEL = exports.KIND_LABEL;
|
|
83
|
+
exports.PRIORITY_HEADING = {
|
|
84
|
+
high: 'High priority',
|
|
85
|
+
medium: 'Medium priority',
|
|
86
|
+
low: 'Low priority',
|
|
87
|
+
};
|
|
88
|
+
const KIND_ORDER = ['skill', 'context', 'test', 'prompt', 'connectivity', 'workflow', 'other'];
|
|
89
|
+
const PRIORITY_ORDER = ['high', 'medium', 'low'];
|
|
90
|
+
const PRIORITY_RANK = { high: 0, medium: 1, low: 2 };
|
|
91
|
+
/**
|
|
92
|
+
* Split recommendations into ordered, non-empty groups by either priority
|
|
93
|
+
* (high→low) or kind (a fixed, stable order). When grouping by kind, each
|
|
94
|
+
* group's recs are sorted high-priority first. Pure + unit-tested; shared by the
|
|
95
|
+
* Markdown renderer and the human printer so the two never diverge.
|
|
96
|
+
*/
|
|
97
|
+
function groupRecommendations(recs, groupBy) {
|
|
98
|
+
if (groupBy === 'kind') {
|
|
99
|
+
return KIND_ORDER.map((k) => ({
|
|
100
|
+
key: k,
|
|
101
|
+
heading: exports.KIND_LABEL[k],
|
|
102
|
+
recs: recs
|
|
103
|
+
.filter((r) => r.kind === k)
|
|
104
|
+
.sort((a, b) => PRIORITY_RANK[a.priority] - PRIORITY_RANK[b.priority]),
|
|
105
|
+
})).filter((g) => g.recs.length > 0);
|
|
106
|
+
}
|
|
107
|
+
return PRIORITY_ORDER.map((p) => ({
|
|
108
|
+
key: p,
|
|
109
|
+
heading: exports.PRIORITY_HEADING[p],
|
|
110
|
+
recs: recs.filter((r) => r.priority === p),
|
|
111
|
+
})).filter((g) => g.recs.length > 0);
|
|
112
|
+
}
|
|
113
|
+
/** A fenced code block whose fence is guaranteed longer than any backtick run
|
|
114
|
+
* inside `body`, so the snippet can't break out of its own fence. */
|
|
115
|
+
function fencedBlock(body) {
|
|
116
|
+
const longest = Math.max(0, ...(body.match(/`+/g) ?? []).map((m) => m.length));
|
|
117
|
+
const fence = '`'.repeat(Math.max(3, longest + 1));
|
|
118
|
+
return `${fence}\n${body}\n${fence}`;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Render a reflection report as clean Markdown — for pasting into an issue/PR or
|
|
122
|
+
* saving alongside the JSON. Pure (no color, no I/O) so it's unit-tested.
|
|
123
|
+
* Recommendations are grouped by severity (high→low); each carries its kind,
|
|
124
|
+
* optional target, detail, and a fenced example.
|
|
125
|
+
*/
|
|
126
|
+
function renderReportMarkdown(report, meta, groupBy = 'priority') {
|
|
127
|
+
const lines = ['# Reflection', ''];
|
|
128
|
+
const scope = meta.workspace
|
|
129
|
+
? `workspace **${meta.workspace}**`
|
|
130
|
+
: `${meta.analyzed} session${meta.analyzed === 1 ? '' : 's'} across ${meta.workspaces} workspace${meta.workspaces === 1 ? '' : 's'}`;
|
|
131
|
+
const stamp = meta.generatedAt ? ` · ${meta.generatedAt}` : '';
|
|
132
|
+
lines.push(`_${scope}${stamp}_`, '');
|
|
133
|
+
if (report.summary.trim())
|
|
134
|
+
lines.push(report.summary.trim(), '');
|
|
135
|
+
if (report.recommendations.length === 0) {
|
|
136
|
+
lines.push('## Recommendations', '', '_No specific recommendations — looks solid._', '');
|
|
137
|
+
return lines.join('\n');
|
|
138
|
+
}
|
|
139
|
+
lines.push('## Recommendations', '', `**${severitySummary(report.recommendations)}**`, '');
|
|
140
|
+
for (const group of groupRecommendations(report.recommendations, groupBy)) {
|
|
141
|
+
lines.push(`### ${group.heading}`, '');
|
|
142
|
+
for (const r of group.recs) {
|
|
143
|
+
const target = r.target ? ` (\`${r.target}\`)` : '';
|
|
144
|
+
// Under a kind heading the [Kind] prefix is redundant; show a priority tag
|
|
145
|
+
// instead. Under a priority heading, show the kind.
|
|
146
|
+
const label = groupBy === 'kind' ? `_${r.priority}_ — ` : `[${exports.KIND_LABEL[r.kind]}] `;
|
|
147
|
+
lines.push(`- **${label}${r.title}**${target}`);
|
|
148
|
+
if (r.detail.trim()) {
|
|
149
|
+
lines.push(...r.detail.trim().split('\n').map((l) => ` ${l}`));
|
|
150
|
+
}
|
|
151
|
+
if (r.example?.trim()) {
|
|
152
|
+
lines.push('', ...fencedBlock(r.example.trim()).split('\n').map((l) => ` ${l}`));
|
|
153
|
+
}
|
|
154
|
+
lines.push('');
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n';
|
|
158
|
+
}
|
|
50
159
|
// --------------------------------------------------------- transcript distill
|
|
51
160
|
// Kept deliberately lean: the judge runs on the user's own (often local, slow)
|
|
52
161
|
// agent, and a 10-workspace corpus at full verbosity produced a ~200KB prompt
|
|
@@ -408,3 +517,63 @@ async function saveReflectionReport(report, meta) {
|
|
|
408
517
|
await fs.writeFile(file, JSON.stringify({ generatedAt: new Date().toISOString(), ...meta, ...report }, null, 2));
|
|
409
518
|
return file;
|
|
410
519
|
}
|
|
520
|
+
/**
|
|
521
|
+
* List saved reports under `~/.nemus/reflect/`, newest first (filenames start
|
|
522
|
+
* with an ISO timestamp, so a reverse name sort is chronological). Unreadable /
|
|
523
|
+
* unparseable files are skipped, not fatal. Returns [] if the dir is absent.
|
|
524
|
+
*/
|
|
525
|
+
async function listSavedReports(dir = exports.REFLECT_REPORTS_DIR) {
|
|
526
|
+
let names;
|
|
527
|
+
try {
|
|
528
|
+
names = await fs.readdir(dir);
|
|
529
|
+
}
|
|
530
|
+
catch {
|
|
531
|
+
return [];
|
|
532
|
+
}
|
|
533
|
+
const jsons = names.filter((n) => n.endsWith('.json')).sort().reverse();
|
|
534
|
+
const out = [];
|
|
535
|
+
for (const name of jsons) {
|
|
536
|
+
const file = path.join(dir, name);
|
|
537
|
+
try {
|
|
538
|
+
const raw = JSON.parse(await fs.readFile(file, 'utf-8'));
|
|
539
|
+
out.push({
|
|
540
|
+
id: name.replace(/\.json$/, ''),
|
|
541
|
+
file,
|
|
542
|
+
generatedAt: typeof raw.generatedAt === 'string' ? raw.generatedAt : undefined,
|
|
543
|
+
analyzed: typeof raw.analyzed === 'number' ? raw.analyzed : 0,
|
|
544
|
+
workspaces: typeof raw.workspaces === 'number' ? raw.workspaces : 0,
|
|
545
|
+
workspace: typeof raw.workspace === 'string' ? raw.workspace : undefined,
|
|
546
|
+
report: parseReflectionReport(raw),
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
catch {
|
|
550
|
+
/* skip a corrupt/partial file */
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
return out;
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Resolve a report reference against an (already newest-first) list. Returns ALL
|
|
557
|
+
* matches so a caller can detect ambiguity: `undefined`/`'latest'` -> the newest;
|
|
558
|
+
* an exact id -> that one; otherwise every id with the prefix (newest-first). An
|
|
559
|
+
* exact id always wins over prefixes, so an id can't be ambiguous with itself.
|
|
560
|
+
* Pure + unit-tested.
|
|
561
|
+
*/
|
|
562
|
+
function findSavedMatches(all, ref) {
|
|
563
|
+
if (all.length === 0)
|
|
564
|
+
return [];
|
|
565
|
+
if (!ref || ref === 'latest')
|
|
566
|
+
return [all[0]];
|
|
567
|
+
const exact = all.find((r) => r.id === ref);
|
|
568
|
+
if (exact)
|
|
569
|
+
return [exact];
|
|
570
|
+
return all.filter((r) => r.id.startsWith(ref));
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Load one saved report by id. `undefined`/`'latest'` returns the newest; an id
|
|
574
|
+
* is matched exactly, then as a prefix (newest match wins). Returns null when
|
|
575
|
+
* nothing matches. For ambiguity-aware callers, use findSavedMatches directly.
|
|
576
|
+
*/
|
|
577
|
+
async function loadSavedReport(ref, dir = exports.REFLECT_REPORTS_DIR) {
|
|
578
|
+
return findSavedMatches(await listSavedReports(dir), ref)[0] ?? null;
|
|
579
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { getUserConfig, saveUserConfig, CONFIG_PATH } from '../utils/config';
|
|
3
|
+
import {
|
|
4
|
+
CONFIG_KEYS,
|
|
5
|
+
CONFIG_SCHEMA,
|
|
6
|
+
isConfigKey,
|
|
7
|
+
applyConfigSet,
|
|
8
|
+
applyConfigUnset,
|
|
9
|
+
formatConfigValue,
|
|
10
|
+
} from '../utils/config-schema';
|
|
11
|
+
import { outputJson, outputJsonError } from '../utils/output';
|
|
12
|
+
import { logSuccess, logError } from '../utils/logger';
|
|
13
|
+
import { colorize } from '../utils/colors';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Non-interactive config management: `nemus config get/set/unset/list/path`.
|
|
17
|
+
* Complements the interactive `configure` wizard and is script-friendly —
|
|
18
|
+
* `get`/`list` write DATA to stdout (raw value, or JSON with --json), logs go to
|
|
19
|
+
* stderr. Values are validated/coerced against config-schema.ts.
|
|
20
|
+
*/
|
|
21
|
+
export function registerConfigCommand(parent: Command): void {
|
|
22
|
+
const config = parent.command('config').description('Get or set Nemus configuration');
|
|
23
|
+
|
|
24
|
+
config
|
|
25
|
+
.command('get')
|
|
26
|
+
.description('Print a config value (or all values with no key)')
|
|
27
|
+
.argument('[key]', 'Config key')
|
|
28
|
+
.option('--json', 'Output as JSON')
|
|
29
|
+
.action((key: string | undefined, opts: { json?: boolean }) => {
|
|
30
|
+
const cfg = getUserConfig();
|
|
31
|
+
if (key === undefined) {
|
|
32
|
+
printAll(cfg, opts.json);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (!isConfigKey(key)) {
|
|
36
|
+
if (opts.json) outputJsonError(`Unknown config key "${key}"`);
|
|
37
|
+
else logError(`Unknown config key "${key}". Run "nemus config list" to see valid keys.`);
|
|
38
|
+
process.exitCode = 1;
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const value = cfg[key];
|
|
42
|
+
if (opts.json) outputJson({ key, value });
|
|
43
|
+
else process.stdout.write(formatConfigValue(value) + '\n');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
config
|
|
47
|
+
.command('set')
|
|
48
|
+
.description('Set a config value')
|
|
49
|
+
.argument('<key>', 'Config key')
|
|
50
|
+
.argument('<value>', 'New value')
|
|
51
|
+
.option('--json', 'Output as JSON')
|
|
52
|
+
.action((key: string, value: string, opts: { json?: boolean }) => {
|
|
53
|
+
const result = applyConfigSet(getUserConfig(), key, value);
|
|
54
|
+
if (!result.ok) {
|
|
55
|
+
if (opts.json) outputJsonError(result.error);
|
|
56
|
+
else logError(result.error);
|
|
57
|
+
process.exitCode = 1;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
saveUserConfig(result.next);
|
|
61
|
+
if (opts.json) outputJson({ ok: true, key, value: result.value });
|
|
62
|
+
else logSuccess(`Set ${colorize(key, 'cyan')} = ${formatConfigValue(result.value)}`);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
config
|
|
66
|
+
.command('unset')
|
|
67
|
+
.description('Reset a config value to its default')
|
|
68
|
+
.argument('<key>', 'Config key')
|
|
69
|
+
.option('--json', 'Output as JSON')
|
|
70
|
+
.action((key: string, opts: { json?: boolean }) => {
|
|
71
|
+
const result = applyConfigUnset(getUserConfig(), key);
|
|
72
|
+
if (!result.ok) {
|
|
73
|
+
if (opts.json) outputJsonError(result.error);
|
|
74
|
+
else logError(result.error);
|
|
75
|
+
process.exitCode = 1;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
saveUserConfig(result.next);
|
|
79
|
+
if (opts.json) outputJson({ ok: true, key, value: result.value });
|
|
80
|
+
else logSuccess(`Reset ${colorize(key, 'cyan')} to default (${formatConfigValue(result.value)})`);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
config
|
|
84
|
+
.command('list')
|
|
85
|
+
.alias('ls')
|
|
86
|
+
.description('List all config keys and current values')
|
|
87
|
+
.option('--json', 'Output as JSON')
|
|
88
|
+
.action((opts: { json?: boolean }) => printAll(getUserConfig(), opts.json));
|
|
89
|
+
|
|
90
|
+
config
|
|
91
|
+
.command('path')
|
|
92
|
+
.description('Print the path to the config file')
|
|
93
|
+
.action(() => {
|
|
94
|
+
process.stdout.write(CONFIG_PATH + '\n');
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function printAll(cfg: ReturnType<typeof getUserConfig>, json?: boolean): void {
|
|
99
|
+
if (json) {
|
|
100
|
+
const values: Record<string, unknown> = {};
|
|
101
|
+
for (const key of CONFIG_KEYS) values[key] = cfg[key];
|
|
102
|
+
outputJson({ path: CONFIG_PATH, values });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const width = Math.max(...CONFIG_KEYS.map((k) => k.length));
|
|
106
|
+
console.log(colorize('Nemus configuration', 'bright') + colorize(` (${CONFIG_PATH})`, 'dim'));
|
|
107
|
+
for (const key of CONFIG_KEYS) {
|
|
108
|
+
const val = formatConfigValue(cfg[key]);
|
|
109
|
+
const shown = val === '' ? colorize('(empty)', 'dim') : val;
|
|
110
|
+
console.log(` ${key.padEnd(width)} ${shown} ${colorize(CONFIG_SCHEMA[key].describe, 'dim')}`);
|
|
111
|
+
}
|
|
112
|
+
}
|