@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
|
@@ -0,0 +1,145 @@
|
|
|
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.validateTypedValue = validateTypedValue;
|
|
7
|
+
exports.applyConfigSet = applyConfigSet;
|
|
8
|
+
exports.applyConfigUnset = applyConfigUnset;
|
|
9
|
+
exports.formatConfigValue = formatConfigValue;
|
|
10
|
+
exports.reviewConfigFileText = reviewConfigFileText;
|
|
11
|
+
const config_1 = require("./config");
|
|
12
|
+
const AGENT_VALUES = ['claude', 'pi', 'opencode', 'codex', 'gemini'];
|
|
13
|
+
exports.CONFIG_SCHEMA = {
|
|
14
|
+
workspacesDir: { type: 'string', describe: 'Directory where workspaces are created' },
|
|
15
|
+
githubOrg: { type: 'string', allowEmpty: true, describe: 'Default GitHub org for repo lookups' },
|
|
16
|
+
cloneProtocol: { type: 'enum', values: ['ssh', 'https'], describe: 'Protocol used to clone repos' },
|
|
17
|
+
aiAgent: {
|
|
18
|
+
type: 'enum',
|
|
19
|
+
values: [...AGENT_VALUES, 'both', 'auto'],
|
|
20
|
+
describe: 'AI agent(s) to integrate with',
|
|
21
|
+
},
|
|
22
|
+
primaryAgent: {
|
|
23
|
+
type: 'enum',
|
|
24
|
+
values: [...AGENT_VALUES, 'auto'],
|
|
25
|
+
describe: 'Agent launched when opening a workspace',
|
|
26
|
+
},
|
|
27
|
+
autoLaunchClaude: { type: 'boolean', describe: 'Auto-launch the agent after creating a workspace' },
|
|
28
|
+
generateClaudeContext: { type: 'boolean', describe: 'Generate agent context files (AGENTS.md)' },
|
|
29
|
+
installMcp: { type: 'boolean', describe: 'Install the MCP server during configure' },
|
|
30
|
+
piWorkspaceInputStatus: { type: 'boolean', describe: "Show workspace status in Pi's input area" },
|
|
31
|
+
claudeWorkspaceStatusLine: { type: 'boolean', describe: "Show workspace table in Claude's status line" },
|
|
32
|
+
autoReportBugs: { type: 'boolean', describe: 'Auto-file a GitHub issue when a command crashes' },
|
|
33
|
+
};
|
|
34
|
+
exports.CONFIG_KEYS = Object.keys(exports.CONFIG_SCHEMA).sort();
|
|
35
|
+
const TRUE_WORDS = new Set(['true', '1', 'yes', 'on', 'y']);
|
|
36
|
+
const FALSE_WORDS = new Set(['false', '0', 'no', 'off', 'n']);
|
|
37
|
+
/** True if `key` is a writable config key. */
|
|
38
|
+
function isConfigKey(key) {
|
|
39
|
+
return Object.prototype.hasOwnProperty.call(exports.CONFIG_SCHEMA, key);
|
|
40
|
+
}
|
|
41
|
+
/** Validate + coerce a raw string for `key` into the field's typed value. */
|
|
42
|
+
function parseConfigValue(key, raw) {
|
|
43
|
+
const spec = exports.CONFIG_SCHEMA[key];
|
|
44
|
+
if (spec.type === 'boolean') {
|
|
45
|
+
const v = raw.trim().toLowerCase();
|
|
46
|
+
if (TRUE_WORDS.has(v))
|
|
47
|
+
return { ok: true, value: true };
|
|
48
|
+
if (FALSE_WORDS.has(v))
|
|
49
|
+
return { ok: true, value: false };
|
|
50
|
+
return { ok: false, error: `${key} expects a boolean (true/false); got "${raw}"` };
|
|
51
|
+
}
|
|
52
|
+
if (spec.type === 'enum') {
|
|
53
|
+
// Enum values are all lowercase, so normalize input like booleans do —
|
|
54
|
+
// `HTTPS` / ` https ` should resolve to the canonical value, not fail.
|
|
55
|
+
const v = raw.trim().toLowerCase();
|
|
56
|
+
if (spec.values.includes(v)) {
|
|
57
|
+
return { ok: true, value: v };
|
|
58
|
+
}
|
|
59
|
+
return { ok: false, error: `${key} must be one of: ${spec.values.join(', ')}; got "${raw}"` };
|
|
60
|
+
}
|
|
61
|
+
// string: trim surrounding whitespace (a stray space in a path/org is almost
|
|
62
|
+
// always a mistake), but preserve case.
|
|
63
|
+
const trimmed = raw.trim();
|
|
64
|
+
if (!spec.allowEmpty && trimmed === '') {
|
|
65
|
+
return { ok: false, error: `${key} cannot be empty` };
|
|
66
|
+
}
|
|
67
|
+
return { ok: true, value: trimmed };
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Validate an already-typed value (as it appears in the JSON config file) for
|
|
71
|
+
* `key` against its field spec. This is the parse-free sibling of
|
|
72
|
+
* parseConfigValue (which coerces a CLI string): it checks that a boolean field
|
|
73
|
+
* holds a boolean, an enum holds an allowed string, and a string field holds a
|
|
74
|
+
* (non-empty, unless allowEmpty) string. Used by `config edit` so a hand-edit
|
|
75
|
+
* is validated the same way `config set` validates. Pure + unit-tested.
|
|
76
|
+
*/
|
|
77
|
+
function validateTypedValue(key, value) {
|
|
78
|
+
const spec = exports.CONFIG_SCHEMA[key];
|
|
79
|
+
if (spec.type === 'boolean') {
|
|
80
|
+
return typeof value === 'boolean' ? { ok: true } : { ok: false, error: `${key} must be a boolean` };
|
|
81
|
+
}
|
|
82
|
+
if (spec.type === 'enum') {
|
|
83
|
+
return typeof value === 'string' && spec.values.includes(value)
|
|
84
|
+
? { ok: true }
|
|
85
|
+
: { ok: false, error: `${key} must be one of: ${spec.values.join(', ')}` };
|
|
86
|
+
}
|
|
87
|
+
if (typeof value !== 'string')
|
|
88
|
+
return { ok: false, error: `${key} must be a string` };
|
|
89
|
+
if (!spec.allowEmpty && value.trim() === '')
|
|
90
|
+
return { ok: false, error: `${key} cannot be empty` };
|
|
91
|
+
return { ok: true };
|
|
92
|
+
}
|
|
93
|
+
/** Apply a `set` to a config object, returning a NEW config or an error. Pure. */
|
|
94
|
+
function applyConfigSet(current, key, raw) {
|
|
95
|
+
if (!isConfigKey(key)) {
|
|
96
|
+
return { ok: false, error: `Unknown config key "${key}". Run "nemus config list" to see valid keys.` };
|
|
97
|
+
}
|
|
98
|
+
const parsed = parseConfigValue(key, raw);
|
|
99
|
+
if (!parsed.ok)
|
|
100
|
+
return parsed;
|
|
101
|
+
return { ok: true, next: { ...current, [key]: parsed.value }, value: parsed.value };
|
|
102
|
+
}
|
|
103
|
+
/** Reset a key to its default value, returning a NEW config or an error. Pure. */
|
|
104
|
+
function applyConfigUnset(current, key) {
|
|
105
|
+
if (!isConfigKey(key)) {
|
|
106
|
+
return { ok: false, error: `Unknown config key "${key}". Run "nemus config list" to see valid keys.` };
|
|
107
|
+
}
|
|
108
|
+
const value = config_1.CONFIG_DEFAULTS[key];
|
|
109
|
+
return { ok: true, next: { ...current, [key]: value }, value };
|
|
110
|
+
}
|
|
111
|
+
/** Render a config value for plain (scriptable) stdout output. */
|
|
112
|
+
function formatConfigValue(value) {
|
|
113
|
+
return typeof value === 'boolean' ? String(value) : String(value ?? '');
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Review the raw text of a hand-edited config file the same way `config set`
|
|
117
|
+
* validates: it must parse, be a plain object, only use known keys, and every
|
|
118
|
+
* known key present must hold a schema-valid value. Pure so `config edit` can be
|
|
119
|
+
* fully unit-tested without spawning an editor.
|
|
120
|
+
*/
|
|
121
|
+
function reviewConfigFileText(text) {
|
|
122
|
+
const base = { parseError: false, notObject: false, unknownKeys: [], invalid: [] };
|
|
123
|
+
let raw;
|
|
124
|
+
try {
|
|
125
|
+
raw = JSON.parse(text);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return { ...base, parseError: true, ok: false };
|
|
129
|
+
}
|
|
130
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
131
|
+
return { ...base, notObject: true, ok: false };
|
|
132
|
+
}
|
|
133
|
+
const obj = raw;
|
|
134
|
+
const known = new Set(exports.CONFIG_KEYS);
|
|
135
|
+
const unknownKeys = Object.keys(obj).filter((k) => !known.has(k));
|
|
136
|
+
const invalid = [];
|
|
137
|
+
for (const key of exports.CONFIG_KEYS) {
|
|
138
|
+
if (!(key in obj))
|
|
139
|
+
continue;
|
|
140
|
+
const res = validateTypedValue(key, obj[key]);
|
|
141
|
+
if (!res.ok)
|
|
142
|
+
invalid.push(res.error);
|
|
143
|
+
}
|
|
144
|
+
return { parseError: false, notObject: false, unknownKeys, invalid, ok: invalid.length === 0 };
|
|
145
|
+
}
|
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,35 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resolveEditor = resolveEditor;
|
|
4
|
+
exports.openInEditor = openInEditor;
|
|
5
|
+
const child_process_1 = require("child_process");
|
|
6
|
+
/**
|
|
7
|
+
* Resolve the user's preferred editor as an argv array. Honors `$VISUAL` then
|
|
8
|
+
* `$EDITOR` (the long-standing Unix convention — `VISUAL` wins for full-screen
|
|
9
|
+
* editors), falling back to `notepad` on Windows and `vi` elsewhere. The env
|
|
10
|
+
* value may include flags (e.g. `code --wait`, `emacs -nw`), so it's split on
|
|
11
|
+
* whitespace into a command + args. Pure + unit-tested.
|
|
12
|
+
*/
|
|
13
|
+
function resolveEditor(env = process.env, platform = process.platform) {
|
|
14
|
+
const raw = (env.VISUAL || env.EDITOR || '').trim();
|
|
15
|
+
if (raw)
|
|
16
|
+
return raw.split(/\s+/);
|
|
17
|
+
return platform === 'win32' ? ['notepad'] : ['vi'];
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Open `file` in the resolved editor, inheriting the terminal so the editor is
|
|
21
|
+
* interactive. Returns a structured result rather than throwing so the caller
|
|
22
|
+
* controls messaging/exit. `spawn` is injected for tests.
|
|
23
|
+
*/
|
|
24
|
+
function openInEditor(file, deps = {}) {
|
|
25
|
+
const spawn = deps.spawn ?? child_process_1.spawnSync;
|
|
26
|
+
const [cmd, ...args] = resolveEditor(deps.env, deps.platform);
|
|
27
|
+
const res = spawn(cmd, [...args, file], { stdio: 'inherit' });
|
|
28
|
+
if (res.error) {
|
|
29
|
+
const err = res.error;
|
|
30
|
+
const reason = err.code === 'ENOENT' ? `editor "${cmd}" not found` : err.message;
|
|
31
|
+
return { ok: false, editor: cmd, error: reason };
|
|
32
|
+
}
|
|
33
|
+
const code = typeof res.status === 'number' ? res.status : 1;
|
|
34
|
+
return { ok: code === 0, editor: cmd, code };
|
|
35
|
+
}
|
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,163 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import { getUserConfig, saveUserConfig, CONFIG_PATH } from '../utils/config';
|
|
4
|
+
import { openInEditor } from '../utils/editor';
|
|
5
|
+
import {
|
|
6
|
+
CONFIG_KEYS,
|
|
7
|
+
CONFIG_SCHEMA,
|
|
8
|
+
isConfigKey,
|
|
9
|
+
applyConfigSet,
|
|
10
|
+
applyConfigUnset,
|
|
11
|
+
reviewConfigFileText,
|
|
12
|
+
formatConfigValue,
|
|
13
|
+
} from '../utils/config-schema';
|
|
14
|
+
import { outputJson, outputJsonError } from '../utils/output';
|
|
15
|
+
import { logSuccess, logError, logWarning } from '../utils/logger';
|
|
16
|
+
import { colorize } from '../utils/colors';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Non-interactive config management: `nemus config get/set/unset/list/path`.
|
|
20
|
+
* Complements the interactive `configure` wizard and is script-friendly —
|
|
21
|
+
* `get`/`list` write DATA to stdout (raw value, or JSON with --json), logs go to
|
|
22
|
+
* stderr. Values are validated/coerced against config-schema.ts.
|
|
23
|
+
*/
|
|
24
|
+
export function registerConfigCommand(parent: Command): void {
|
|
25
|
+
const config = parent.command('config').description('Get or set Nemus configuration');
|
|
26
|
+
|
|
27
|
+
config
|
|
28
|
+
.command('get')
|
|
29
|
+
.description('Print a config value (or all values with no key)')
|
|
30
|
+
.argument('[key]', 'Config key')
|
|
31
|
+
.option('--json', 'Output as JSON')
|
|
32
|
+
.action((key: string | undefined, opts: { json?: boolean }) => {
|
|
33
|
+
const cfg = getUserConfig();
|
|
34
|
+
if (key === undefined) {
|
|
35
|
+
printAll(cfg, opts.json);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (!isConfigKey(key)) {
|
|
39
|
+
if (opts.json) outputJsonError(`Unknown config key "${key}"`);
|
|
40
|
+
else logError(`Unknown config key "${key}". Run "nemus config list" to see valid keys.`);
|
|
41
|
+
process.exitCode = 1;
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const value = cfg[key];
|
|
45
|
+
if (opts.json) outputJson({ key, value });
|
|
46
|
+
else process.stdout.write(formatConfigValue(value) + '\n');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
config
|
|
50
|
+
.command('set')
|
|
51
|
+
.description('Set a config value')
|
|
52
|
+
.argument('<key>', 'Config key')
|
|
53
|
+
.argument('<value>', 'New value')
|
|
54
|
+
.option('--json', 'Output as JSON')
|
|
55
|
+
.action((key: string, value: string, opts: { json?: boolean }) => {
|
|
56
|
+
const result = applyConfigSet(getUserConfig(), key, value);
|
|
57
|
+
if (!result.ok) {
|
|
58
|
+
if (opts.json) outputJsonError(result.error);
|
|
59
|
+
else logError(result.error);
|
|
60
|
+
process.exitCode = 1;
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
saveUserConfig(result.next);
|
|
64
|
+
if (opts.json) outputJson({ ok: true, key, value: result.value });
|
|
65
|
+
else logSuccess(`Set ${colorize(key, 'cyan')} = ${formatConfigValue(result.value)}`);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
config
|
|
69
|
+
.command('unset')
|
|
70
|
+
.description('Reset a config value to its default')
|
|
71
|
+
.argument('<key>', 'Config key')
|
|
72
|
+
.option('--json', 'Output as JSON')
|
|
73
|
+
.action((key: string, opts: { json?: boolean }) => {
|
|
74
|
+
const result = applyConfigUnset(getUserConfig(), key);
|
|
75
|
+
if (!result.ok) {
|
|
76
|
+
if (opts.json) outputJsonError(result.error);
|
|
77
|
+
else logError(result.error);
|
|
78
|
+
process.exitCode = 1;
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
saveUserConfig(result.next);
|
|
82
|
+
if (opts.json) outputJson({ ok: true, key, value: result.value });
|
|
83
|
+
else logSuccess(`Reset ${colorize(key, 'cyan')} to default (${formatConfigValue(result.value)})`);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
config
|
|
87
|
+
.command('list')
|
|
88
|
+
.alias('ls')
|
|
89
|
+
.description('List all config keys and current values')
|
|
90
|
+
.option('--json', 'Output as JSON')
|
|
91
|
+
.action((opts: { json?: boolean }) => printAll(getUserConfig(), opts.json));
|
|
92
|
+
|
|
93
|
+
config
|
|
94
|
+
.command('path')
|
|
95
|
+
.description('Print the path to the config file')
|
|
96
|
+
.action(() => {
|
|
97
|
+
process.stdout.write(CONFIG_PATH + '\n');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
config
|
|
101
|
+
.command('edit')
|
|
102
|
+
.description('Open the config file in $EDITOR (or $VISUAL)')
|
|
103
|
+
.action(() => {
|
|
104
|
+
handleEdit();
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function handleEdit(): void {
|
|
109
|
+
if (!process.stdout.isTTY) {
|
|
110
|
+
logError('`config edit` needs an interactive terminal. Use `config set <key> <value>` in scripts.');
|
|
111
|
+
process.exitCode = 1;
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
// Seed the file with the fully-resolved config so there's something complete
|
|
115
|
+
// to edit on a first run (getUserConfig merges defaults + any overrides).
|
|
116
|
+
if (!fs.existsSync(CONFIG_PATH)) saveUserConfig(getUserConfig());
|
|
117
|
+
|
|
118
|
+
const result = openInEditor(CONFIG_PATH);
|
|
119
|
+
if (!result.ok) {
|
|
120
|
+
logError(result.error ?? `editor exited with code ${result.code}`);
|
|
121
|
+
process.exitCode = 1;
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Re-validate: a hand-edit can produce invalid JSON or invalid values, which
|
|
126
|
+
// getUserConfig would silently ignore (falling back to defaults). Surface that
|
|
127
|
+
// instead, using the SAME schema `config set` uses so both write paths agree.
|
|
128
|
+
const review = reviewConfigFileText(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
|
129
|
+
if (review.parseError) {
|
|
130
|
+
logError(`${CONFIG_PATH} is not valid JSON after editing — changes are kept, but Nemus will use defaults until it parses.`);
|
|
131
|
+
process.exitCode = 1;
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (review.notObject) {
|
|
135
|
+
logError(`${CONFIG_PATH} must contain a JSON object — changes are kept, but Nemus will use defaults until it does.`);
|
|
136
|
+
process.exitCode = 1;
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (review.unknownKeys.length > 0) logWarning(`Ignoring unrecognized key(s): ${review.unknownKeys.join(', ')}`);
|
|
140
|
+
if (!review.ok) {
|
|
141
|
+
for (const e of review.invalid) logWarning(e);
|
|
142
|
+
logError('Some values are invalid and will fall back to their defaults until fixed.');
|
|
143
|
+
process.exitCode = 1;
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
logSuccess('Config saved.');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function printAll(cfg: ReturnType<typeof getUserConfig>, json?: boolean): void {
|
|
150
|
+
if (json) {
|
|
151
|
+
const values: Record<string, unknown> = {};
|
|
152
|
+
for (const key of CONFIG_KEYS) values[key] = cfg[key];
|
|
153
|
+
outputJson({ path: CONFIG_PATH, values });
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const width = Math.max(...CONFIG_KEYS.map((k) => k.length));
|
|
157
|
+
console.log(colorize('Nemus configuration', 'bright') + colorize(` (${CONFIG_PATH})`, 'dim'));
|
|
158
|
+
for (const key of CONFIG_KEYS) {
|
|
159
|
+
const val = formatConfigValue(cfg[key]);
|
|
160
|
+
const shown = val === '' ? colorize('(empty)', 'dim') : val;
|
|
161
|
+
console.log(` ${key.padEnd(width)} ${shown} ${colorize(CONFIG_SCHEMA[key].describe, 'dim')}`);
|
|
162
|
+
}
|
|
163
|
+
}
|