@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/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
- dependencies.stderr(`demur: ${errorDetail(error)}`);
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
+ }