Skip to main content

unlost/
cli.rs

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    /// Logging level for unlost (overrides RUST_LOG when set)
65    #[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    /// Default terminal-friendly output (ANSI colors)
96    Ansi,
97    /// No ANSI colors (useful for piping)
98    Plain,
99}
100
101#[derive(Debug, Subcommand)]
102pub enum Command {
103    /// Global recorder that multiplexes workspaces via base URL
104    #[command(hide = true)]
105    Serve {
106        /// Bind address. Accepts either `port` or `ip:port`.
107        /// Examples: `3000`, `127.0.0.1:3000`.
108        #[arg(long, default_value = "127.0.0.1:3000")]
109        bind: String,
110
111        /// Embedding model (fastembed). Default: BAAI/bge-small-en-v1.5
112        #[arg(long, default_value = crate::constants::DEFAULT_EMBED_MODEL)]
113        embed_model: String,
114
115        /// Embedding cache directory (defaults to XDG data dir)
116        #[arg(long, env = "UNLOST_EMBED_CACHE_DIR")]
117        embed_cache_dir: Option<String>,
118    },
119
120    /// Record live LLM conversations (captures and summarizes)
121    #[command(alias = "proxy", hide = true)]
122    Record {
123        /// Bind address. Accepts either `port` or `ip:port`.
124        /// Examples: `3000`, `0.0.0.0:3000`.
125        #[arg(long, default_value = "3000")]
126        bind: String,
127
128        /// Upstream host (or set UNLOST_UPSTREAM_HOST)
129        #[arg(long, env = "UNLOST_UPSTREAM_HOST")]
130        upstream_host: String,
131
132        /// Upstream port (or set UNLOST_UPSTREAM_PORT)
133        #[arg(long, env = "UNLOST_UPSTREAM_PORT", default_value_t = 443)]
134        upstream_port: u16,
135
136        /// Embedding model (fastembed). Default: BAAI/bge-small-en-v1.5
137        #[arg(long, default_value = crate::constants::DEFAULT_EMBED_MODEL)]
138        embed_model: String,
139
140        /// Embedding cache directory (defaults to XDG data dir)
141        #[arg(long, env = "UNLOST_EMBED_CACHE_DIR")]
142        embed_cache_dir: Option<String>,
143    },
144
145    /// Semantic search across recorded capsules
146    Query {
147        /// Query text
148        query: Vec<String>,
149
150        /// Max results
151        #[arg(long, default_value_t = 5)]
152        limit: usize,
153
154        /// Filter results to a symbol
155        #[arg(long)]
156        symbol: Option<String>,
157
158        /// Filter by user emotion (joy, anger, frustration, sad, confused, neutral)
159        #[arg(long, value_enum)]
160        emotion: Option<EmotionType>,
161
162        /// Filter by upstream provider (openai, anthropic, opencode)
163        #[arg(long, value_enum)]
164        provider: Option<ProviderType>,
165
166        /// Filter to capsules after this time (RFC3339 or relative: 1h, 1d, 1w, 1m, 1y)
167        #[arg(long)]
168        since: Option<String>,
169
170        /// Filter to capsules before this time (RFC3339 or relative: 1h, 1d, 1w, 1m, 1y)
171        #[arg(long)]
172        until: Option<String>,
173
174        /// Disable LLM narrative (prints raw matches)
175        #[arg(long, default_value_t = false)]
176        no_llm: bool,
177
178        /// LLM model to use for query narrative
179        #[arg(long)]
180        llm_model: Option<String>,
181
182        /// Print raw match facts after the narrative
183        #[arg(long, default_value_t = false)]
184        facts: bool,
185
186        /// Output format
187        #[arg(long, value_enum, default_value_t = OutputFormat::Ansi)]
188        output: OutputFormat,
189
190        /// Shortcut for `--output plain`
191        #[arg(long, default_value_t = false)]
192        plain: bool,
193
194        /// Embedding model (fastembed). Default: BAAI/bge-small-en-v1.5
195        #[arg(long, default_value = crate::constants::DEFAULT_EMBED_MODEL)]
196        embed_model: String,
197
198        /// Embedding cache directory (defaults to XDG data dir)
199        #[arg(long, env = "UNLOST_EMBED_CACHE_DIR")]
200        embed_cache_dir: Option<String>,
201
202        /// Path to capsules JSONL (fallback mode only). Defaults to the workspace's JSONL.
203        #[arg(long, default_value = "")]
204        file: String,
205    },
206
207    /// Trace the causal chain of decisions that led to the current state of a file, symbol, or concept
208    Trace {
209        /// File path, symbol name, or free-text question (e.g. "why is the timeout 30s?")
210        target: Vec<String>,
211
212        /// Max seed capsules from initial semantic search
213        #[arg(long, default_value_t = 5)]
214        seeds: usize,
215
216        /// Max capsules per symbol fan-out
217        #[arg(long, default_value_t = 8)]
218        fan_out: usize,
219
220        /// Similarity distance threshold (0.0–1.0); capsules above this are dropped
221        #[arg(long, default_value_t = 0.65)]
222        threshold: f32,
223
224        /// Filter to capsules after this time (RFC3339 or relative: 1h, 1d, 1w, 1M, 1y)
225        #[arg(long)]
226        since: Option<String>,
227
228        /// Filter to capsules before this time (RFC3339 or relative: 1h, 1d, 1w, 1M, 1y)
229        #[arg(long)]
230        until: Option<String>,
231
232        /// Restrict trace to capsules from a specific agent session ID
233        #[arg(long)]
234        session_id: Option<String>,
235
236        /// Restrict trace to commits reachable from this commit (inclusive lower bound, e.g. main)
237        #[arg(long)]
238        from_commit: Option<String>,
239
240        /// Restrict trace to commits up to and including this commit (e.g. HEAD)
241        #[arg(long)]
242        to_commit: Option<String>,
243
244        /// LLM model to use for trace narrative
245        #[arg(long)]
246        llm_model: Option<String>,
247
248        /// Disable LLM narrative (prints raw chain)
249        #[arg(long, default_value_t = false)]
250        no_llm: bool,
251
252        /// Output format
253        #[arg(long, value_enum, default_value_t = OutputFormat::Ansi)]
254        output: OutputFormat,
255
256        /// Shortcut for `--output plain`
257        #[arg(long, default_value_t = false)]
258        plain: bool,
259
260        /// Embedding model (fastembed). Default: BAAI/bge-small-en-v1.5
261        #[arg(long, default_value = crate::constants::DEFAULT_EMBED_MODEL)]
262        embed_model: String,
263
264        /// Embedding cache directory (defaults to XDG data dir)
265        #[arg(long, env = "UNLOST_EMBED_CACHE_DIR")]
266        embed_cache_dir: Option<String>,
267    },
268
269    /// Post an unlost context comment on a GitHub PR (stealth mode — runs automatically when
270    /// the agent creates a PR, but can also be invoked manually)
271    PrComment {
272        /// GitHub PR URL or number (e.g. https://github.com/owner/repo/pull/42 or 42)
273        pr: String,
274
275        /// Agent session ID to scope the trace to (auto-detected when run from shim)
276        #[arg(long)]
277        session_id: Option<String>,
278
279        /// Base commit for diff scope (e.g. main). Defaults to PR base branch.
280        #[arg(long)]
281        from_commit: Option<String>,
282
283        /// LLM model to use for the PR comment narrative
284        #[arg(long)]
285        llm_model: Option<String>,
286
287        /// Embedding model (fastembed). Default: BAAI/bge-small-en-v1.5
288        #[arg(long, default_value = crate::constants::DEFAULT_EMBED_MODEL)]
289        embed_model: String,
290
291        /// Embedding cache directory (defaults to XDG data dir)
292        #[arg(long, env = "UNLOST_EMBED_CACHE_DIR")]
293        embed_cache_dir: Option<String>,
294    },
295
296    /// Get a staff engineer's debrief on this codebase — what matters, what bites, where to start
297    Brief {
298        /// Optional scope: file path, symbol, or concept to focus the brief on
299        target: Vec<String>,
300
301        /// LLM model to use for the brief
302        #[arg(long)]
303        llm_model: Option<String>,
304
305        /// Output format
306        #[arg(long, value_enum, default_value_t = OutputFormat::Ansi)]
307        output: OutputFormat,
308
309        /// Shortcut for `--output plain`
310        #[arg(long, default_value_t = false)]
311        plain: bool,
312
313        /// Embedding model (fastembed). Default: BAAI/bge-small-en-v1.5
314        #[arg(long, default_value = crate::constants::DEFAULT_EMBED_MODEL)]
315        embed_model: String,
316
317        /// Embedding cache directory (defaults to XDG data dir)
318        #[arg(long, env = "UNLOST_EMBED_CACHE_DIR")]
319        embed_cache_dir: Option<String>,
320    },
321
322    /// Recall the story so far (proactive overview)
323    Recall {
324        /// Optional scope (file path or symbol/function name)
325        target: Vec<String>,
326
327        /// Max capsules to use
328        #[arg(long, default_value_t = 40)]
329        limit: usize,
330
331        /// Filter by user emotion (joy, anger, frustration, sad, confused, neutral)
332        #[arg(long, value_enum)]
333        emotion: Option<EmotionType>,
334
335        /// Filter by upstream provider (openai, anthropic, opencode)
336        #[arg(long, value_enum)]
337        provider: Option<ProviderType>,
338
339        /// Filter to capsules after this time (RFC3339 or relative: 1h, 1d, 1w, 1m, 1y)
340        #[arg(long)]
341        since: Option<String>,
342
343        /// Filter to capsules before this time (RFC3339 or relative: 1h, 1d, 1w, 1m, 1y)
344        #[arg(long)]
345        until: Option<String>,
346
347        /// LLM model to use for recall narrative
348        #[arg(long)]
349        llm_model: Option<String>,
350
351        /// Output format
352        #[arg(long, value_enum, default_value_t = OutputFormat::Ansi)]
353        output: OutputFormat,
354
355        /// Shortcut for `--output plain`
356        #[arg(long, default_value_t = false)]
357        plain: bool,
358
359        /// Embedding model (fastembed). Default: BAAI/bge-small-en-v1.5
360        #[arg(long, default_value = crate::constants::DEFAULT_EMBED_MODEL)]
361        embed_model: String,
362
363        /// Embedding cache directory (defaults to XDG data dir)
364        #[arg(long, env = "UNLOST_EMBED_CACHE_DIR")]
365        embed_cache_dir: Option<String>,
366    },
367
368    /// Explore future paths grounded in your workspace memory
369    Explore {
370        /// Scenario or goal to explore (e.g. "should we keep lancedb or move to sqlite+fts?")
371        query: Vec<String>,
372
373        /// LLM model to use for the exploration narrative
374        #[arg(long)]
375        llm_model: Option<String>,
376
377        /// Output format
378        #[arg(long, value_enum, default_value_t = OutputFormat::Ansi)]
379        output: OutputFormat,
380
381        /// Shortcut for `--output plain`
382        #[arg(long, default_value_t = false)]
383        plain: bool,
384
385        /// Embedding model (fastembed). Default: BAAI/bge-small-en-v1.5
386        #[arg(long, default_value = crate::constants::DEFAULT_EMBED_MODEL)]
387        embed_model: String,
388
389        /// Embedding cache directory (defaults to XDG data dir)
390        #[arg(long, env = "UNLOST_EMBED_CACHE_DIR")]
391        embed_cache_dir: Option<String>,
392    },
393
394    /// Pressure-test a past decision or technology choice using your workspace memory
395    Challenge {
396        /// Decision or technology to challenge (e.g. "lancedb" or "was using fastembed the right call?")
397        target: Vec<String>,
398
399        /// Show full analysis: adds UNKNOWNS and PROBES sections (default: concise)
400        #[arg(long, default_value_t = false)]
401        deep: bool,
402
403        /// LLM model to use for the challenge narrative
404        #[arg(long)]
405        llm_model: Option<String>,
406
407        /// Output format
408        #[arg(long, value_enum, default_value_t = OutputFormat::Ansi)]
409        output: OutputFormat,
410
411        /// Shortcut for `--output plain`
412        #[arg(long, default_value_t = false)]
413        plain: bool,
414
415        /// Embedding model (fastembed). Default: BAAI/bge-small-en-v1.5
416        #[arg(long, default_value = crate::constants::DEFAULT_EMBED_MODEL)]
417        embed_model: String,
418
419        /// Embedding cache directory (defaults to XDG data dir)
420        #[arg(long, env = "UNLOST_EMBED_CACHE_DIR")]
421        embed_cache_dir: Option<String>,
422    },
423
424    /// Show workspace metrics (local, derived from metrics.jsonl)
425    Metrics {
426        /// Workspace path (defaults to current directory)
427        #[arg(long, default_value = ".")]
428        path: String,
429    },
430
431    /// Show recent friction interventions applied to agents
432    Interventions {
433        /// Workspace path (defaults to current directory)
434        #[arg(long, default_value = ".")]
435        path: String,
436
437        /// Max interventions to show
438        #[arg(long, default_value_t = 10)]
439        limit: usize,
440
441        /// Filter to interventions after this time (RFC3339 or relative: 1h, 1d, 1w, 1m, 1y)
442        #[arg(long)]
443        since: Option<String>,
444
445        /// Filter to interventions before this time (RFC3339 or relative: 1h, 1d, 1w, 1m, 1y)
446        #[arg(long)]
447        until: Option<String>,
448    },
449
450    /// Replay/backfill agent transcripts into unlost
451    Replay {
452        #[command(subcommand)]
453        command: ReplayCommand,
454    },
455
456    /// Inspect stored capsules for this workspace
457    Inspect {
458        /// Workspace path (defaults to current directory)
459        #[arg(long, default_value = ".")]
460        path: String,
461
462        /// Max rows to print
463        #[arg(long, default_value_t = 20)]
464        limit: usize,
465
466        /// Filter by user emotion (joy, anger, frustration, sad, confused, neutral)
467        #[arg(long, value_enum)]
468        emotion: Option<EmotionType>,
469
470        /// Filter by upstream provider (openai, anthropic, opencode)
471        #[arg(long, value_enum)]
472        provider: Option<ProviderType>,
473
474        /// Filter to capsules after this time (RFC3339 or relative: 1h, 1d, 1w, 1m, 1y)
475        #[arg(long)]
476        since: Option<String>,
477
478        /// Filter to capsules before this time (RFC3339 or relative: 1h, 1d, 1w, 1m, 1y)
479        #[arg(long)]
480        until: Option<String>,
481
482        /// Optional Lance filter expression (DataFusion SQL)
483        #[arg(long)]
484        filter: Option<String>,
485    },
486
487    /// Seed LanceDB from the current codebase (unfault-core graph)
488    Init {
489        /// Root directory to scan
490        #[arg(long, default_value = ".")]
491        path: String,
492
493        /// Embedding model (fastembed). Default: BAAI/bge-small-en-v1.5
494        #[arg(long, default_value = crate::constants::DEFAULT_EMBED_MODEL)]
495        embed_model: String,
496
497        /// Embedding cache directory (defaults to XDG data dir)
498        #[arg(long, env = "UNLOST_EMBED_CACHE_DIR")]
499        embed_cache_dir: Option<String>,
500
501        /// Max number of capsules to insert
502        #[arg(long, default_value_t = 120)]
503        max_capsules: usize,
504
505        /// Disable LLM summaries for init
506        #[arg(long, default_value_t = false)]
507        no_llm: bool,
508
509        /// Include recent git history (commit subjects + touched files) when available
510        #[arg(long, default_value_t = true)]
511        git_history: bool,
512
513        /// Max commits to consider for git history (bounded)
514        #[arg(long, default_value_t = 50)]
515        git_commits: usize,
516
517        /// Limit git history to a subdirectory (relative to repo root). Defaults to --path.
518        #[arg(long)]
519        git_path: Option<String>,
520
521        /// LLM model to use for init summaries
522        #[arg(long)]
523        llm_model: Option<String>,
524
525        /// Max LLM-generated capsules
526        #[arg(long, default_value_t = 12)]
527        llm_max_capsules: usize,
528    },
529
530    /// Manage local models (download, etc.)
531    Model {
532        #[command(subcommand)]
533        command: ModelCommand,
534    },
535
536    /// Manage configuration (LLM provider, etc.)
537    #[command(alias = "configure")]
538    Config {
539        #[command(subcommand)]
540        command: ConfigCommand,
541    },
542
543    /// Delete all generated data for the current workspace
544    Clear {
545        /// Workspace path (defaults to current directory)
546        #[arg(long, default_value = ".")]
547        path: String,
548
549        /// Skip confirmation prompt
550        #[arg(long, short = 'y')]
551        yes: bool,
552    },
553
554    /// Rebuild LanceDB index from capsules.jsonl
555    Reindex {
556        /// Workspace path (defaults to current directory)
557        #[arg(long, default_value = ".")]
558        path: String,
559
560        /// Skip confirmation prompt
561        #[arg(long, short = 'y')]
562        yes: bool,
563    },
564
565    /// Test emotion detection on a string (developer tool)
566    #[command(hide = true)]
567    Emotion {
568        /// Text to classify
569        text: String,
570    },
571
572    /// Agent integration shims (OpenCode, Claude Code, etc.)
573    #[command(hide = true)]
574    Shim {
575        #[command(subcommand)]
576        command: ShimCommand,
577    },
578
579    /// Show where the workspace's files are stored
580    Where {
581        /// Workspace path (defaults to current directory)
582        #[arg(long, default_value = ".")]
583        path: String,
584    },
585
586    /// Create or list workspace checkpoints (pre-synthesized session story segments)
587    Checkpoint {
588        /// List recent checkpoints instead of creating a new one
589        #[arg(long, default_value_t = false)]
590        list: bool,
591
592        /// Scope checkpoint to a specific agent session ID
593        #[arg(long)]
594        session_id: Option<String>,
595
596        /// Filter list to checkpoints after this time (RFC3339 or relative: 1h, 1d, 1w, 1m, 1y)
597        #[arg(long)]
598        since: Option<String>,
599
600        /// LLM model to use for checkpoint narrative generation
601        #[arg(long)]
602        llm_model: Option<String>,
603    },
604}
605
606#[derive(Debug, Subcommand)]
607pub enum ShimCommand {
608    /// Run the OpenCode stdio shim (JSON-RPC over stdin/stdout)
609    Opencode {
610        /// Embedding model (fastembed). Default: BAAI/bge-small-en-v1.5
611        #[arg(long, default_value = crate::constants::DEFAULT_EMBED_MODEL)]
612        embed_model: String,
613
614        /// Embedding cache directory (defaults to XDG data dir)
615        #[arg(long, env = "UNLOST_EMBED_CACHE_DIR")]
616        embed_cache_dir: Option<String>,
617
618        /// Disable LLM extraction (fast, zero cost)
619        #[arg(long, default_value_t = false)]
620        no_extraction: bool,
621    },
622
623    /// Run the Claude hooks shim (reads hook JSON from stdin)
624    #[command(alias = "claudecode")]
625    Claude {
626        /// Embedding model (fastembed). Default: BAAI/bge-small-en-v1.5
627        #[arg(long, default_value = crate::constants::DEFAULT_EMBED_MODEL)]
628        embed_model: String,
629
630        /// Embedding cache directory (defaults to XDG data dir)
631        #[arg(long, env = "UNLOST_EMBED_CACHE_DIR")]
632        embed_cache_dir: Option<String>,
633    },
634
635    /// Replay/backfill agent transcripts into unlost
636    Replay {
637        #[command(subcommand)]
638        command: ReplayCommand,
639    },
640}
641
642#[derive(Debug, Subcommand)]
643pub enum ReplayCommand {
644    /// Replay a Claude transcript file into the current workspace
645    #[command(alias = "claudecode")]
646    Claude {
647        /// Workspace path (defaults to current directory)
648        #[arg(long, default_value = ".")]
649        path: String,
650
651        /// Claude transcript .jsonl file or directory path
652        #[arg(long)]
653        transcript_path: String,
654
655        /// Claude session id (defaults to transcript filename stem)
656        #[arg(long)]
657        session_id: Option<String>,
658
659        /// Force replay from beginning and overwrite cursor to EOF
660        #[arg(long, default_value_t = true)]
661        from_start: bool,
662
663        /// Skip turns already replayed (best-effort)
664        #[arg(long, default_value_t = true)]
665        dedupe: bool,
666
667        /// Disable LLM extraction (fast, zero cost)
668        #[arg(long, default_value_t = false)]
669        no_extraction: bool,
670
671        /// Enable full LLM extraction for every turn (slow, expensive)
672        #[arg(long, default_value_t = false)]
673        full_extraction: bool,
674
675        /// Clear existing database and replayed-tracking for this workspace before starting
676        #[arg(long, default_value_t = false)]
677        clear: bool,
678
679        /// Embedding model (fastembed). Default: BAAI/bge-small-en-v1.5
680        #[arg(long, default_value = crate::constants::DEFAULT_EMBED_MODEL)]
681        embed_model: String,
682
683        /// Embedding cache directory (defaults to XDG data dir)
684        #[arg(long, env = "UNLOST_EMBED_CACHE_DIR")]
685        embed_cache_dir: Option<String>,
686
687        /// Ground replayed turns with actual git logs (find corresponding commits)
688        #[arg(long, default_value_t = false)]
689        git_grounding: bool,
690    },
691
692    /// Ingest git commit history as capsules into the current workspace
693    Git {
694        /// Workspace path (defaults to current directory)
695        #[arg(long, default_value = ".")]
696        path: String,
697
698        /// Max commits to ingest (most recent first, deduplicates on re-run)
699        #[arg(long, default_value_t = 500)]
700        max_commits: usize,
701
702        /// Embedding model (fastembed). Default: BAAI/bge-small-en-v1.5
703        #[arg(long, default_value = crate::constants::DEFAULT_EMBED_MODEL)]
704        embed_model: String,
705
706        /// Embedding cache directory (defaults to XDG data dir)
707        #[arg(long, env = "UNLOST_EMBED_CACHE_DIR")]
708        embed_cache_dir: Option<String>,
709    },
710
711    /// Replay OpenCode messages from disk storage into the current workspace
712    Opencode {
713        /// Workspace path (defaults to current directory)
714        #[arg(long, default_value = ".")]
715        path: String,
716
717        /// Skip messages already replayed (best-effort)
718        #[arg(long, default_value_t = true)]
719        dedupe: bool,
720
721        /// Disable LLM extraction (fast, zero cost)
722        #[arg(long, default_value_t = false)]
723        no_extraction: bool,
724
725        /// Enable full LLM extraction for every turn (slow, expensive)
726        #[arg(long, default_value_t = false)]
727        full_extraction: bool,
728
729        /// Clear existing database and replayed-tracking for this workspace before starting
730        #[arg(long, default_value_t = false)]
731        clear: bool,
732
733        /// Embedding model (fastembed). Default: BAAI/bge-small-en-v1.5
734        #[arg(long, default_value = crate::constants::DEFAULT_EMBED_MODEL)]
735        embed_model: String,
736
737        /// Embedding cache directory (defaults to XDG data dir)
738        #[arg(long, env = "UNLOST_EMBED_CACHE_DIR")]
739        embed_cache_dir: Option<String>,
740
741        /// Ground replayed turns with actual git logs (find corresponding commits)
742        #[arg(long, default_value_t = false)]
743        git_grounding: bool,
744    },
745}
746
747#[derive(Debug, Subcommand)]
748pub enum ConfigCommand {
749    /// Manage LLM configuration for init/query narratives
750    Llm {
751        #[command(subcommand)]
752        command: LlmCommand,
753    },
754
755    /// Configure an agent workspace to talk to unlost
756    Agent {
757        #[command(subcommand)]
758        command: AgentCommand,
759    },
760}
761
762#[derive(Debug, Subcommand)]
763pub enum AgentCommand {
764    /// Configure OpenCode to load the unlost plugin (stdio shim)
765    Opencode {
766        /// Workspace path (defaults to current directory; uses git toplevel)
767        #[arg(long, default_value = ".")]
768        path: String,
769
770        /// npm package name to add
771        #[arg(long, default_value = "@unfault/unlost-opencode")]
772        plugin: String,
773
774        /// Install globally in ~/.config/opencode/opencode.json instead of per-project
775        #[arg(long)]
776        global: bool,
777    },
778
779    /// Configure Claude hooks to use unlost
780    #[command(alias = "claudecode")]
781    Claude {
782        /// Workspace path (defaults to current directory; uses git toplevel)
783        #[arg(long, default_value = ".")]
784        path: String,
785
786        /// Install globally in ~/.claude/settings.json instead of per-project
787        #[arg(long)]
788        global: bool,
789    },
790}
791
792#[derive(Debug, Subcommand)]
793pub enum LlmCommand {
794    /// Configure OpenAI as LLM provider
795    Openai {
796        /// OpenAI API key
797        #[arg(long, env = "OPENAI_API_KEY")]
798        api_key: String,
799
800        /// Default model to use
801        #[arg(long, default_value = "gpt-4o-mini")]
802        model: String,
803
804        /// Optional base URL override (OpenAI-compatible)
805        #[arg(long)]
806        base_url: Option<String>,
807    },
808
809    /// Configure Anthropic as LLM provider
810    Anthropic {
811        /// Anthropic API key
812        #[arg(long, env = "ANTHROPIC_API_KEY")]
813        api_key: String,
814
815        /// Default model to use
816        #[arg(long, default_value = "claude-3-5-sonnet-20241022")]
817        model: String,
818
819        /// Optional base URL override
820        #[arg(long)]
821        base_url: Option<String>,
822    },
823
824    /// Configure local Ollama as LLM provider (OpenAI-compatible endpoint)
825    Ollama {
826        /// Ollama model name (e.g. llama3.2:3b)
827        #[arg(long)]
828        model: String,
829
830        /// OpenAI-compatible base URL (default: http://127.0.0.1:11434/v1)
831        #[arg(long, default_value = "http://127.0.0.1:11434/v1")]
832        base_url: String,
833    },
834
835    /// Configure a custom OpenAI-compatible endpoint
836    Custom {
837        /// Base URL (e.g. https://my-endpoint/v1)
838        #[arg(long)]
839        base_url: String,
840
841        /// API key (if required)
842        #[arg(long)]
843        api_key: Option<String>,
844
845        /// Default model to use
846        #[arg(long)]
847        model: String,
848    },
849
850    /// Show current LLM configuration
851    Show,
852
853    /// Remove LLM configuration
854    Remove,
855}
856
857#[derive(Debug, Subcommand)]
858pub enum ModelCommand {
859    /// Download embedding model files into the local cache
860    Download {
861        /// Embedding model (fastembed)
862        #[arg(long, default_value = crate::constants::DEFAULT_EMBED_MODEL)]
863        embed_model: String,
864
865        /// Cache directory (defaults to XDG data dir)
866        #[arg(long, env = "UNLOST_EMBED_CACHE_DIR")]
867        cache_dir: Option<String>,
868
869        /// Delete cache dir before downloading
870        #[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    // `:3000`
882    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    // `3000`
888    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    // `ip:port`
894    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        // Test port-only formats
913        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        // Test IP:port format
922        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        // Test IPv6
931        let addr = parse_bind("[::1]:3000").unwrap();
932        assert_eq!(addr.port(), 3000);
933
934        // Test error cases
935        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()); // Port out of range
941    }
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}