1use crate::{ArtifactType, Disposition, Impact};
2use clap::{Args, Parser, Subcommand, ValueEnum};
3use std::path::PathBuf;
4
5#[derive(Debug, Parser)]
6#[command(
7 name = "blotter",
8 version,
9 about,
10 long_about = None,
11 arg_required_else_help = true,
12 subcommand_required = true,
13 rename_all = "kebab-case"
14)]
15pub struct Cli {
16 #[arg(
17 long,
18 global = true,
19 value_name = "PATH",
20 help = "Override log-file discovery for this invocation"
21 )]
22 pub file: Option<PathBuf>,
23
24 #[arg(
25 long,
26 global = true,
27 help = "Indent the JSON envelope for human reading"
28 )]
29 pub pretty: bool,
30
31 #[command(subcommand)]
32 pub command: Command,
33}
34
35const ADD_AFTER_HELP: &str = "\
36Admission: file a cut only when at least one of these holds.
37 transferable another agent or user would plausibly hit the same thing
38 consequential cost real time, produced wrong work, forced retries, or stopped the task
39 recurring the same underlying friction has happened before
40 misleading the error pointed at the wrong cause, hid it, or blamed the wrong file
41 systemic a missing affordance, a doc gap, a brittle interface, a reusable footgun
42Skip one-off execution slips unless they recur: typos, shell quoting, a bad first guess,
43a patch that missed on stale context, a linter correctly rejecting code you just wrote,
44a malformed fixture you authored. Impact records consequence, not admission.";
45
46#[derive(Debug, Subcommand)]
47pub enum Command {
48 #[command(alias = "log", after_help = ADD_AFTER_HELP)]
49 Add(AddArgs),
50 #[command(alias = "idea")]
51 Dogear(DogearArgs),
52 Promote(PromoteArgs),
53 List(ListArgs),
54 Export(ExportArgs),
55 Triage(TriageArgs),
56 Verify(VerifyArgs),
57 Retrospect(RetrospectArgs),
58 Digest(DigestArgs),
59 Sweep(SweepArgs),
60 Resolve(ResolveArgs),
61 Archive(ArchiveArgs),
62 Schema {
63 #[arg(
64 value_enum,
65 default_value_t = SchemaTarget::All,
66 help = "Contract section to emit"
67 )]
68 target: SchemaTarget,
69 },
70 Doctor(DoctorArgs),
71}
72
73#[derive(Debug, Args)]
74pub struct AddArgs {
75 #[arg(
76 value_name = "TEXT",
77 help = "Cut text; omit or use - to read from stdin"
78 )]
79 pub text: Option<String>,
80 #[arg(long, help = "Agent name; overrides BLOTTER_AGENT")]
81 pub agent: Option<String>,
82 #[arg(long = "tag", help = "Tag the cut; repeatable")]
83 pub tags: Vec<String>,
84 #[arg(
85 long,
86 value_enum,
87 default_value_t = Impact::Low,
88 help = "Consequence, not admission. blocking: could not proceed; material: lost real time or produced wrong work; low: limited cost, still worth filing"
89 )]
90 pub impact: Impact,
91 #[arg(
92 long,
93 allow_hyphen_values = true,
94 value_name = "TEXT",
95 help = "Command that failed"
96 )]
97 pub cmd: Option<String>,
98 #[arg(long = "exit", value_name = "N", help = "Command exit status")]
99 pub exit_code: Option<i32>,
100 #[arg(
101 long,
102 value_name = "PATH",
103 help = "Read regular UTF-8 PATH (<=1 MiB); best-effort redaction; store sanitized value <=4096 bytes"
104 )]
105 pub stderr_file: Option<PathBuf>,
106 #[arg(
107 long,
108 allow_hyphen_values = true,
109 value_name = "TEXT",
110 help = "Additional evidence or filing note"
111 )]
112 pub evidence: Option<String>,
113 #[arg(long, help = "Validate without appending")]
114 pub dry_run: bool,
115}
116
117#[derive(Debug, Args)]
118pub struct DogearArgs {
119 #[arg(
120 value_name = "TEXT",
121 help = "Dogear text; omit or use - to read from stdin"
122 )]
123 pub text: Option<String>,
124 #[arg(long, help = "Agent name; overrides BLOTTER_AGENT")]
125 pub agent: Option<String>,
126 #[arg(long = "tag", help = "Tag the dogear; repeatable")]
127 pub tags: Vec<String>,
128 #[arg(
129 long,
130 allow_hyphen_values = true,
131 value_name = "TEXT",
132 help = "Optional research note; leading hyphens accepted"
133 )]
134 pub evidence: Option<String>,
135 #[arg(long, help = "Validate without appending")]
136 pub dry_run: bool,
137}
138
139#[derive(Debug, Args)]
140pub struct PromoteArgs {
141 #[arg(
142 long = "source",
143 value_name = "ID",
144 required = true,
145 help = "Cut ID or unique prefix this artifact came from; repeatable"
146 )]
147 pub sources: Vec<String>,
148 #[arg(
149 long = "artifact-type",
150 value_enum,
151 required = true,
152 help = "What the experiences became"
153 )]
154 pub artifact_type: ArtifactType,
155 #[arg(
156 long = "artifact-ref",
157 value_name = "REF",
158 required = true,
159 allow_hyphen_values = true,
160 help = "Where the artifact lives; best-effort redaction"
161 )]
162 pub artifact_ref: String,
163 #[arg(
164 long,
165 allow_hyphen_values = true,
166 value_name = "TEXT",
167 help = "Optional commentary; best-effort redaction; outside the ID hash"
168 )]
169 pub note: Option<String>,
170 #[arg(long, help = "Agent name; overrides BLOTTER_AGENT")]
171 pub agent: Option<String>,
172 #[arg(long, help = "Validate without appending")]
173 pub dry_run: bool,
174}
175
176#[derive(Debug, Args)]
177pub struct ListArgs {
178 #[arg(
179 long,
180 value_enum,
181 default_value_t = ListKind::Cut,
182 help = "Record kind to list"
183 )]
184 pub kind: ListKind,
185 #[arg(
189 long,
190 value_enum,
191 help = "Filter by lifecycle status; default open, which does not exclude promotions"
192 )]
193 pub status: Option<StatusFilter>,
194 #[arg(long, help = "Filter by agent")]
195 pub agent: Option<String>,
196 #[arg(long, help = "Filter by tag")]
197 pub tag: Option<String>,
198 #[arg(long, value_enum, help = "Filter cuts by impact")]
199 pub impact: Option<Impact>,
200 #[arg(long, help = "Filter since an RFC3339 timestamp or Nd/Nh duration")]
201 pub since: Option<String>,
202 #[arg(long, default_value_t = 50, help = "Maximum records to return")]
203 pub limit: usize,
204 #[arg(
205 long,
206 value_enum,
207 default_value_t = OutputFormat::Json,
208 help = "Output format"
209 )]
210 pub format: OutputFormat,
211}
212
213#[derive(Debug, Args)]
214pub struct ExportArgs {
215 #[arg(long, value_enum, help = "Output bridge format; required: otlp-json")]
216 pub format: Option<ExportFormat>,
217 #[arg(long, help = "Filter since an RFC3339 timestamp or Nd/Nh duration")]
218 pub since: Option<String>,
219}
220
221#[derive(Debug, Args)]
222pub struct TriageArgs {
223 #[arg(
224 long,
225 default_value_t = 3,
226 value_name = "N",
227 help = "Minimum similar open cuts per cluster"
228 )]
229 pub min_count: usize,
230}
231
232#[derive(Debug, Args)]
233pub struct VerifyArgs {}
234
235#[derive(Debug, Args)]
236pub struct RetrospectArgs {}
237
238#[derive(Debug, Args)]
239pub struct DigestArgs {
240 #[arg(
241 long,
242 default_value = "7d",
243 help = "Report since an RFC3339 timestamp or Nd/Nh duration"
244 )]
245 pub since: String,
246 #[arg(
247 long,
248 value_enum,
249 default_value_t = OutputFormat::Json,
250 help = "Output format"
251 )]
252 pub format: OutputFormat,
253}
254
255#[derive(Debug, Args)]
256pub struct ArchiveArgs {
257 #[arg(
258 long,
259 required = true,
260 value_name = "VALUE",
261 help = "Archive closed groups before an RFC3339 timestamp or Nd/Nh duration"
262 )]
263 pub before: String,
264 #[arg(long, help = "Plan archive retention without writing")]
265 pub dry_run: bool,
266}
267
268#[derive(Debug, Args)]
269pub struct DoctorArgs {
270 #[arg(long, help = "Repair safe doctor findings")]
271 pub fix: bool,
272 #[arg(long, requires = "fix", help = "Plan doctor repairs without writing")]
273 pub dry_run: bool,
274 #[arg(
275 long,
276 conflicts_with = "fix",
277 help = "Scan physical lines for home-path leaks (decoded on a parsing line, raw otherwise)"
278 )]
279 pub leaks: bool,
280 #[arg(
281 long,
282 value_name = "LITERAL",
283 requires = "leaks",
284 help = "Flag a literal raw-line leak; repeatable; requires --leaks"
285 )]
286 pub deny: Vec<String>,
287}
288
289#[derive(Debug, Args)]
290pub struct SweepArgs {
291 #[arg(
292 value_name = "PATH",
293 help = "Repository directory or direct JSONL log file; repeatable"
294 )]
295 pub paths: Vec<PathBuf>,
296 #[arg(
297 long,
298 value_name = "FILE",
299 help = "User-owned file with one path per line; blank lines and # comments ignored"
300 )]
301 pub registry: Option<PathBuf>,
302 #[arg(long, help = "Filter since an RFC3339 timestamp or Nd/Nh duration")]
303 pub since: Option<String>,
304 #[arg(
305 long,
306 value_enum,
307 default_value_t = SweepKind::Cut,
308 help = "Record kind to include in items"
309 )]
310 pub kind: SweepKind,
311}
312
313#[derive(Debug, Args)]
314pub struct ResolveArgs {
315 #[arg(
316 value_name = "ID",
317 num_args = 1..,
318 required = true,
319 help = "One or more IDs or unique prefixes"
320 )]
321 pub ids: Vec<String>,
322 #[arg(
323 long,
324 allow_hyphen_values = true,
325 help = "Resolution note; leading hyphens accepted"
326 )]
327 pub note: Option<String>,
328 #[arg(long, help = "Resolving agent; overrides BLOTTER_AGENT")]
329 pub agent: Option<String>,
330 #[arg(long, value_name = "ID", help = "Graduation task ID")]
331 pub task: Option<String>,
332 #[arg(long, value_name = "URL", help = "Graduation pull request URL")]
333 pub pr: Option<String>,
334 #[arg(long, value_name = "SHA", help = "Graduation commit SHA")]
335 pub commit: Option<String>,
336 #[arg(
337 long,
338 value_name = "URL",
339 conflicts_with = "dropped",
340 help = "Published destination (dogear records only)"
341 )]
342 pub url: Option<String>,
343 #[arg(long, help = "Mark dropped (dogear records only)")]
344 pub dropped: bool,
345 #[arg(
346 long,
347 value_enum,
348 help = "How the cut was disposed of; required for cuts, rejected for dogears"
349 )]
350 pub disposition: Option<Disposition>,
351 #[arg(
352 long,
353 value_name = "ID",
354 help = "Link to an existing promotion; requires --disposition promoted"
355 )]
356 pub promotion: Option<String>,
357 #[arg(long, help = "Append a correction to an existing resolved record")]
358 pub amend: bool,
359 #[arg(long, help = "Validate without appending a resolution")]
360 pub dry_run: bool,
361}
362
363#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
364pub enum StatusFilter {
365 Open,
366 Resolved,
367 All,
368}
369
370#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
371pub enum ListKind {
372 Cut,
373 Dogear,
374 Promotion,
375 All,
376}
377
378#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
382pub enum SweepKind {
383 Cut,
384 Dogear,
385 All,
386}
387
388#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
389pub enum OutputFormat {
390 Json,
391 Md,
392}
393
394#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
395pub enum ExportFormat {
396 OtlpJson,
397}
398
399#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
400pub enum SchemaTarget {
401 All,
402 Record,
403 Error,
404 ExitCodes,
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410 use clap::CommandFactory;
411
412 fn assert_all_arguments_have_help(command: &clap::Command) {
413 for argument in command.get_arguments() {
414 assert!(
415 argument.get_help().is_some() || argument.get_long_help().is_some(),
416 "{} argument {:?} is missing help text",
417 command.get_name(),
418 argument.get_id()
419 );
420 }
421 for subcommand in command.get_subcommands() {
422 assert_all_arguments_have_help(subcommand);
423 }
424 }
425
426 #[test]
427 fn parser_covers_defaults_aliases_and_globals() {
428 let cli =
429 Cli::try_parse_from(["blotter", "--file", "x", "log", "ouch", "--pretty"]).unwrap();
430 assert!(cli.pretty);
431 assert_eq!(cli.file, Some(PathBuf::from("x")));
432 let Command::Add(args) = cli.command else {
433 panic!("expected add")
434 };
435 assert_eq!(args.text.as_deref(), Some("ouch"));
436 assert_eq!(args.impact, Impact::Low);
437
438 let cli = Cli::try_parse_from(["blotter", "list"]).unwrap();
439 let Command::List(args) = cli.command else {
440 panic!("expected list")
441 };
442 assert_eq!(args.kind, ListKind::Cut);
443 assert_eq!(args.status, None);
444 assert_eq!(args.limit, 50);
445 assert_eq!(args.format, OutputFormat::Json);
446
447 let cli = Cli::try_parse_from(["blotter", "export", "--format", "otlp-json"]).unwrap();
448 let Command::Export(args) = cli.command else {
449 panic!("expected export")
450 };
451 assert_eq!(args.format, Some(ExportFormat::OtlpJson));
452
453 let cli = Cli::try_parse_from(["blotter", "triage"]).unwrap();
454 let Command::Triage(args) = cli.command else {
455 panic!("expected triage")
456 };
457 assert_eq!(args.min_count, 3);
458
459 let cli = Cli::try_parse_from(["blotter", "verify"]).unwrap();
460 assert!(matches!(cli.command, Command::Verify(_)));
461
462 let cli = Cli::try_parse_from(["blotter", "retrospect"]).unwrap();
463 assert!(matches!(cli.command, Command::Retrospect(_)));
464
465 let cli = Cli::try_parse_from(["blotter", "digest"]).unwrap();
466 let Command::Digest(args) = cli.command else {
467 panic!("expected digest")
468 };
469 assert_eq!(args.since, "7d");
470 assert_eq!(args.format, OutputFormat::Json);
471
472 let cli = Cli::try_parse_from([
473 "blotter",
474 "sweep",
475 "repo",
476 "--registry",
477 "repos.txt",
478 "--since",
479 "1d",
480 "--kind",
481 "all",
482 ])
483 .unwrap();
484 let Command::Sweep(args) = cli.command else {
485 panic!("expected sweep")
486 };
487 assert_eq!(args.paths, [PathBuf::from("repo")]);
488 assert_eq!(args.registry, Some(PathBuf::from("repos.txt")));
489 assert_eq!(args.since.as_deref(), Some("1d"));
490 assert_eq!(args.kind, SweepKind::All);
491 }
492
493 #[test]
494 fn parser_rejects_bad_values_and_missing_required_id() {
495 assert!(Cli::try_parse_from(["blotter", "list", "--format", "jsonl"]).is_err());
496 assert!(Cli::try_parse_from(["blotter", "digest", "--format", "jsonl"]).is_err());
497 assert!(Cli::try_parse_from(["blotter", "sweep", "--kind", "other"]).is_err());
498 assert!(Cli::try_parse_from(["blotter", "sweep", "repo", "--kind", "promotion"]).is_err());
499 assert!(
500 Cli::try_parse_from([
501 "blotter",
502 "promote",
503 "--source",
504 "abcd",
505 "--artifact-type",
506 "poem",
507 "--artifact-ref",
508 "x"
509 ])
510 .is_err()
511 );
512 assert!(Cli::try_parse_from(["blotter", "promote", "--artifact-type", "doc"]).is_err());
513 assert!(Cli::try_parse_from(["blotter", "add", "x", "--impact", "critical"]).is_err());
514 assert!(Cli::try_parse_from(["blotter", "add", "x", "--severity", "minor"]).is_err());
515 assert!(Cli::try_parse_from(["blotter", "resolve"]).is_err());
516 assert!(Cli::try_parse_from(["blotter"]).is_err());
517 for args in [
518 vec!["blotter", "list", "--include-auto"],
519 vec![
520 "blotter",
521 "export",
522 "--format",
523 "otlp-json",
524 "--include-auto",
525 ],
526 vec!["blotter", "triage", "--include-auto"],
527 vec!["blotter", "verify", "--include-auto"],
528 vec!["blotter", "digest", "--include-auto"],
529 vec!["blotter", "sweep", "repo", "--include-auto"],
530 vec!["blotter", "hook", "exec", "claude-code"],
531 vec!["blotter", "hook", "install", "claude-code"],
532 ] {
533 assert!(Cli::try_parse_from(args).is_err());
534 }
535 }
536
537 #[test]
538 fn parser_accepts_every_command_and_stdin_marker() {
539 for args in [
540 vec!["blotter", "add", "-"],
541 vec!["blotter", "idea", "-"],
542 vec!["blotter", "list", "--status", "all"],
543 vec!["blotter", "list", "--kind", "dogear"],
544 vec!["blotter", "export", "--format", "otlp-json"],
545 vec!["blotter", "triage", "--min-count", "2"],
546 vec!["blotter", "verify"],
547 vec!["blotter", "digest"],
548 vec!["blotter", "sweep", "repo"],
549 vec!["blotter", "resolve", "abcd"],
550 vec!["blotter", "list", "--kind", "promotion"],
551 vec![
552 "blotter",
553 "promote",
554 "--source",
555 "abcd",
556 "--artifact-type",
557 "skill",
558 "--artifact-ref",
559 "skills/x.md",
560 ],
561 vec!["blotter", "archive", "--before", "1d"],
562 vec!["blotter", "schema", "record"],
563 vec!["blotter", "doctor"],
564 ] {
565 assert!(Cli::try_parse_from(args).is_ok());
566 }
567 }
568
569 #[test]
570 fn parser_accepts_leading_hyphen_text_values_without_swallowing_following_options() {
571 let cli = Cli::try_parse_from([
572 "blotter",
573 "add",
574 "text",
575 "--cmd",
576 "-tool arg",
577 "--evidence",
578 "--detail note",
579 "--agent",
580 "tester",
581 ])
582 .unwrap();
583 let Command::Add(args) = cli.command else {
584 panic!("expected add")
585 };
586 assert_eq!(args.cmd.as_deref(), Some("-tool arg"));
587 assert_eq!(args.evidence.as_deref(), Some("--detail note"));
588 assert_eq!(args.agent.as_deref(), Some("tester"));
589
590 let cli = Cli::try_parse_from([
591 "blotter",
592 "resolve",
593 "abcd1234",
594 "--note",
595 "--retry after timeout",
596 "--agent",
597 "fixer",
598 ])
599 .unwrap();
600 let Command::Resolve(args) = cli.command else {
601 panic!("expected resolve")
602 };
603 assert_eq!(args.note.as_deref(), Some("--retry after timeout"));
604 assert_eq!(args.agent.as_deref(), Some("fixer"));
605 }
606
607 #[test]
608 fn every_argument_has_help_text() {
609 let mut command = Cli::command();
610 command.build();
611 assert_all_arguments_have_help(&command);
612 }
613}