1use anyhow::Context;
2use clap::{Parser, Subcommand, ValueEnum};
3use std::net::{IpAddr, Ipv4Addr, SocketAddr};
4
5#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
6pub enum EmotionType {
7 Joy,
8 Anger,
9 Frustration,
10 Sad,
11 Confused,
12 Neutral,
13}
14
15#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
16pub enum ProviderType {
17 Openai,
18 Anthropic,
19 Opencode,
20}
21
22#[derive(Debug, Parser)]
23#[command(
24 name = "unlost",
25 version,
26 about = "Local-first code memory (record, init, query)",
27 help_template = "\
28unlost {version}
29{about}
30
31{usage-heading} {usage}
32
33Memory:
34 query Semantic search across recorded capsules
35 trace Trace the causal chain of decisions that led to the current state of a file, symbol, or concept
36 recall Recall the story so far (proactive overview)
37 explore Explore future paths grounded in your workspace memory
38 challenge Pressure-test a past decision or technology choice using your workspace memory
39 brief Get a staff engineer's debrief on this codebase — what matters, what bites, where to start
40 pr-comment Post an unlost context comment on a GitHub PR
41 checkpoint Create or list workspace checkpoints (pre-synthesized session stories)
42
43Workspace:
44 init Seed LanceDB from the current codebase (unfault-core graph)
45 reindex Rebuild LanceDB index from capsules.jsonl
46 replay Replay/backfill agent transcripts into unlost
47 clear Delete all generated data for the current workspace
48 where Show where the workspace's files are stored
49
50Setup:
51 config Manage configuration (LLM provider, etc.)
52 model Manage local models (download, etc.)
53
54Diagnostics:
55 metrics Show workspace metrics (local, derived from metrics.jsonl)
56 interventions Show recent friction interventions applied to agents
57 inspect Inspect stored capsules for this workspace
58
59Options:
60{options}
61"
62)]
63pub struct Cli {
64 #[arg(long, global = true, value_enum, alias = "log-level")]
66 pub log: Option<LogLevel>,
67
68 #[command(subcommand)]
69 pub command: Option<Command>,
70}
71
72#[derive(Debug, Clone, Copy, ValueEnum)]
73pub enum LogLevel {
74 Error,
75 Warn,
76 Info,
77 Debug,
78 Trace,
79}
80
81impl LogLevel {
82 pub fn as_tracing_str(self) -> &'static str {
83 match self {
84 LogLevel::Error => "error",
85 LogLevel::Warn => "warn",
86 LogLevel::Info => "info",
87 LogLevel::Debug => "debug",
88 LogLevel::Trace => "trace",
89 }
90 }
91}
92
93#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
94pub enum OutputFormat {
95 Ansi,
97 Plain,
99}
100
101#[derive(Debug, Subcommand)]
102pub enum Command {
103 #[command(hide = true)]
105 Serve {
106 #[arg(long, default_value = "127.0.0.1:3000")]
109 bind: String,
110
111 #[arg(long, default_value = crate::constants::DEFAULT_EMBED_MODEL)]
113 embed_model: String,
114
115 #[arg(long, env = "UNLOST_EMBED_CACHE_DIR")]
117 embed_cache_dir: Option<String>,
118 },
119
120 #[command(alias = "proxy", hide = true)]
122 Record {
123 #[arg(long, default_value = "3000")]
126 bind: String,
127
128 #[arg(long, env = "UNLOST_UPSTREAM_HOST")]
130 upstream_host: String,
131
132 #[arg(long, env = "UNLOST_UPSTREAM_PORT", default_value_t = 443)]
134 upstream_port: u16,
135
136 #[arg(long, default_value = crate::constants::DEFAULT_EMBED_MODEL)]
138 embed_model: String,
139
140 #[arg(long, env = "UNLOST_EMBED_CACHE_DIR")]
142 embed_cache_dir: Option<String>,
143 },
144
145 Query {
147 query: Vec<String>,
149
150 #[arg(long, default_value_t = 5)]
152 limit: usize,
153
154 #[arg(long)]
156 symbol: Option<String>,
157
158 #[arg(long, value_enum)]
160 emotion: Option<EmotionType>,
161
162 #[arg(long, value_enum)]
164 provider: Option<ProviderType>,
165
166 #[arg(long)]
168 since: Option<String>,
169
170 #[arg(long)]
172 until: Option<String>,
173
174 #[arg(long, default_value_t = false)]
176 no_llm: bool,
177
178 #[arg(long)]
180 llm_model: Option<String>,
181
182 #[arg(long, default_value_t = false)]
184 facts: bool,
185
186 #[arg(long, value_enum, default_value_t = OutputFormat::Ansi)]
188 output: OutputFormat,
189
190 #[arg(long, default_value_t = false)]
192 plain: bool,
193
194 #[arg(long, default_value = crate::constants::DEFAULT_EMBED_MODEL)]
196 embed_model: String,
197
198 #[arg(long, env = "UNLOST_EMBED_CACHE_DIR")]
200 embed_cache_dir: Option<String>,
201
202 #[arg(long, default_value = "")]
204 file: String,
205 },
206
207 Trace {
209 target: Vec<String>,
211
212 #[arg(long, default_value_t = 5)]
214 seeds: usize,
215
216 #[arg(long, default_value_t = 8)]
218 fan_out: usize,
219
220 #[arg(long, default_value_t = 0.65)]
222 threshold: f32,
223
224 #[arg(long)]
226 since: Option<String>,
227
228 #[arg(long)]
230 until: Option<String>,
231
232 #[arg(long)]
234 session_id: Option<String>,
235
236 #[arg(long)]
238 from_commit: Option<String>,
239
240 #[arg(long)]
242 to_commit: Option<String>,
243
244 #[arg(long)]
246 llm_model: Option<String>,
247
248 #[arg(long, default_value_t = false)]
250 no_llm: bool,
251
252 #[arg(long, value_enum, default_value_t = OutputFormat::Ansi)]
254 output: OutputFormat,
255
256 #[arg(long, default_value_t = false)]
258 plain: bool,
259
260 #[arg(long, default_value = crate::constants::DEFAULT_EMBED_MODEL)]
262 embed_model: String,
263
264 #[arg(long, env = "UNLOST_EMBED_CACHE_DIR")]
266 embed_cache_dir: Option<String>,
267 },
268
269 PrComment {
272 pr: String,
274
275 #[arg(long)]
277 session_id: Option<String>,
278
279 #[arg(long)]
281 from_commit: Option<String>,
282
283 #[arg(long)]
285 llm_model: Option<String>,
286
287 #[arg(long, default_value = crate::constants::DEFAULT_EMBED_MODEL)]
289 embed_model: String,
290
291 #[arg(long, env = "UNLOST_EMBED_CACHE_DIR")]
293 embed_cache_dir: Option<String>,
294 },
295
296 Brief {
298 target: Vec<String>,
300
301 #[arg(long)]
303 llm_model: Option<String>,
304
305 #[arg(long, value_enum, default_value_t = OutputFormat::Ansi)]
307 output: OutputFormat,
308
309 #[arg(long, default_value_t = false)]
311 plain: bool,
312
313 #[arg(long, default_value = crate::constants::DEFAULT_EMBED_MODEL)]
315 embed_model: String,
316
317 #[arg(long, env = "UNLOST_EMBED_CACHE_DIR")]
319 embed_cache_dir: Option<String>,
320 },
321
322 Recall {
324 target: Vec<String>,
326
327 #[arg(long, default_value_t = 40)]
329 limit: usize,
330
331 #[arg(long, value_enum)]
333 emotion: Option<EmotionType>,
334
335 #[arg(long, value_enum)]
337 provider: Option<ProviderType>,
338
339 #[arg(long)]
341 since: Option<String>,
342
343 #[arg(long)]
345 until: Option<String>,
346
347 #[arg(long)]
349 llm_model: Option<String>,
350
351 #[arg(long, value_enum, default_value_t = OutputFormat::Ansi)]
353 output: OutputFormat,
354
355 #[arg(long, default_value_t = false)]
357 plain: bool,
358
359 #[arg(long, default_value = crate::constants::DEFAULT_EMBED_MODEL)]
361 embed_model: String,
362
363 #[arg(long, env = "UNLOST_EMBED_CACHE_DIR")]
365 embed_cache_dir: Option<String>,
366 },
367
368 Explore {
370 query: Vec<String>,
372
373 #[arg(long)]
375 llm_model: Option<String>,
376
377 #[arg(long, value_enum, default_value_t = OutputFormat::Ansi)]
379 output: OutputFormat,
380
381 #[arg(long, default_value_t = false)]
383 plain: bool,
384
385 #[arg(long, default_value = crate::constants::DEFAULT_EMBED_MODEL)]
387 embed_model: String,
388
389 #[arg(long, env = "UNLOST_EMBED_CACHE_DIR")]
391 embed_cache_dir: Option<String>,
392 },
393
394 Challenge {
396 target: Vec<String>,
398
399 #[arg(long, default_value_t = false)]
401 deep: bool,
402
403 #[arg(long)]
405 llm_model: Option<String>,
406
407 #[arg(long, value_enum, default_value_t = OutputFormat::Ansi)]
409 output: OutputFormat,
410
411 #[arg(long, default_value_t = false)]
413 plain: bool,
414
415 #[arg(long, default_value = crate::constants::DEFAULT_EMBED_MODEL)]
417 embed_model: String,
418
419 #[arg(long, env = "UNLOST_EMBED_CACHE_DIR")]
421 embed_cache_dir: Option<String>,
422 },
423
424 Metrics {
426 #[arg(long, default_value = ".")]
428 path: String,
429 },
430
431 Interventions {
433 #[arg(long, default_value = ".")]
435 path: String,
436
437 #[arg(long, default_value_t = 10)]
439 limit: usize,
440
441 #[arg(long)]
443 since: Option<String>,
444
445 #[arg(long)]
447 until: Option<String>,
448 },
449
450 Replay {
452 #[command(subcommand)]
453 command: ReplayCommand,
454 },
455
456 Inspect {
458 #[arg(long, default_value = ".")]
460 path: String,
461
462 #[arg(long, default_value_t = 20)]
464 limit: usize,
465
466 #[arg(long, value_enum)]
468 emotion: Option<EmotionType>,
469
470 #[arg(long, value_enum)]
472 provider: Option<ProviderType>,
473
474 #[arg(long)]
476 since: Option<String>,
477
478 #[arg(long)]
480 until: Option<String>,
481
482 #[arg(long)]
484 filter: Option<String>,
485 },
486
487 Init {
489 #[arg(long, default_value = ".")]
491 path: String,
492
493 #[arg(long, default_value = crate::constants::DEFAULT_EMBED_MODEL)]
495 embed_model: String,
496
497 #[arg(long, env = "UNLOST_EMBED_CACHE_DIR")]
499 embed_cache_dir: Option<String>,
500
501 #[arg(long, default_value_t = 120)]
503 max_capsules: usize,
504
505 #[arg(long, default_value_t = false)]
507 no_llm: bool,
508
509 #[arg(long, default_value_t = true)]
511 git_history: bool,
512
513 #[arg(long, default_value_t = 50)]
515 git_commits: usize,
516
517 #[arg(long)]
519 git_path: Option<String>,
520
521 #[arg(long)]
523 llm_model: Option<String>,
524
525 #[arg(long, default_value_t = 12)]
527 llm_max_capsules: usize,
528 },
529
530 Model {
532 #[command(subcommand)]
533 command: ModelCommand,
534 },
535
536 #[command(alias = "configure")]
538 Config {
539 #[command(subcommand)]
540 command: ConfigCommand,
541 },
542
543 Clear {
545 #[arg(long, default_value = ".")]
547 path: String,
548
549 #[arg(long, short = 'y')]
551 yes: bool,
552 },
553
554 Reindex {
556 #[arg(long, default_value = ".")]
558 path: String,
559
560 #[arg(long, short = 'y')]
562 yes: bool,
563 },
564
565 #[command(hide = true)]
567 Emotion {
568 text: String,
570 },
571
572 #[command(hide = true)]
574 Shim {
575 #[command(subcommand)]
576 command: ShimCommand,
577 },
578
579 Where {
581 #[arg(long, default_value = ".")]
583 path: String,
584 },
585
586 Checkpoint {
588 #[arg(long, default_value_t = false)]
590 list: bool,
591
592 #[arg(long)]
594 session_id: Option<String>,
595
596 #[arg(long)]
598 since: Option<String>,
599
600 #[arg(long)]
602 llm_model: Option<String>,
603 },
604}
605
606#[derive(Debug, Subcommand)]
607pub enum ShimCommand {
608 Opencode {
610 #[arg(long, default_value = crate::constants::DEFAULT_EMBED_MODEL)]
612 embed_model: String,
613
614 #[arg(long, env = "UNLOST_EMBED_CACHE_DIR")]
616 embed_cache_dir: Option<String>,
617
618 #[arg(long, default_value_t = false)]
620 no_extraction: bool,
621 },
622
623 #[command(alias = "claudecode")]
625 Claude {
626 #[arg(long, default_value = crate::constants::DEFAULT_EMBED_MODEL)]
628 embed_model: String,
629
630 #[arg(long, env = "UNLOST_EMBED_CACHE_DIR")]
632 embed_cache_dir: Option<String>,
633 },
634
635 Replay {
637 #[command(subcommand)]
638 command: ReplayCommand,
639 },
640}
641
642#[derive(Debug, Subcommand)]
643pub enum ReplayCommand {
644 #[command(alias = "claudecode")]
646 Claude {
647 #[arg(long, default_value = ".")]
649 path: String,
650
651 #[arg(long)]
653 transcript_path: String,
654
655 #[arg(long)]
657 session_id: Option<String>,
658
659 #[arg(long, default_value_t = true)]
661 from_start: bool,
662
663 #[arg(long, default_value_t = true)]
665 dedupe: bool,
666
667 #[arg(long, default_value_t = false)]
669 no_extraction: bool,
670
671 #[arg(long, default_value_t = false)]
673 full_extraction: bool,
674
675 #[arg(long, default_value_t = false)]
677 clear: bool,
678
679 #[arg(long, default_value = crate::constants::DEFAULT_EMBED_MODEL)]
681 embed_model: String,
682
683 #[arg(long, env = "UNLOST_EMBED_CACHE_DIR")]
685 embed_cache_dir: Option<String>,
686
687 #[arg(long, default_value_t = false)]
689 git_grounding: bool,
690 },
691
692 Git {
694 #[arg(long, default_value = ".")]
696 path: String,
697
698 #[arg(long, default_value_t = 500)]
700 max_commits: usize,
701
702 #[arg(long, default_value = crate::constants::DEFAULT_EMBED_MODEL)]
704 embed_model: String,
705
706 #[arg(long, env = "UNLOST_EMBED_CACHE_DIR")]
708 embed_cache_dir: Option<String>,
709 },
710
711 Opencode {
713 #[arg(long, default_value = ".")]
715 path: String,
716
717 #[arg(long, default_value_t = true)]
719 dedupe: bool,
720
721 #[arg(long, default_value_t = false)]
723 no_extraction: bool,
724
725 #[arg(long, default_value_t = false)]
727 full_extraction: bool,
728
729 #[arg(long, default_value_t = false)]
731 clear: bool,
732
733 #[arg(long, default_value = crate::constants::DEFAULT_EMBED_MODEL)]
735 embed_model: String,
736
737 #[arg(long, env = "UNLOST_EMBED_CACHE_DIR")]
739 embed_cache_dir: Option<String>,
740
741 #[arg(long, default_value_t = false)]
743 git_grounding: bool,
744 },
745}
746
747#[derive(Debug, Subcommand)]
748pub enum ConfigCommand {
749 Llm {
751 #[command(subcommand)]
752 command: LlmCommand,
753 },
754
755 Agent {
757 #[command(subcommand)]
758 command: AgentCommand,
759 },
760}
761
762#[derive(Debug, Subcommand)]
763pub enum AgentCommand {
764 Opencode {
766 #[arg(long, default_value = ".")]
768 path: String,
769
770 #[arg(long, default_value = "@unfault/unlost-opencode")]
772 plugin: String,
773
774 #[arg(long)]
776 global: bool,
777 },
778
779 #[command(alias = "claudecode")]
781 Claude {
782 #[arg(long, default_value = ".")]
784 path: String,
785
786 #[arg(long)]
788 global: bool,
789 },
790}
791
792#[derive(Debug, Subcommand)]
793pub enum LlmCommand {
794 Openai {
796 #[arg(long, env = "OPENAI_API_KEY")]
798 api_key: String,
799
800 #[arg(long, default_value = "gpt-4o-mini")]
802 model: String,
803
804 #[arg(long)]
806 base_url: Option<String>,
807 },
808
809 Anthropic {
811 #[arg(long, env = "ANTHROPIC_API_KEY")]
813 api_key: String,
814
815 #[arg(long, default_value = "claude-3-5-sonnet-20241022")]
817 model: String,
818
819 #[arg(long)]
821 base_url: Option<String>,
822 },
823
824 Ollama {
826 #[arg(long)]
828 model: String,
829
830 #[arg(long, default_value = "http://127.0.0.1:11434/v1")]
832 base_url: String,
833 },
834
835 Custom {
837 #[arg(long)]
839 base_url: String,
840
841 #[arg(long)]
843 api_key: Option<String>,
844
845 #[arg(long)]
847 model: String,
848 },
849
850 Show,
852
853 Remove,
855}
856
857#[derive(Debug, Subcommand)]
858pub enum ModelCommand {
859 Download {
861 #[arg(long, default_value = crate::constants::DEFAULT_EMBED_MODEL)]
863 embed_model: String,
864
865 #[arg(long, env = "UNLOST_EMBED_CACHE_DIR")]
867 cache_dir: Option<String>,
868
869 #[arg(long, default_value_t = false)]
871 force: bool,
872 },
873}
874
875pub fn parse_bind(s: &str) -> anyhow::Result<SocketAddr> {
876 let s = s.trim();
877 if s.is_empty() {
878 anyhow::bail!("bind cannot be empty");
879 }
880
881 if let Some(port_str) = s.strip_prefix(':') {
883 let port: u16 = port_str.parse().context("invalid port")?;
884 return Ok(SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), port));
885 }
886
887 if s.chars().all(|c| c.is_ascii_digit()) {
889 let port: u16 = s.parse().context("invalid port")?;
890 return Ok(SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), port));
891 }
892
893 s.parse().context("invalid bind address")
895}
896
897#[cfg(test)]
898mod tests {
899 use super::*;
900
901 #[test]
902 fn test_log_level_as_tracing_str() {
903 assert_eq!(LogLevel::Error.as_tracing_str(), "error");
904 assert_eq!(LogLevel::Warn.as_tracing_str(), "warn");
905 assert_eq!(LogLevel::Info.as_tracing_str(), "info");
906 assert_eq!(LogLevel::Debug.as_tracing_str(), "debug");
907 assert_eq!(LogLevel::Trace.as_tracing_str(), "trace");
908 }
909
910 #[test]
911 fn test_parse_bind() {
912 let addr = parse_bind("3000").unwrap();
914 assert_eq!(addr.port(), 3000);
915 assert_eq!(addr.ip(), std::net::IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)));
916
917 let addr = parse_bind(":3000").unwrap();
918 assert_eq!(addr.port(), 3000);
919 assert_eq!(addr.ip(), std::net::IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)));
920
921 let addr = parse_bind("127.0.0.1:3000").unwrap();
923 assert_eq!(addr.port(), 3000);
924 assert_eq!(addr.ip(), std::net::IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
925
926 let addr = parse_bind("0.0.0.0:8080").unwrap();
927 assert_eq!(addr.port(), 8080);
928 assert_eq!(addr.ip(), std::net::IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)));
929
930 let addr = parse_bind("[::1]:3000").unwrap();
932 assert_eq!(addr.port(), 3000);
933
934 assert!(parse_bind("").is_err());
936 assert!(parse_bind(" ").is_err());
937 assert!(parse_bind("invalid").is_err());
938 assert!(parse_bind("127.0.0.1").is_err());
939 assert!(parse_bind("127.0.0.1:invalid").is_err());
940 assert!(parse_bind("99999").is_err()); }
942
943 #[test]
944 fn test_output_format_equality() {
945 assert_eq!(OutputFormat::Ansi, OutputFormat::Ansi);
946 assert_eq!(OutputFormat::Plain, OutputFormat::Plain);
947 assert_ne!(OutputFormat::Ansi, OutputFormat::Plain);
948 }
949}