@wyattjoh/demur 0.4.2 → 0.6.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/README.md +99 -21
- package/extensions/demur/cost-tracker.ts +31 -6
- package/extensions/demur/index.ts +150 -36
- package/extensions/demur/paths.ts +46 -0
- package/extensions/demur/settings.ts +77 -19
- package/extensions/demur/training-store.ts +334 -0
- package/package.json +12 -3
- package/src/cli.ts +508 -1
- package/src/settings-model.ts +60 -0
- package/src/training-review-model.ts +175 -0
- package/src/training-review-tui.tsx +1231 -0
package/src/cli.ts
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Predicate } from "effect";
|
|
3
|
+
import { loadCostTotals } from "../extensions/demur/cost-tracker.ts";
|
|
4
|
+
import {
|
|
5
|
+
loadDemurSettings,
|
|
6
|
+
saveDemurSettings,
|
|
7
|
+
type DemurSettings,
|
|
8
|
+
} from "../extensions/demur/settings.ts";
|
|
9
|
+
import {
|
|
10
|
+
loadTrainingRecords,
|
|
11
|
+
loadTrainingReviews,
|
|
12
|
+
recordTrainingReview,
|
|
13
|
+
type TrainingRecord,
|
|
14
|
+
type TrainingReview,
|
|
15
|
+
type TrainingReviewInput,
|
|
16
|
+
} from "../extensions/demur/training-store.ts";
|
|
3
17
|
import { guard } from "./guard.ts";
|
|
4
18
|
import {
|
|
5
19
|
deleteApiKey,
|
|
@@ -7,12 +21,27 @@ import {
|
|
|
7
21
|
storeApiKey,
|
|
8
22
|
type ResolvedApiKey,
|
|
9
23
|
} from "./key.ts";
|
|
24
|
+
import {
|
|
25
|
+
buildTrainingReviewEntries,
|
|
26
|
+
createTrainingReviewInput,
|
|
27
|
+
filterTrainingReviewEntries,
|
|
28
|
+
getLatestTrainingReview,
|
|
29
|
+
getTrainingReviewFilter,
|
|
30
|
+
type TrainingReviewEntry,
|
|
31
|
+
type TrainingReviewFilter,
|
|
32
|
+
type TrainingReviewSnapshot,
|
|
33
|
+
} from "./training-review-model.ts";
|
|
34
|
+
import type { TrainingReviewTuiResult } from "./training-review-tui.tsx";
|
|
10
35
|
import type { Verdict } from "./types.ts";
|
|
11
36
|
|
|
12
37
|
const USAGE = `Usage:
|
|
38
|
+
demur
|
|
13
39
|
demur auth login
|
|
14
40
|
demur auth status
|
|
15
41
|
demur auth logout
|
|
42
|
+
demur training list [--status=<all|unreviewed|allow|ask|deny>] [--cwd=<query>] [--json]
|
|
43
|
+
demur training review
|
|
44
|
+
demur training review <record-id> --decision=<allow|ask|deny> [--note=<text>] [--json]
|
|
16
45
|
demur judge "<command>" [--cwd=<path>]`;
|
|
17
46
|
|
|
18
47
|
/**
|
|
@@ -23,6 +52,20 @@ export type CliDependencies = {
|
|
|
23
52
|
resolveApiKey(): Promise<ResolvedApiKey | undefined>;
|
|
24
53
|
storeApiKey(value: string): Promise<void>;
|
|
25
54
|
deleteApiKey(): Promise<boolean>;
|
|
55
|
+
loadTrainingRecords(): Promise<ReadonlyArray<TrainingRecord>>;
|
|
56
|
+
loadTrainingReviews(): Promise<ReadonlyArray<TrainingReview>>;
|
|
57
|
+
loadGlobalEstimatedCostUsd(): Promise<number>;
|
|
58
|
+
recordTrainingReview(input: TrainingReviewInput): Promise<TrainingReview>;
|
|
59
|
+
loadDemurSettings(): Promise<DemurSettings>;
|
|
60
|
+
saveDemurSettings(settings: DemurSettings): Promise<void>;
|
|
61
|
+
runTrainingReviewTui(
|
|
62
|
+
snapshot: TrainingReviewSnapshot,
|
|
63
|
+
settings: DemurSettings,
|
|
64
|
+
reloadSnapshot: () => Promise<TrainingReviewSnapshot>,
|
|
65
|
+
recordReview: (input: TrainingReviewInput) => Promise<TrainingReview>,
|
|
66
|
+
saveSettings: (settings: DemurSettings) => Promise<void>,
|
|
67
|
+
): Promise<TrainingReviewTuiResult>;
|
|
68
|
+
isInteractive(): boolean;
|
|
26
69
|
readSecret(prompt: string): Promise<string>;
|
|
27
70
|
cwd(): string;
|
|
28
71
|
stdout(message: string): void;
|
|
@@ -34,6 +77,32 @@ const defaultDependencies: CliDependencies = {
|
|
|
34
77
|
resolveApiKey,
|
|
35
78
|
storeApiKey,
|
|
36
79
|
deleteApiKey,
|
|
80
|
+
loadTrainingRecords,
|
|
81
|
+
loadTrainingReviews,
|
|
82
|
+
loadGlobalEstimatedCostUsd: async () =>
|
|
83
|
+
(await loadCostTotals()).estimatedCostUsd,
|
|
84
|
+
recordTrainingReview,
|
|
85
|
+
loadDemurSettings,
|
|
86
|
+
saveDemurSettings,
|
|
87
|
+
runTrainingReviewTui: async (
|
|
88
|
+
snapshot,
|
|
89
|
+
settings,
|
|
90
|
+
reloadSnapshot,
|
|
91
|
+
recordReview,
|
|
92
|
+
saveSettings,
|
|
93
|
+
) => {
|
|
94
|
+
const { runTrainingReviewTui } = await import(
|
|
95
|
+
"./training-review-tui.tsx"
|
|
96
|
+
);
|
|
97
|
+
return runTrainingReviewTui(
|
|
98
|
+
snapshot,
|
|
99
|
+
settings,
|
|
100
|
+
reloadSnapshot,
|
|
101
|
+
recordReview,
|
|
102
|
+
saveSettings,
|
|
103
|
+
);
|
|
104
|
+
},
|
|
105
|
+
isInteractive: () => Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
37
106
|
readSecret,
|
|
38
107
|
cwd: () => process.cwd(),
|
|
39
108
|
stdout: (message) => console.log(message),
|
|
@@ -52,10 +121,24 @@ export async function runCli(
|
|
|
52
121
|
dependencies: CliDependencies = defaultDependencies,
|
|
53
122
|
): Promise<number> {
|
|
54
123
|
try {
|
|
124
|
+
if (args.length === 0) {
|
|
125
|
+
if (!dependencies.isInteractive()) {
|
|
126
|
+
dependencies.stderr(
|
|
127
|
+
"demur: the interactive app requires a terminal; use `demur training list --json` or review a record by ID.",
|
|
128
|
+
);
|
|
129
|
+
return 2;
|
|
130
|
+
}
|
|
131
|
+
return await runTraining(["review"], dependencies);
|
|
132
|
+
}
|
|
133
|
+
|
|
55
134
|
if (args[0] === "auth") {
|
|
56
135
|
return await runAuth(args.slice(1), dependencies);
|
|
57
136
|
}
|
|
58
137
|
|
|
138
|
+
if (args[0] === "training") {
|
|
139
|
+
return await runTraining(args.slice(1), dependencies);
|
|
140
|
+
}
|
|
141
|
+
|
|
59
142
|
if (args[0] === "help" || args[0] === "--help" || args[0] === "-h") {
|
|
60
143
|
dependencies.stdout(USAGE);
|
|
61
144
|
return 0;
|
|
@@ -64,7 +147,16 @@ export async function runCli(
|
|
|
64
147
|
const judgeArgs = args[0] === "judge" ? args.slice(1) : args;
|
|
65
148
|
return await runJudge(judgeArgs, dependencies);
|
|
66
149
|
} catch (error: unknown) {
|
|
67
|
-
|
|
150
|
+
if (isTrainingJsonRequest(args)) {
|
|
151
|
+
writeTrainingJsonError(
|
|
152
|
+
trainingOperation(args),
|
|
153
|
+
"unexpected_error",
|
|
154
|
+
errorDetail(error),
|
|
155
|
+
dependencies,
|
|
156
|
+
);
|
|
157
|
+
} else {
|
|
158
|
+
dependencies.stderr(`demur: ${errorDetail(error)}`);
|
|
159
|
+
}
|
|
68
160
|
return 1;
|
|
69
161
|
}
|
|
70
162
|
}
|
|
@@ -136,6 +228,421 @@ async function runAuth(
|
|
|
136
228
|
return 2;
|
|
137
229
|
}
|
|
138
230
|
|
|
231
|
+
type ParsedArguments = {
|
|
232
|
+
positionals: ReadonlyArray<string>;
|
|
233
|
+
options: ReadonlyMap<string, string | true>;
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
type ParseArgumentsResult =
|
|
237
|
+
| { parsed: ParsedArguments; error: undefined }
|
|
238
|
+
| { parsed: undefined; error: string };
|
|
239
|
+
|
|
240
|
+
async function runTraining(
|
|
241
|
+
args: ReadonlyArray<string>,
|
|
242
|
+
dependencies: CliDependencies,
|
|
243
|
+
): Promise<number> {
|
|
244
|
+
const command = args[0];
|
|
245
|
+
if (command === "list") {
|
|
246
|
+
return runTrainingList(args.slice(1), dependencies);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (command === "review") {
|
|
250
|
+
if (args.length === 1) {
|
|
251
|
+
return runInteractiveTrainingReview(dependencies);
|
|
252
|
+
}
|
|
253
|
+
return runTrainingReview(args.slice(1), dependencies);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return writeTrainingUsageError(
|
|
257
|
+
trainingOperation(["training", ...args]),
|
|
258
|
+
args.includes("--json"),
|
|
259
|
+
"expected `training list` or `training review`",
|
|
260
|
+
dependencies,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function runInteractiveTrainingReview(
|
|
265
|
+
dependencies: CliDependencies,
|
|
266
|
+
): Promise<number> {
|
|
267
|
+
if (!dependencies.isInteractive()) {
|
|
268
|
+
dependencies.stderr(
|
|
269
|
+
"demur: `training review` requires a terminal; use `training list --json` and review a record by ID.",
|
|
270
|
+
);
|
|
271
|
+
return 2;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const loadSnapshot = () => loadTrainingReviewSnapshot(dependencies);
|
|
275
|
+
const snapshot = await loadSnapshot();
|
|
276
|
+
const settings = await dependencies.loadDemurSettings();
|
|
277
|
+
const result = await dependencies.runTrainingReviewTui(
|
|
278
|
+
snapshot,
|
|
279
|
+
settings,
|
|
280
|
+
loadSnapshot,
|
|
281
|
+
dependencies.recordTrainingReview,
|
|
282
|
+
dependencies.saveDemurSettings,
|
|
283
|
+
);
|
|
284
|
+
printReviewSummary(result, dependencies);
|
|
285
|
+
return 0;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function runTrainingList(
|
|
289
|
+
args: ReadonlyArray<string>,
|
|
290
|
+
dependencies: CliDependencies,
|
|
291
|
+
): Promise<number> {
|
|
292
|
+
const operation = "training.list";
|
|
293
|
+
const parsedResult = parseArguments(
|
|
294
|
+
args,
|
|
295
|
+
new Set(["json"]),
|
|
296
|
+
new Set(["status", "cwd"]),
|
|
297
|
+
);
|
|
298
|
+
const json = args.includes("--json");
|
|
299
|
+
if (parsedResult.error !== undefined) {
|
|
300
|
+
return writeTrainingUsageError(
|
|
301
|
+
operation,
|
|
302
|
+
json,
|
|
303
|
+
parsedResult.error,
|
|
304
|
+
dependencies,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const parsed = parsedResult.parsed;
|
|
309
|
+
if (parsed.positionals.length !== 0) {
|
|
310
|
+
return writeTrainingUsageError(
|
|
311
|
+
operation,
|
|
312
|
+
hasOption(parsed, "json"),
|
|
313
|
+
"`training list` does not accept positional arguments",
|
|
314
|
+
dependencies,
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const statusValue = getStringOption(parsed, "status") ?? "all";
|
|
319
|
+
if (!isTrainingReviewFilter(statusValue)) {
|
|
320
|
+
return writeTrainingUsageError(
|
|
321
|
+
operation,
|
|
322
|
+
hasOption(parsed, "json"),
|
|
323
|
+
`unsupported status: ${statusValue}`,
|
|
324
|
+
dependencies,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const cwdQuery = getStringOption(parsed, "cwd") ?? "";
|
|
329
|
+
const entries = filterTrainingReviewEntries(
|
|
330
|
+
await loadTrainingEntries(dependencies),
|
|
331
|
+
statusValue,
|
|
332
|
+
cwdQuery,
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
if (hasOption(parsed, "json")) {
|
|
336
|
+
writeTrainingJsonSuccess(
|
|
337
|
+
operation,
|
|
338
|
+
{
|
|
339
|
+
filter: {
|
|
340
|
+
status: statusValue,
|
|
341
|
+
cwd: cwdQuery === "" ? null : cwdQuery,
|
|
342
|
+
},
|
|
343
|
+
records: entries.map(trainingListRecord),
|
|
344
|
+
},
|
|
345
|
+
dependencies,
|
|
346
|
+
);
|
|
347
|
+
return 0;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (entries.length === 0) {
|
|
351
|
+
dependencies.stdout("No matching demur training records.");
|
|
352
|
+
return 0;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
dependencies.stdout(
|
|
356
|
+
`${entries.length} matching training record${entries.length === 1 ? "" : "s"}:`,
|
|
357
|
+
);
|
|
358
|
+
for (const entry of entries) {
|
|
359
|
+
const record = entry.record;
|
|
360
|
+
dependencies.stdout(
|
|
361
|
+
[
|
|
362
|
+
record.id,
|
|
363
|
+
getTrainingReviewFilter(entry),
|
|
364
|
+
`model=${record.verdict.decision}`,
|
|
365
|
+
record.cwd,
|
|
366
|
+
summarizeTrainingCommand(record.command),
|
|
367
|
+
].join("\t"),
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
return 0;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function runTrainingReview(
|
|
374
|
+
args: ReadonlyArray<string>,
|
|
375
|
+
dependencies: CliDependencies,
|
|
376
|
+
): Promise<number> {
|
|
377
|
+
const operation = "training.review";
|
|
378
|
+
const parsedResult = parseArguments(
|
|
379
|
+
args,
|
|
380
|
+
new Set(["json"]),
|
|
381
|
+
new Set(["decision", "note"]),
|
|
382
|
+
);
|
|
383
|
+
const json = args.includes("--json");
|
|
384
|
+
if (parsedResult.error !== undefined) {
|
|
385
|
+
return writeTrainingUsageError(
|
|
386
|
+
operation,
|
|
387
|
+
json,
|
|
388
|
+
parsedResult.error,
|
|
389
|
+
dependencies,
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const parsed = parsedResult.parsed;
|
|
394
|
+
if (parsed.positionals.length !== 1) {
|
|
395
|
+
return writeTrainingUsageError(
|
|
396
|
+
operation,
|
|
397
|
+
hasOption(parsed, "json"),
|
|
398
|
+
"`training review` requires exactly one record ID",
|
|
399
|
+
dependencies,
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const decisionValue = getStringOption(parsed, "decision");
|
|
404
|
+
if (decisionValue === undefined || !isDecision(decisionValue)) {
|
|
405
|
+
return writeTrainingUsageError(
|
|
406
|
+
operation,
|
|
407
|
+
hasOption(parsed, "json"),
|
|
408
|
+
"`--decision` must be allow, ask, or deny",
|
|
409
|
+
dependencies,
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const recordId = parsed.positionals[0];
|
|
414
|
+
const entries = await loadTrainingEntries(dependencies);
|
|
415
|
+
const entry = entries.find((candidate) => candidate.record.id === recordId);
|
|
416
|
+
if (entry === undefined) {
|
|
417
|
+
return writeTrainingDomainError(
|
|
418
|
+
operation,
|
|
419
|
+
hasOption(parsed, "json"),
|
|
420
|
+
"record_not_found",
|
|
421
|
+
`training record not found: ${recordId}`,
|
|
422
|
+
dependencies,
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const previousReview = getLatestTrainingReview(entry) ?? null;
|
|
427
|
+
const review = await dependencies.recordTrainingReview(
|
|
428
|
+
createTrainingReviewInput(
|
|
429
|
+
entry.record,
|
|
430
|
+
decisionValue,
|
|
431
|
+
getStringOption(parsed, "note"),
|
|
432
|
+
),
|
|
433
|
+
);
|
|
434
|
+
const corrected = decisionValue !== entry.record.verdict.decision;
|
|
435
|
+
|
|
436
|
+
if (hasOption(parsed, "json")) {
|
|
437
|
+
writeTrainingJsonSuccess(
|
|
438
|
+
operation,
|
|
439
|
+
{ review, corrected, previousReview },
|
|
440
|
+
dependencies,
|
|
441
|
+
);
|
|
442
|
+
return 0;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
dependencies.stdout(
|
|
446
|
+
`Recorded ${decisionValue.toUpperCase()} review for ${recordId}${corrected ? ` (model: ${entry.record.verdict.decision.toUpperCase()})` : " (accepted model decision)"}.`,
|
|
447
|
+
);
|
|
448
|
+
return 0;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async function loadTrainingReviewSnapshot(
|
|
452
|
+
dependencies: CliDependencies,
|
|
453
|
+
): Promise<TrainingReviewSnapshot> {
|
|
454
|
+
const [records, reviews, globalEstimatedCostUsd] = await Promise.all([
|
|
455
|
+
dependencies.loadTrainingRecords(),
|
|
456
|
+
dependencies.loadTrainingReviews(),
|
|
457
|
+
dependencies.loadGlobalEstimatedCostUsd(),
|
|
458
|
+
]);
|
|
459
|
+
return { records, reviews, globalEstimatedCostUsd };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function loadTrainingEntries(
|
|
463
|
+
dependencies: CliDependencies,
|
|
464
|
+
): Promise<ReadonlyArray<TrainingReviewEntry>> {
|
|
465
|
+
const [records, reviews] = await Promise.all([
|
|
466
|
+
dependencies.loadTrainingRecords(),
|
|
467
|
+
dependencies.loadTrainingReviews(),
|
|
468
|
+
]);
|
|
469
|
+
return buildTrainingReviewEntries({
|
|
470
|
+
records,
|
|
471
|
+
reviews,
|
|
472
|
+
globalEstimatedCostUsd: 0,
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function printReviewSummary(
|
|
477
|
+
result: TrainingReviewTuiResult,
|
|
478
|
+
dependencies: CliDependencies,
|
|
479
|
+
): void {
|
|
480
|
+
const skipped = result.skipped === 0
|
|
481
|
+
? "."
|
|
482
|
+
: `; ${result.skipped} skipped.`;
|
|
483
|
+
dependencies.stdout(
|
|
484
|
+
`Reviewed ${result.reviewed} record${result.reviewed === 1 ? "" : "s"}; ${result.corrected} corrected${skipped}`,
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function parseArguments(
|
|
489
|
+
args: ReadonlyArray<string>,
|
|
490
|
+
booleanOptions: ReadonlySet<string>,
|
|
491
|
+
valueOptions: ReadonlySet<string>,
|
|
492
|
+
): ParseArgumentsResult {
|
|
493
|
+
const positionals: Array<string> = [];
|
|
494
|
+
const options = new Map<string, string | true>();
|
|
495
|
+
|
|
496
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
497
|
+
const argument = args[index];
|
|
498
|
+
if (argument === undefined) continue;
|
|
499
|
+
if (!argument.startsWith("--")) {
|
|
500
|
+
positionals.push(argument);
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const separator = argument.indexOf("=");
|
|
505
|
+
const name = argument.slice(2, separator < 0 ? undefined : separator);
|
|
506
|
+
const inlineValue = separator < 0 ? undefined : argument.slice(separator + 1);
|
|
507
|
+
if (options.has(name)) {
|
|
508
|
+
return { parsed: undefined, error: `duplicate option: --${name}` };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (booleanOptions.has(name)) {
|
|
512
|
+
if (inlineValue !== undefined) {
|
|
513
|
+
return {
|
|
514
|
+
parsed: undefined,
|
|
515
|
+
error: `option --${name} does not accept a value`,
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
options.set(name, true);
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (!valueOptions.has(name)) {
|
|
523
|
+
return { parsed: undefined, error: `unknown option: --${name}` };
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const followingValue = args[index + 1];
|
|
527
|
+
const value = inlineValue ??
|
|
528
|
+
(followingValue !== undefined && !followingValue.startsWith("--")
|
|
529
|
+
? followingValue
|
|
530
|
+
: undefined);
|
|
531
|
+
if (value === undefined) {
|
|
532
|
+
return {
|
|
533
|
+
parsed: undefined,
|
|
534
|
+
error: `option --${name} requires a value`,
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
if (inlineValue === undefined) index += 1;
|
|
538
|
+
options.set(name, value);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
return { parsed: { positionals, options }, error: undefined };
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function getStringOption(
|
|
545
|
+
parsed: ParsedArguments,
|
|
546
|
+
name: string,
|
|
547
|
+
): string | undefined {
|
|
548
|
+
const value = parsed.options.get(name);
|
|
549
|
+
return typeof value === "string" ? value : undefined;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function hasOption(parsed: ParsedArguments, name: string): boolean {
|
|
553
|
+
return parsed.options.has(name);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function isTrainingReviewFilter(value: string): value is TrainingReviewFilter {
|
|
557
|
+
return value === "all" ||
|
|
558
|
+
value === "unreviewed" ||
|
|
559
|
+
value === "allow" ||
|
|
560
|
+
value === "ask" ||
|
|
561
|
+
value === "deny";
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function isDecision(value: string): value is "allow" | "ask" | "deny" {
|
|
565
|
+
return value === "allow" || value === "ask" || value === "deny";
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function trainingListRecord(entry: TrainingReviewEntry): object {
|
|
569
|
+
return {
|
|
570
|
+
status: getTrainingReviewFilter(entry),
|
|
571
|
+
record: entry.record,
|
|
572
|
+
reviews: entry.reviews,
|
|
573
|
+
latestReview: getLatestTrainingReview(entry) ?? null,
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function summarizeTrainingCommand(command: string): string {
|
|
578
|
+
return command.replaceAll(/\s+/g, " ").trim();
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function writeTrainingUsageError(
|
|
582
|
+
operation: string,
|
|
583
|
+
json: boolean,
|
|
584
|
+
message: string,
|
|
585
|
+
dependencies: CliDependencies,
|
|
586
|
+
): number {
|
|
587
|
+
if (json) {
|
|
588
|
+
writeTrainingJsonError(
|
|
589
|
+
operation,
|
|
590
|
+
"invalid_arguments",
|
|
591
|
+
message,
|
|
592
|
+
dependencies,
|
|
593
|
+
);
|
|
594
|
+
} else {
|
|
595
|
+
dependencies.stderr(`demur: ${message}`);
|
|
596
|
+
dependencies.stderr(USAGE);
|
|
597
|
+
}
|
|
598
|
+
return 2;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function writeTrainingDomainError(
|
|
602
|
+
operation: string,
|
|
603
|
+
json: boolean,
|
|
604
|
+
code: string,
|
|
605
|
+
message: string,
|
|
606
|
+
dependencies: CliDependencies,
|
|
607
|
+
): number {
|
|
608
|
+
if (json) {
|
|
609
|
+
writeTrainingJsonError(operation, code, message, dependencies);
|
|
610
|
+
} else {
|
|
611
|
+
dependencies.stderr(`demur: ${message}`);
|
|
612
|
+
}
|
|
613
|
+
return 1;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function writeTrainingJsonSuccess(
|
|
617
|
+
operation: string,
|
|
618
|
+
result: unknown,
|
|
619
|
+
dependencies: CliDependencies,
|
|
620
|
+
): void {
|
|
621
|
+
dependencies.stdout(JSON.stringify({ version: 1, ok: true, operation, result }));
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function writeTrainingJsonError(
|
|
625
|
+
operation: string,
|
|
626
|
+
code: string,
|
|
627
|
+
message: string,
|
|
628
|
+
dependencies: CliDependencies,
|
|
629
|
+
): void {
|
|
630
|
+
dependencies.stdout(JSON.stringify({
|
|
631
|
+
version: 1,
|
|
632
|
+
ok: false,
|
|
633
|
+
operation,
|
|
634
|
+
error: { code, message },
|
|
635
|
+
}));
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function isTrainingJsonRequest(args: ReadonlyArray<string>): boolean {
|
|
639
|
+
return args[0] === "training" && args.includes("--json");
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function trainingOperation(args: ReadonlyArray<string>): string {
|
|
643
|
+
return `training.${args[1] ?? "unknown"}`;
|
|
644
|
+
}
|
|
645
|
+
|
|
139
646
|
async function runJudge(
|
|
140
647
|
args: ReadonlyArray<string>,
|
|
141
648
|
dependencies: CliDependencies,
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEMUR_MODES,
|
|
3
|
+
FAILURE_POLICIES,
|
|
4
|
+
type DemurSettings,
|
|
5
|
+
} from "../extensions/demur/settings.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Settings exposed by both the Pi extension menu and the central TUI.
|
|
9
|
+
*/
|
|
10
|
+
export type DemurSettingKey = "mode" | "training" | "failurePolicy";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Move one persisted demur setting to its next or previous supported value.
|
|
14
|
+
*
|
|
15
|
+
* Selecting disabled mode always turns training capture off. Training cannot be
|
|
16
|
+
* changed while disabled, matching the persisted configuration invariant.
|
|
17
|
+
*
|
|
18
|
+
* @param settings - Current persisted settings
|
|
19
|
+
* @param key - Setting to change
|
|
20
|
+
* @param direction - Positive for the next value, negative for the previous
|
|
21
|
+
* @returns The updated settings, or the original object when no change is valid
|
|
22
|
+
*/
|
|
23
|
+
export function changeDemurSetting(
|
|
24
|
+
settings: DemurSettings,
|
|
25
|
+
key: DemurSettingKey,
|
|
26
|
+
direction: number,
|
|
27
|
+
): DemurSettings {
|
|
28
|
+
if (key === "training") {
|
|
29
|
+
if (settings.mode === "disabled") return settings;
|
|
30
|
+
return { ...settings, training: !settings.training };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (key === "mode") {
|
|
34
|
+
const mode = cycleValue(DEMUR_MODES, settings.mode, direction);
|
|
35
|
+
return {
|
|
36
|
+
...settings,
|
|
37
|
+
mode,
|
|
38
|
+
training: mode === "disabled" ? false : settings.training,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
...settings,
|
|
44
|
+
failurePolicy: cycleValue(
|
|
45
|
+
FAILURE_POLICIES,
|
|
46
|
+
settings.failurePolicy,
|
|
47
|
+
direction,
|
|
48
|
+
),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function cycleValue<Value>(
|
|
53
|
+
values: ReadonlyArray<Value>,
|
|
54
|
+
current: Value,
|
|
55
|
+
direction: number,
|
|
56
|
+
): Value {
|
|
57
|
+
const currentIndex = values.indexOf(current);
|
|
58
|
+
const offset = direction < 0 ? values.length - 1 : 1;
|
|
59
|
+
return values[(currentIndex + offset) % values.length] ?? current;
|
|
60
|
+
}
|