memento-mori-jester 0.1.3
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/LICENSE +21 -0
- package/README.md +313 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +789 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +33 -0
- package/dist/config.js +295 -0
- package/dist/config.js.map +1 -0
- package/dist/core.d.ts +7 -0
- package/dist/core.js +489 -0
- package/dist/core.js.map +1 -0
- package/dist/format.d.ts +2 -0
- package/dist/format.js +25 -0
- package/dist/format.js.map +1 -0
- package/dist/hooks.d.ts +26 -0
- package/dist/hooks.js +133 -0
- package/dist/hooks.js.map +1 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +87 -0
- package/dist/server.js.map +1 -0
- package/dist/types.d.ts +61 -0
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/docs/AGENTS.md +145 -0
- package/docs/GITHUB_ACTIONS.md +116 -0
- package/docs/RELEASE.md +119 -0
- package/examples/github-action.yml +21 -0
- package/examples/jester.config.json +28 -0
- package/package.json +71 -0
- package/scripts/install.ps1 +30 -0
- package/scripts/install.sh +30 -0
- package/scripts/run-tests.mjs +32 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,789 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { configPresetNames, defaultUserConfig, findConfigPath, loadConfig, validateConfig, writeDefaultConfig } from "./config.js";
|
|
7
|
+
import { review, reviewCommand } from "./core.js";
|
|
8
|
+
import { formatReview } from "./format.js";
|
|
9
|
+
import { hookNames, hookStatus, installHook, isHookName, shellCommandPrefixForLocalCli, shellQuote, uninstallHook } from "./hooks.js";
|
|
10
|
+
import { reviewKinds, tones } from "./types.js";
|
|
11
|
+
const packageSpecDefault = "memento-mori-jester@latest";
|
|
12
|
+
const args = process.argv.slice(2);
|
|
13
|
+
main(args).catch((error) => {
|
|
14
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
15
|
+
process.exitCode = 1;
|
|
16
|
+
});
|
|
17
|
+
async function main(argv) {
|
|
18
|
+
if (argv.length === 0 || argv.includes("--help") || argv.includes("-h")) {
|
|
19
|
+
output.write(helpText());
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (argv[0] === "mcp-server") {
|
|
23
|
+
await import("./server.js");
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (argv[0] === "mcp-config") {
|
|
27
|
+
const setupOptions = parseSetupOptions(argv.slice(1));
|
|
28
|
+
output.write(`${JSON.stringify(mcpConfigSnippet(setupOptions), null, 2)}\n`);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (argv[0] === "init") {
|
|
32
|
+
const setupOptions = parseSetupOptions(argv.slice(1));
|
|
33
|
+
output.write(renderInit(setupOptions));
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (argv[0] === "bootstrap") {
|
|
37
|
+
output.write(await handleBootstrap(argv.slice(1)));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (argv[0] === "config") {
|
|
41
|
+
output.write(await handleConfigCommand(argv.slice(1)));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (argv[0] === "doctor") {
|
|
45
|
+
const doctorOptions = parseConfigCommandOptions(argv.slice(1));
|
|
46
|
+
const result = await renderDoctor(doctorOptions);
|
|
47
|
+
output.write(result.text);
|
|
48
|
+
if (!result.ok) {
|
|
49
|
+
process.exitCode = 1;
|
|
50
|
+
}
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (argv[0] === "install-hook") {
|
|
54
|
+
const result = await handleInstallHook(argv.slice(1));
|
|
55
|
+
output.write(`${result.message}\n`);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (argv[0] === "uninstall-hook") {
|
|
59
|
+
const result = await handleUninstallHook(argv.slice(1));
|
|
60
|
+
output.write(`${result.message}\n`);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (argv[0] === "hook-status") {
|
|
64
|
+
output.write(await renderHookStatus());
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const { command, rest } = splitCommand(argv);
|
|
68
|
+
const options = parseOptions(rest);
|
|
69
|
+
const kind = resolveKind(command, options.kind);
|
|
70
|
+
const content = await resolveContent(options, rest);
|
|
71
|
+
if (!content.trim()) {
|
|
72
|
+
throw new Error("Nothing to review. Pass text, use --file, or pipe content on stdin.");
|
|
73
|
+
}
|
|
74
|
+
const loadedConfig = await loadConfig({
|
|
75
|
+
configPath: options.configPath,
|
|
76
|
+
search: !options.noConfig
|
|
77
|
+
});
|
|
78
|
+
const inputForReview = {
|
|
79
|
+
kind,
|
|
80
|
+
content,
|
|
81
|
+
subject: options.subject,
|
|
82
|
+
context: options.context,
|
|
83
|
+
tone: options.tone,
|
|
84
|
+
intensity: options.intensity,
|
|
85
|
+
riskTolerance: options.riskTolerance,
|
|
86
|
+
config: loadedConfig.config
|
|
87
|
+
};
|
|
88
|
+
const result = review(inputForReview);
|
|
89
|
+
output.write(options.json ? `${JSON.stringify(result, null, 2)}\n` : `${formatReview(result)}\n`);
|
|
90
|
+
if (options.failOn === "block" && result.verdict === "block") {
|
|
91
|
+
process.exitCode = 2;
|
|
92
|
+
}
|
|
93
|
+
else if (options.failOn === "caution" && result.verdict !== "pass") {
|
|
94
|
+
process.exitCode = result.verdict === "block" ? 2 : 1;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function splitCommand(argv) {
|
|
98
|
+
const [first, ...rest] = argv;
|
|
99
|
+
if (reviewKinds.includes(first) || first === "review") {
|
|
100
|
+
return { command: first, rest };
|
|
101
|
+
}
|
|
102
|
+
return { command: "review", rest: argv };
|
|
103
|
+
}
|
|
104
|
+
function resolveKind(command, optionKind) {
|
|
105
|
+
if (command !== "review" && reviewKinds.includes(command)) {
|
|
106
|
+
return command;
|
|
107
|
+
}
|
|
108
|
+
return optionKind ?? "plan";
|
|
109
|
+
}
|
|
110
|
+
function parseOptions(argv) {
|
|
111
|
+
const options = { json: false, noConfig: false };
|
|
112
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
113
|
+
const arg = argv[index];
|
|
114
|
+
const next = argv[index + 1];
|
|
115
|
+
if (arg === "--json") {
|
|
116
|
+
options.json = true;
|
|
117
|
+
}
|
|
118
|
+
else if (arg === "--kind") {
|
|
119
|
+
options.kind = parseKind(requireValue(arg, next));
|
|
120
|
+
index += 1;
|
|
121
|
+
}
|
|
122
|
+
else if (arg === "--tone") {
|
|
123
|
+
options.tone = parseTone(requireValue(arg, next));
|
|
124
|
+
index += 1;
|
|
125
|
+
}
|
|
126
|
+
else if (arg === "--intensity") {
|
|
127
|
+
options.intensity = Number.parseInt(requireValue(arg, next), 10);
|
|
128
|
+
index += 1;
|
|
129
|
+
}
|
|
130
|
+
else if (arg === "--risk") {
|
|
131
|
+
options.riskTolerance = parseRisk(requireValue(arg, next));
|
|
132
|
+
index += 1;
|
|
133
|
+
}
|
|
134
|
+
else if (arg === "--fail-on") {
|
|
135
|
+
options.failOn = parseFailOn(requireValue(arg, next));
|
|
136
|
+
index += 1;
|
|
137
|
+
}
|
|
138
|
+
else if (arg === "--subject") {
|
|
139
|
+
options.subject = requireValue(arg, next);
|
|
140
|
+
index += 1;
|
|
141
|
+
}
|
|
142
|
+
else if (arg === "--context") {
|
|
143
|
+
options.context = requireValue(arg, next);
|
|
144
|
+
index += 1;
|
|
145
|
+
}
|
|
146
|
+
else if (arg === "--file") {
|
|
147
|
+
options.file = requireValue(arg, next);
|
|
148
|
+
index += 1;
|
|
149
|
+
}
|
|
150
|
+
else if (arg === "--config") {
|
|
151
|
+
options.configPath = requireValue(arg, next);
|
|
152
|
+
index += 1;
|
|
153
|
+
}
|
|
154
|
+
else if (arg === "--no-config") {
|
|
155
|
+
options.noConfig = true;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return options;
|
|
159
|
+
}
|
|
160
|
+
function parseConfigCommandOptions(argv) {
|
|
161
|
+
const options = {
|
|
162
|
+
json: false,
|
|
163
|
+
force: false,
|
|
164
|
+
noConfig: false,
|
|
165
|
+
preset: "default"
|
|
166
|
+
};
|
|
167
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
168
|
+
const arg = argv[index];
|
|
169
|
+
const next = argv[index + 1];
|
|
170
|
+
if (arg === "--json") {
|
|
171
|
+
options.json = true;
|
|
172
|
+
}
|
|
173
|
+
else if (arg === "--force") {
|
|
174
|
+
options.force = true;
|
|
175
|
+
}
|
|
176
|
+
else if (arg === "--path") {
|
|
177
|
+
options.path = requireValue(arg, next);
|
|
178
|
+
index += 1;
|
|
179
|
+
}
|
|
180
|
+
else if (arg === "--config") {
|
|
181
|
+
options.configPath = requireValue(arg, next);
|
|
182
|
+
index += 1;
|
|
183
|
+
}
|
|
184
|
+
else if (arg === "--no-config") {
|
|
185
|
+
options.noConfig = true;
|
|
186
|
+
}
|
|
187
|
+
else if (arg === "--preset") {
|
|
188
|
+
options.preset = parseConfigPreset(requireValue(arg, next));
|
|
189
|
+
index += 1;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return options;
|
|
193
|
+
}
|
|
194
|
+
function parseSetupOptions(argv) {
|
|
195
|
+
const options = {
|
|
196
|
+
mode: "npx",
|
|
197
|
+
agent: "generic",
|
|
198
|
+
packageSpec: packageSpecDefault,
|
|
199
|
+
tone: "court_jester",
|
|
200
|
+
intensity: 3,
|
|
201
|
+
riskTolerance: "medium",
|
|
202
|
+
json: false
|
|
203
|
+
};
|
|
204
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
205
|
+
const arg = argv[index];
|
|
206
|
+
const next = argv[index + 1];
|
|
207
|
+
if (arg === "--json") {
|
|
208
|
+
options.json = true;
|
|
209
|
+
}
|
|
210
|
+
else if (arg === "--mode") {
|
|
211
|
+
options.mode = parseSetupMode(requireValue(arg, next));
|
|
212
|
+
index += 1;
|
|
213
|
+
}
|
|
214
|
+
else if (arg === "--agent") {
|
|
215
|
+
options.agent = parseAgent(requireValue(arg, next));
|
|
216
|
+
index += 1;
|
|
217
|
+
}
|
|
218
|
+
else if (arg === "--package") {
|
|
219
|
+
options.packageSpec = requireValue(arg, next);
|
|
220
|
+
index += 1;
|
|
221
|
+
}
|
|
222
|
+
else if (arg === "--tone") {
|
|
223
|
+
options.tone = parseTone(requireValue(arg, next));
|
|
224
|
+
index += 1;
|
|
225
|
+
}
|
|
226
|
+
else if (arg === "--intensity") {
|
|
227
|
+
options.intensity = Number.parseInt(requireValue(arg, next), 10);
|
|
228
|
+
index += 1;
|
|
229
|
+
}
|
|
230
|
+
else if (arg === "--risk") {
|
|
231
|
+
options.riskTolerance = parseRisk(requireValue(arg, next));
|
|
232
|
+
index += 1;
|
|
233
|
+
}
|
|
234
|
+
else if (!arg.startsWith("--")) {
|
|
235
|
+
if (isSetupMode(arg)) {
|
|
236
|
+
options.mode = arg;
|
|
237
|
+
}
|
|
238
|
+
else if (isAgent(arg)) {
|
|
239
|
+
options.agent = arg;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return options;
|
|
244
|
+
}
|
|
245
|
+
async function resolveContent(options, argv) {
|
|
246
|
+
if (options.file) {
|
|
247
|
+
return readFile(options.file, "utf8");
|
|
248
|
+
}
|
|
249
|
+
const positional = collectPositional(argv);
|
|
250
|
+
if (positional.length > 0) {
|
|
251
|
+
return positional.join(" ");
|
|
252
|
+
}
|
|
253
|
+
if (!input.isTTY) {
|
|
254
|
+
return readStdin();
|
|
255
|
+
}
|
|
256
|
+
return "";
|
|
257
|
+
}
|
|
258
|
+
function collectPositional(argv) {
|
|
259
|
+
const positional = [];
|
|
260
|
+
let afterSeparator = false;
|
|
261
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
262
|
+
const arg = argv[index];
|
|
263
|
+
if (arg === "--") {
|
|
264
|
+
afterSeparator = true;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (!afterSeparator && isKnownOption(arg)) {
|
|
268
|
+
index += optionHasValue(arg) ? 1 : 0;
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
positional.push(arg);
|
|
272
|
+
}
|
|
273
|
+
return positional;
|
|
274
|
+
}
|
|
275
|
+
function optionHasValue(arg) {
|
|
276
|
+
return ["--kind", "--tone", "--intensity", "--risk", "--fail-on", "--subject", "--context", "--file", "--config", "--path", "--preset"].includes(arg);
|
|
277
|
+
}
|
|
278
|
+
function isKnownOption(arg) {
|
|
279
|
+
return optionHasValue(arg) || ["--json", "--no-config", "--force"].includes(arg);
|
|
280
|
+
}
|
|
281
|
+
function readStdin() {
|
|
282
|
+
return new Promise((resolve, reject) => {
|
|
283
|
+
let data = "";
|
|
284
|
+
input.setEncoding("utf8");
|
|
285
|
+
input.on("data", (chunk) => {
|
|
286
|
+
data += chunk;
|
|
287
|
+
});
|
|
288
|
+
input.on("end", () => resolve(data));
|
|
289
|
+
input.on("error", reject);
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
function parseKind(value) {
|
|
293
|
+
if (reviewKinds.includes(value)) {
|
|
294
|
+
return value;
|
|
295
|
+
}
|
|
296
|
+
throw new Error(`Unknown review kind "${value}". Use one of: ${reviewKinds.join(", ")}`);
|
|
297
|
+
}
|
|
298
|
+
function parseTone(value) {
|
|
299
|
+
if (tones.includes(value)) {
|
|
300
|
+
return value;
|
|
301
|
+
}
|
|
302
|
+
throw new Error(`Unknown tone "${value}". Use one of: ${tones.join(", ")}`);
|
|
303
|
+
}
|
|
304
|
+
function parseRisk(value) {
|
|
305
|
+
if (value === "low" || value === "medium" || value === "high") {
|
|
306
|
+
return value;
|
|
307
|
+
}
|
|
308
|
+
throw new Error('Unknown risk tolerance. Use "low", "medium", or "high".');
|
|
309
|
+
}
|
|
310
|
+
function parseFailOn(value) {
|
|
311
|
+
if (value === "caution" || value === "block") {
|
|
312
|
+
return value;
|
|
313
|
+
}
|
|
314
|
+
throw new Error('Unknown fail threshold. Use "caution" or "block".');
|
|
315
|
+
}
|
|
316
|
+
function parseConfigPreset(value) {
|
|
317
|
+
if (configPresetNames.includes(value)) {
|
|
318
|
+
return value;
|
|
319
|
+
}
|
|
320
|
+
throw new Error(`Unknown config preset "${value}". Use one of: ${configPresetNames.join(", ")}`);
|
|
321
|
+
}
|
|
322
|
+
function parseSetupMode(value) {
|
|
323
|
+
if (isSetupMode(value)) {
|
|
324
|
+
return value;
|
|
325
|
+
}
|
|
326
|
+
throw new Error('Unknown setup mode. Use "npx", "global", or "local".');
|
|
327
|
+
}
|
|
328
|
+
function isSetupMode(value) {
|
|
329
|
+
return value === "npx" || value === "global" || value === "local";
|
|
330
|
+
}
|
|
331
|
+
function parseAgent(value) {
|
|
332
|
+
if (isAgent(value)) {
|
|
333
|
+
return value;
|
|
334
|
+
}
|
|
335
|
+
throw new Error('Unknown agent target. Use "generic", "claude", or "codex".');
|
|
336
|
+
}
|
|
337
|
+
function isAgent(value) {
|
|
338
|
+
return value === "generic" || value === "claude" || value === "codex";
|
|
339
|
+
}
|
|
340
|
+
function requireValue(flag, value) {
|
|
341
|
+
if (!value || value.startsWith("--")) {
|
|
342
|
+
throw new Error(`Missing value for ${flag}.`);
|
|
343
|
+
}
|
|
344
|
+
return value;
|
|
345
|
+
}
|
|
346
|
+
function mcpConfigSnippet(options) {
|
|
347
|
+
return {
|
|
348
|
+
mcpServers: {
|
|
349
|
+
"memento-mori-jester": {
|
|
350
|
+
...mcpCommandSpec(options)
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
function mcpCommandSpec(options) {
|
|
356
|
+
if (options.mode === "local") {
|
|
357
|
+
return {
|
|
358
|
+
command: "node",
|
|
359
|
+
args: [serverPath()]
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
if (options.mode === "global") {
|
|
363
|
+
return {
|
|
364
|
+
command: "memento-mori-jester-mcp",
|
|
365
|
+
args: []
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
return {
|
|
369
|
+
command: "npx",
|
|
370
|
+
args: ["-y", options.packageSpec, "mcp-server"]
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
function serverPath() {
|
|
374
|
+
return join(dirname(fileURLToPath(import.meta.url)), "server.js");
|
|
375
|
+
}
|
|
376
|
+
function cliPath() {
|
|
377
|
+
return fileURLToPath(import.meta.url);
|
|
378
|
+
}
|
|
379
|
+
function renderInit(options) {
|
|
380
|
+
const cliCommand = renderCliCommand(options);
|
|
381
|
+
const config = JSON.stringify(mcpConfigSnippet(options), null, 2);
|
|
382
|
+
const agentLine = options.agent === "generic"
|
|
383
|
+
? "Paste this into any MCP client that accepts the standard mcpServers JSON shape."
|
|
384
|
+
: `Use this for ${options.agent}; if its config format differs, keep the command and args values.`;
|
|
385
|
+
return `Memento Mori Jester setup
|
|
386
|
+
|
|
387
|
+
Try it now:
|
|
388
|
+
${cliCommand} command "git reset --hard"
|
|
389
|
+
|
|
390
|
+
MCP config (${options.mode} mode):
|
|
391
|
+
${config}
|
|
392
|
+
|
|
393
|
+
${agentLine}
|
|
394
|
+
|
|
395
|
+
Suggested agent instruction:
|
|
396
|
+
Before risky commands, final answers, commits, or large edits, call the Memento Mori Jester. Treat BLOCK as requiring a changed plan, and CAUTION as requiring at least one concrete verification step.
|
|
397
|
+
|
|
398
|
+
Useful next checks:
|
|
399
|
+
${cliCommand} doctor
|
|
400
|
+
${cliCommand} config init
|
|
401
|
+
${cliCommand} install-hook pre-commit
|
|
402
|
+
${cliCommand} plan "I will just refactor auth and ship it"
|
|
403
|
+
`;
|
|
404
|
+
}
|
|
405
|
+
function renderCliCommand(options) {
|
|
406
|
+
if (options.mode === "global") {
|
|
407
|
+
return "jester";
|
|
408
|
+
}
|
|
409
|
+
if (options.mode === "local") {
|
|
410
|
+
return `node "${cliPath()}"`;
|
|
411
|
+
}
|
|
412
|
+
return `npx -y ${options.packageSpec}`;
|
|
413
|
+
}
|
|
414
|
+
async function handleConfigCommand(argv) {
|
|
415
|
+
const [subcommand = "show"] = argv;
|
|
416
|
+
const options = parseConfigCommandOptions(argv.slice(1));
|
|
417
|
+
if (subcommand === "init") {
|
|
418
|
+
const path = await writeDefaultConfig({
|
|
419
|
+
path: options.path,
|
|
420
|
+
force: options.force,
|
|
421
|
+
preset: options.preset
|
|
422
|
+
});
|
|
423
|
+
return `Wrote ${path}\n`;
|
|
424
|
+
}
|
|
425
|
+
if (subcommand === "presets") {
|
|
426
|
+
return `${configPresetNames.join("\n")}\n`;
|
|
427
|
+
}
|
|
428
|
+
if (subcommand === "show") {
|
|
429
|
+
const loaded = await loadConfig({
|
|
430
|
+
configPath: options.configPath,
|
|
431
|
+
search: !options.noConfig
|
|
432
|
+
});
|
|
433
|
+
if (options.json) {
|
|
434
|
+
return `${JSON.stringify(loaded, null, 2)}\n`;
|
|
435
|
+
}
|
|
436
|
+
const label = loaded.path ? `Loaded ${loaded.path}` : "No config file found; using built-in defaults.";
|
|
437
|
+
return `${label}\n${JSON.stringify({ ...defaultUserConfig(), ...loaded.config }, null, 2)}\n`;
|
|
438
|
+
}
|
|
439
|
+
if (subcommand === "validate") {
|
|
440
|
+
const result = await validateConfig({
|
|
441
|
+
configPath: options.configPath,
|
|
442
|
+
search: !options.noConfig
|
|
443
|
+
});
|
|
444
|
+
if (options.json) {
|
|
445
|
+
return `${JSON.stringify(result, null, 2)}\n`;
|
|
446
|
+
}
|
|
447
|
+
if (result.ok) {
|
|
448
|
+
return `Config valid: ${result.path}\n`;
|
|
449
|
+
}
|
|
450
|
+
process.exitCode = 1;
|
|
451
|
+
return `Config invalid${result.path ? `: ${result.path}` : ""}\n${result.issues.map((issue) => `- ${issue}`).join("\n")}\n`;
|
|
452
|
+
}
|
|
453
|
+
throw new Error('Unknown config command. Use "jester config init", "jester config show", "jester config validate", or "jester config presets".');
|
|
454
|
+
}
|
|
455
|
+
async function handleBootstrap(argv) {
|
|
456
|
+
const options = parseBootstrapOptions(argv);
|
|
457
|
+
const configFile = await ensureBootstrapConfig(options);
|
|
458
|
+
const mcpFile = await writeStarterFile({
|
|
459
|
+
relativePath: "memento-mori.mcp.json",
|
|
460
|
+
content: `${JSON.stringify(mcpConfigSnippet(options), null, 2)}\n`,
|
|
461
|
+
force: options.force
|
|
462
|
+
});
|
|
463
|
+
const instructionsFile = await writeStarterFile({
|
|
464
|
+
relativePath: "MEMENTO_MORI.md",
|
|
465
|
+
content: renderBootstrapInstructions(options),
|
|
466
|
+
force: options.force
|
|
467
|
+
});
|
|
468
|
+
const loaded = await loadConfig({ configPath: configFile.path, search: false });
|
|
469
|
+
const failOn = loaded.config.hookFailOn ?? "block";
|
|
470
|
+
const hooks = [];
|
|
471
|
+
for (const hook of options.hooks) {
|
|
472
|
+
hooks.push(await installHook({
|
|
473
|
+
hook,
|
|
474
|
+
commandPrefix: hookCommandPrefix(options),
|
|
475
|
+
failOn,
|
|
476
|
+
force: options.force
|
|
477
|
+
}));
|
|
478
|
+
}
|
|
479
|
+
const result = {
|
|
480
|
+
ok: true,
|
|
481
|
+
mode: options.mode,
|
|
482
|
+
agent: options.agent,
|
|
483
|
+
preset: options.preset,
|
|
484
|
+
files: [configFile, mcpFile, instructionsFile],
|
|
485
|
+
hooks,
|
|
486
|
+
nextSteps: [
|
|
487
|
+
`${renderCliCommand(options)} doctor`,
|
|
488
|
+
`${renderCliCommand(options)} config validate`,
|
|
489
|
+
"Add memento-mori.mcp.json to your MCP client, or copy the command and args from it."
|
|
490
|
+
]
|
|
491
|
+
};
|
|
492
|
+
if (options.json) {
|
|
493
|
+
return `${JSON.stringify(result, null, 2)}\n`;
|
|
494
|
+
}
|
|
495
|
+
const lines = [
|
|
496
|
+
"Memento Mori Jester bootstrap",
|
|
497
|
+
"",
|
|
498
|
+
"Files:",
|
|
499
|
+
...result.files.map((file) => ` ${file.changed ? "wrote" : "kept"} ${file.path}`),
|
|
500
|
+
""
|
|
501
|
+
];
|
|
502
|
+
if (hooks.length > 0) {
|
|
503
|
+
lines.push("Hooks:", ...hooks.map((hook) => ` ${hook.message}`), "");
|
|
504
|
+
}
|
|
505
|
+
lines.push("Next:", ...result.nextSteps.map((step) => ` ${step}`), "");
|
|
506
|
+
return lines.join("\n");
|
|
507
|
+
}
|
|
508
|
+
async function handleInstallHook(argv) {
|
|
509
|
+
const options = await parseHookCommandOptions(argv);
|
|
510
|
+
const loaded = await loadConfig({
|
|
511
|
+
configPath: options.configPath,
|
|
512
|
+
search: !options.noConfig
|
|
513
|
+
});
|
|
514
|
+
const failOn = options.failOn ?? loaded.config.hookFailOn ?? "block";
|
|
515
|
+
return installHook({
|
|
516
|
+
hook: options.hook,
|
|
517
|
+
commandPrefix: hookCommandPrefix(options.setup),
|
|
518
|
+
failOn,
|
|
519
|
+
force: options.force
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
async function handleUninstallHook(argv) {
|
|
523
|
+
const options = await parseHookCommandOptions(argv);
|
|
524
|
+
return uninstallHook(options.hook, { force: options.force });
|
|
525
|
+
}
|
|
526
|
+
async function parseHookCommandOptions(argv) {
|
|
527
|
+
const setup = parseSetupOptions(argv);
|
|
528
|
+
let hook;
|
|
529
|
+
let failOn;
|
|
530
|
+
let force = false;
|
|
531
|
+
let configPath;
|
|
532
|
+
let noConfig = false;
|
|
533
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
534
|
+
const arg = argv[index];
|
|
535
|
+
const next = argv[index + 1];
|
|
536
|
+
if (isHookName(arg)) {
|
|
537
|
+
hook = arg;
|
|
538
|
+
}
|
|
539
|
+
else if (arg === "--fail-on") {
|
|
540
|
+
failOn = parseFailOn(requireValue(arg, next));
|
|
541
|
+
index += 1;
|
|
542
|
+
}
|
|
543
|
+
else if (arg === "--force") {
|
|
544
|
+
force = true;
|
|
545
|
+
}
|
|
546
|
+
else if (arg === "--config") {
|
|
547
|
+
configPath = requireValue(arg, next);
|
|
548
|
+
index += 1;
|
|
549
|
+
}
|
|
550
|
+
else if (arg === "--no-config") {
|
|
551
|
+
noConfig = true;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
if (!hook) {
|
|
555
|
+
throw new Error(`Missing hook name. Use one of: ${hookNames.join(", ")}`);
|
|
556
|
+
}
|
|
557
|
+
return {
|
|
558
|
+
hook,
|
|
559
|
+
setup,
|
|
560
|
+
failOn,
|
|
561
|
+
force,
|
|
562
|
+
configPath,
|
|
563
|
+
noConfig
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
function parseBootstrapOptions(argv) {
|
|
567
|
+
const setup = parseSetupOptions(argv);
|
|
568
|
+
let preset = "default";
|
|
569
|
+
let force = false;
|
|
570
|
+
const hooks = [];
|
|
571
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
572
|
+
const arg = argv[index];
|
|
573
|
+
const next = argv[index + 1];
|
|
574
|
+
if (arg === "--preset") {
|
|
575
|
+
preset = parseConfigPreset(requireValue(arg, next));
|
|
576
|
+
index += 1;
|
|
577
|
+
}
|
|
578
|
+
else if (arg === "--force") {
|
|
579
|
+
force = true;
|
|
580
|
+
}
|
|
581
|
+
else if (arg === "--hook") {
|
|
582
|
+
const hook = requireValue(arg, next);
|
|
583
|
+
if (!isHookName(hook)) {
|
|
584
|
+
throw new Error(`Unknown hook "${hook}". Use one of: ${hookNames.join(", ")}`);
|
|
585
|
+
}
|
|
586
|
+
hooks.push(hook);
|
|
587
|
+
index += 1;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
return {
|
|
591
|
+
...setup,
|
|
592
|
+
preset,
|
|
593
|
+
force,
|
|
594
|
+
hooks: [...new Set(hooks)]
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
async function renderHookStatus() {
|
|
598
|
+
const statuses = await hookStatus();
|
|
599
|
+
return `${statuses.map((status) => `${status.hook}: ${status.message} (${status.path})`).join("\n")}\n`;
|
|
600
|
+
}
|
|
601
|
+
function hookCommandPrefix(options) {
|
|
602
|
+
if (options.mode === "local") {
|
|
603
|
+
return shellCommandPrefixForLocalCli(cliPath());
|
|
604
|
+
}
|
|
605
|
+
if (options.mode === "global") {
|
|
606
|
+
return "jester";
|
|
607
|
+
}
|
|
608
|
+
return `npx -y ${shellQuote(options.packageSpec)}`;
|
|
609
|
+
}
|
|
610
|
+
async function renderDoctor(options) {
|
|
611
|
+
let configCheck;
|
|
612
|
+
try {
|
|
613
|
+
const loaded = await loadConfig({
|
|
614
|
+
configPath: options.configPath,
|
|
615
|
+
search: !options.noConfig
|
|
616
|
+
});
|
|
617
|
+
configCheck = {
|
|
618
|
+
name: "config",
|
|
619
|
+
ok: true,
|
|
620
|
+
detail: loaded.path ? `Loaded ${loaded.path}.` : "No config file found; using built-in defaults."
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
catch (error) {
|
|
624
|
+
configCheck = {
|
|
625
|
+
name: "config",
|
|
626
|
+
ok: false,
|
|
627
|
+
detail: error instanceof Error ? error.message : String(error)
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
const checks = [
|
|
631
|
+
{
|
|
632
|
+
name: "node-version",
|
|
633
|
+
ok: nodeMajorVersion() >= 20,
|
|
634
|
+
detail: `Node ${process.version}; required >=20.`
|
|
635
|
+
},
|
|
636
|
+
{
|
|
637
|
+
name: "mcp-server-file",
|
|
638
|
+
ok: await fileExists(serverPath()),
|
|
639
|
+
detail: serverPath()
|
|
640
|
+
},
|
|
641
|
+
{
|
|
642
|
+
name: "review-engine",
|
|
643
|
+
ok: reviewCommand("git reset --hard").verdict === "block",
|
|
644
|
+
detail: "Dangerous git command is blocked."
|
|
645
|
+
},
|
|
646
|
+
configCheck
|
|
647
|
+
];
|
|
648
|
+
const ok = checks.every((check) => check.ok);
|
|
649
|
+
if (options.json) {
|
|
650
|
+
return {
|
|
651
|
+
ok,
|
|
652
|
+
text: `${JSON.stringify({ ok, checks }, null, 2)}\n`
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
const lines = [
|
|
656
|
+
"Memento Mori Jester doctor",
|
|
657
|
+
"",
|
|
658
|
+
...checks.map((check) => `${check.ok ? "PASS" : "FAIL"} ${check.name}: ${check.detail}`),
|
|
659
|
+
"",
|
|
660
|
+
ok
|
|
661
|
+
? "The fool is fit for court."
|
|
662
|
+
: "Something needs fixing before the fool can be trusted with sharp objects.",
|
|
663
|
+
""
|
|
664
|
+
];
|
|
665
|
+
return { ok, text: lines.join("\n") };
|
|
666
|
+
}
|
|
667
|
+
async function ensureBootstrapConfig(options) {
|
|
668
|
+
const existing = await findConfigPath();
|
|
669
|
+
if (existing && !options.force) {
|
|
670
|
+
return {
|
|
671
|
+
path: existing,
|
|
672
|
+
changed: false,
|
|
673
|
+
message: "Kept existing config file."
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
const path = await writeDefaultConfig({ force: options.force, preset: options.preset });
|
|
677
|
+
return {
|
|
678
|
+
path,
|
|
679
|
+
changed: true,
|
|
680
|
+
message: "Wrote project config."
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
async function writeStarterFile(options) {
|
|
684
|
+
const path = resolve(process.cwd(), options.relativePath);
|
|
685
|
+
const exists = await fileExists(path);
|
|
686
|
+
if (exists && !options.force) {
|
|
687
|
+
return {
|
|
688
|
+
path,
|
|
689
|
+
changed: false,
|
|
690
|
+
message: `Kept existing ${options.relativePath}.`
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
await mkdir(dirname(path), { recursive: true });
|
|
694
|
+
await writeFile(path, options.content, "utf8");
|
|
695
|
+
return {
|
|
696
|
+
path,
|
|
697
|
+
changed: true,
|
|
698
|
+
message: `Wrote ${options.relativePath}.`
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
function renderBootstrapInstructions(options) {
|
|
702
|
+
const cliCommand = renderCliCommand(options);
|
|
703
|
+
return `# Memento Mori Jester
|
|
704
|
+
|
|
705
|
+
## Agent Instruction
|
|
706
|
+
|
|
707
|
+
Before risky commands, final answers, commits, or large edits, call the Memento Mori Jester. Treat BLOCK as requiring a changed plan, and CAUTION as requiring at least one concrete verification step.
|
|
708
|
+
|
|
709
|
+
## MCP
|
|
710
|
+
|
|
711
|
+
Use \`memento-mori.mcp.json\` with an MCP client, or copy the command and args from it into the client's config.
|
|
712
|
+
|
|
713
|
+
## Local Checks
|
|
714
|
+
|
|
715
|
+
\`\`\`powershell
|
|
716
|
+
${cliCommand} doctor
|
|
717
|
+
${cliCommand} config validate
|
|
718
|
+
${cliCommand} command "git reset --hard"
|
|
719
|
+
git diff | ${cliCommand} diff --fail-on block
|
|
720
|
+
\`\`\`
|
|
721
|
+
|
|
722
|
+
## Git Hooks
|
|
723
|
+
|
|
724
|
+
\`\`\`powershell
|
|
725
|
+
${cliCommand} install-hook pre-commit
|
|
726
|
+
${cliCommand} install-hook pre-push --fail-on caution
|
|
727
|
+
\`\`\`
|
|
728
|
+
`;
|
|
729
|
+
}
|
|
730
|
+
function nodeMajorVersion() {
|
|
731
|
+
return Number.parseInt(process.versions.node.split(".")[0] ?? "0", 10);
|
|
732
|
+
}
|
|
733
|
+
async function fileExists(path) {
|
|
734
|
+
try {
|
|
735
|
+
await access(path);
|
|
736
|
+
return true;
|
|
737
|
+
}
|
|
738
|
+
catch {
|
|
739
|
+
return false;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
function helpText() {
|
|
743
|
+
return `Memento Mori Jester
|
|
744
|
+
|
|
745
|
+
Usage:
|
|
746
|
+
jester plan "I will just refactor auth and ship it"
|
|
747
|
+
jester command "Remove-Item .\\\\dist -Recurse -Force"
|
|
748
|
+
git diff | jester diff --fail-on block
|
|
749
|
+
jester final --file final-answer.txt --tone professional
|
|
750
|
+
jester init
|
|
751
|
+
jester bootstrap --preset node
|
|
752
|
+
jester doctor
|
|
753
|
+
jester config init
|
|
754
|
+
jester config init --preset security
|
|
755
|
+
jester config show
|
|
756
|
+
jester config validate
|
|
757
|
+
jester config presets
|
|
758
|
+
jester install-hook pre-commit
|
|
759
|
+
jester install-hook pre-push --fail-on caution
|
|
760
|
+
jester hook-status
|
|
761
|
+
jester mcp-config --mode npx
|
|
762
|
+
jester mcp-server
|
|
763
|
+
|
|
764
|
+
Options:
|
|
765
|
+
--kind <plan|command|diff|final> Review kind when using "review"
|
|
766
|
+
--tone <gentle_stoic|court_jester|absolute_menace|professional>
|
|
767
|
+
--intensity <1-5>
|
|
768
|
+
--risk <low|medium|high>
|
|
769
|
+
--fail-on <caution|block> Set a non-zero exit code at this verdict
|
|
770
|
+
--subject <text>
|
|
771
|
+
--context <text>
|
|
772
|
+
--file <path>
|
|
773
|
+
--config <path> Use a specific jester config file
|
|
774
|
+
--no-config Ignore jester.config.json discovery
|
|
775
|
+
--preset <default|node|python|security>
|
|
776
|
+
--json
|
|
777
|
+
|
|
778
|
+
Setup options:
|
|
779
|
+
--mode <npx|global|local> MCP command style; default is npx
|
|
780
|
+
--agent <generic|claude|codex> Label the generated setup guidance
|
|
781
|
+
--package <npm-or-git-spec> Package spec used by npx mode
|
|
782
|
+
--hook <pre-commit|pre-push> Install a hook during bootstrap; repeatable
|
|
783
|
+
|
|
784
|
+
Hook options:
|
|
785
|
+
--fail-on <caution|block> Hook failure threshold; defaults to config hookFailOn or block
|
|
786
|
+
--force Replace existing hooks or bootstrap files
|
|
787
|
+
`;
|
|
788
|
+
}
|
|
789
|
+
//# sourceMappingURL=cli.js.map
|