rusty_beads/cli/
mod.rs

1//! CLI commands for Beads.
2
3use std::path::PathBuf;
4use std::sync::Arc;
5
6use anyhow::{Context, Result};
7use clap::{Parser, Subcommand};
8
9use crate::daemon::{Daemon, DaemonClient, DaemonConfig};
10use crate::git::{find_beads_dir, find_database_path, init_beads_dir};
11use crate::storage::{SqliteStorage, Storage};
12use crate::types::{IssueFilter, Status};
13use crate::compact::Compactor;
14use crate::context::{ContextStore, ContextEntry, Namespace, FileContext, ProjectContext};
15use crate::{Issue, Dependency, generate_id};
16
17/// Beads - Git-backed graph issue tracker for AI coding agents.
18#[derive(Parser)]
19#[command(name = "bd", version, about, long_about = None)]
20pub struct Cli {
21    #[command(subcommand)]
22    pub command: Command,
23
24    /// Skip daemon and use direct database access.
25    #[arg(long, global = true)]
26    pub no_daemon: bool,
27
28    /// Override the actor name.
29    #[arg(long, global = true)]
30    pub actor: Option<String>,
31
32    /// Output in JSON format.
33    #[arg(long, global = true)]
34    pub json: bool,
35}
36
37#[derive(Subcommand)]
38pub enum Command {
39    /// Initialize a new .beads directory.
40    Init {
41        /// Path to initialize (defaults to current directory).
42        #[arg(short, long)]
43        path: Option<PathBuf>,
44    },
45
46    /// Create a new issue.
47    Create {
48        /// Issue title.
49        #[arg(short, long)]
50        title: String,
51
52        /// Issue description.
53        #[arg(short, long)]
54        description: Option<String>,
55
56        /// Issue type (bug, feature, task, epic, chore).
57        #[arg(short = 'T', long, default_value = "task")]
58        issue_type: String,
59
60        /// Priority (0-4, lower is higher).
61        #[arg(short, long, default_value = "2")]
62        priority: i32,
63
64        /// Assignee.
65        #[arg(short, long)]
66        assignee: Option<String>,
67
68        /// Parent issue ID (creates child issue).
69        #[arg(long)]
70        parent: Option<String>,
71
72        /// Labels (comma-separated).
73        #[arg(short, long)]
74        labels: Option<String>,
75    },
76
77    /// Show issue details.
78    Show {
79        /// Issue ID.
80        id: String,
81
82        /// Include comments.
83        #[arg(long)]
84        comments: bool,
85
86        /// Include events.
87        #[arg(long)]
88        events: bool,
89    },
90
91    /// List issues.
92    List {
93        /// Filter by status.
94        #[arg(short, long)]
95        status: Option<String>,
96
97        /// Filter by type.
98        #[arg(short = 'T', long)]
99        issue_type: Option<String>,
100
101        /// Filter by assignee.
102        #[arg(short, long)]
103        assignee: Option<String>,
104
105        /// Filter by label.
106        #[arg(short, long)]
107        label: Option<String>,
108
109        /// Include closed issues.
110        #[arg(long)]
111        all: bool,
112
113        /// Maximum number of results.
114        #[arg(short = 'n', long)]
115        limit: Option<usize>,
116    },
117
118    /// Show ready (unblocked) issues.
119    Ready {
120        /// Maximum number of results.
121        #[arg(short = 'n', long)]
122        limit: Option<usize>,
123    },
124
125    /// Update an issue.
126    Update {
127        /// Issue ID.
128        id: String,
129
130        /// New title.
131        #[arg(short, long)]
132        title: Option<String>,
133
134        /// New description.
135        #[arg(short, long)]
136        description: Option<String>,
137
138        /// New status.
139        #[arg(short, long)]
140        status: Option<String>,
141
142        /// New priority.
143        #[arg(short, long)]
144        priority: Option<i32>,
145
146        /// New assignee.
147        #[arg(short, long)]
148        assignee: Option<String>,
149    },
150
151    /// Close an issue.
152    Close {
153        /// Issue ID.
154        id: String,
155
156        /// Reason for closing.
157        #[arg(short, long)]
158        reason: Option<String>,
159    },
160
161    /// Delete (tombstone) an issue.
162    Delete {
163        /// Issue ID.
164        id: String,
165
166        /// Reason for deletion.
167        #[arg(short, long)]
168        reason: Option<String>,
169    },
170
171    /// Manage dependencies.
172    Dep {
173        #[command(subcommand)]
174        action: DepAction,
175    },
176
177    /// Manage labels.
178    Label {
179        #[command(subcommand)]
180        action: LabelAction,
181    },
182
183    /// Show statistics.
184    Stats,
185
186    /// Daemon management.
187    Daemon {
188        #[command(subcommand)]
189        action: DaemonAction,
190    },
191
192    /// Compact completed issues.
193    Compact {
194        /// Maximum compaction level (1-3).
195        #[arg(short, long, default_value = "1")]
196        level: i32,
197
198        /// Compact a specific issue.
199        #[arg(short, long)]
200        id: Option<String>,
201    },
202
203    /// Configuration management.
204    Config {
205        /// Config key.
206        key: Option<String>,
207
208        /// Config value (if setting).
209        value: Option<String>,
210
211        /// Delete the key.
212        #[arg(long)]
213        delete: bool,
214    },
215
216    /// Context store management for agents.
217    Context {
218        #[command(subcommand)]
219        action: ContextAction,
220    },
221}
222
223#[derive(Subcommand)]
224pub enum DepAction {
225    /// Add a dependency.
226    Add {
227        /// Issue that depends on another.
228        issue_id: String,
229
230        /// Issue being depended upon.
231        depends_on: String,
232
233        /// Dependency type (blocks, parent-child, relates-to).
234        #[arg(short = 'T', long, default_value = "blocks")]
235        dep_type: String,
236    },
237
238    /// Remove a dependency.
239    Remove {
240        /// Issue that depends on another.
241        issue_id: String,
242
243        /// Issue being depended upon.
244        depends_on: String,
245    },
246
247    /// List dependencies for an issue.
248    List {
249        /// Issue ID.
250        issue_id: String,
251    },
252}
253
254#[derive(Subcommand)]
255pub enum LabelAction {
256    /// Add a label.
257    Add {
258        /// Issue ID.
259        issue_id: String,
260
261        /// Label to add.
262        label: String,
263    },
264
265    /// Remove a label.
266    Remove {
267        /// Issue ID.
268        issue_id: String,
269
270        /// Label to remove.
271        label: String,
272    },
273
274    /// List labels for an issue.
275    List {
276        /// Issue ID.
277        issue_id: String,
278    },
279}
280
281#[derive(Subcommand)]
282pub enum DaemonAction {
283    /// Start the daemon.
284    Start {
285        /// Run in foreground.
286        #[arg(long)]
287        foreground: bool,
288
289        /// Auto-commit changes.
290        #[arg(long)]
291        auto_commit: bool,
292
293        /// Auto-push to remote.
294        #[arg(long)]
295        auto_push: bool,
296    },
297
298    /// Stop the daemon.
299    Stop,
300
301    /// Show daemon status.
302    Status,
303}
304
305#[derive(Subcommand)]
306pub enum ContextAction {
307    /// Get a context entry by key.
308    Get {
309        /// Context key (e.g., "file:src/main.rs:summary").
310        key: String,
311    },
312
313    /// Set a context entry.
314    Set {
315        /// Context key.
316        key: String,
317
318        /// JSON value.
319        value: String,
320
321        /// TTL in seconds (optional).
322        #[arg(long)]
323        ttl: Option<i64>,
324
325        /// File path this entry relates to.
326        #[arg(long)]
327        file: Option<String>,
328    },
329
330    /// Delete a context entry.
331    Delete {
332        /// Context key to delete.
333        key: String,
334    },
335
336    /// List context entries.
337    List {
338        /// Filter by namespace (file, symbol, project, session, agent).
339        #[arg(short = 'N', long)]
340        namespace: Option<String>,
341
342        /// Filter by key prefix.
343        #[arg(short, long)]
344        prefix: Option<String>,
345
346        /// Maximum number of entries.
347        #[arg(short = 'n', long)]
348        limit: Option<usize>,
349    },
350
351    /// Clear context entries.
352    Clear {
353        /// Clear only entries in this namespace.
354        #[arg(short = 'N', long)]
355        namespace: Option<String>,
356
357        /// Clear expired entries only.
358        #[arg(long)]
359        expired: bool,
360    },
361
362    /// Show context store statistics.
363    Stats,
364
365    /// Get file context.
366    File {
367        /// File path.
368        path: String,
369    },
370
371    /// Get or set project context.
372    Project {
373        /// Set project context (JSON value).
374        #[arg(long)]
375        set: Option<String>,
376    },
377
378    /// Get session context.
379    Session {
380        /// Session ID.
381        session_id: String,
382    },
383
384    /// Refresh git state for invalidation.
385    Refresh,
386}
387
388/// Run the CLI.
389pub async fn run(cli: Cli) -> Result<()> {
390    let actor = cli.actor.unwrap_or_else(get_default_actor);
391    let json_output = cli.json;
392
393    match cli.command {
394        Command::Init { path } => {
395            let target = path.unwrap_or_else(|| std::env::current_dir().unwrap());
396            let beads_path = init_beads_dir(&target)?;
397
398            // Create database
399            let db_path = beads_path.join("beads.db");
400            SqliteStorage::open(&db_path)?;
401
402            if json_output {
403                println!(r#"{{"success": true, "path": {:?}}}"#, beads_path);
404            } else {
405                println!("Initialized .beads directory at {:?}", beads_path);
406            }
407        }
408
409        Command::Create {
410            title,
411            description,
412            issue_type,
413            priority,
414            assignee,
415            parent,
416            labels,
417        } => {
418            let storage = open_storage()?;
419
420            let id = if let Some(ref parent_id) = parent {
421                let counter = storage.next_child_counter(parent_id)?;
422                crate::idgen::generate_child_id(parent_id, counter)
423            } else {
424                generate_id("bd")
425            };
426
427            let mut issue = Issue::new(&id, &title, &actor);
428            issue.description = description;
429            issue.issue_type = issue_type.parse().unwrap_or_default();
430            issue.priority = priority.clamp(0, 4);
431            issue.assignee = assignee;
432            issue.update_content_hash();
433
434            storage.create_issue(&issue)?;
435
436            // Add labels
437            if let Some(labels_str) = labels {
438                for label in labels_str.split(',').map(|s| s.trim()) {
439                    storage.add_label(&id, label)?;
440                }
441            }
442
443            // Add parent-child dependency
444            if let Some(ref parent_id) = parent {
445                let dep = Dependency::parent_child(&id, parent_id)
446                    .with_creator(&actor);
447                storage.add_dependency(&dep)?;
448            }
449
450            if json_output {
451                println!("{}", serde_json::to_string(&issue)?);
452            } else {
453                println!("Created issue: {}", id);
454            }
455        }
456
457        Command::Show { id, comments, events } => {
458            let storage = open_storage()?;
459
460            let issue = storage.get_issue(&id)?
461                .context(format!("Issue not found: {}", id))?;
462
463            if json_output {
464                let mut output = serde_json::to_value(&issue)?;
465                if comments {
466                    let comments = storage.get_comments(&id)?;
467                    output["comments"] = serde_json::to_value(&comments)?;
468                }
469                if events {
470                    let events = storage.get_events(&id)?;
471                    output["events"] = serde_json::to_value(&events)?;
472                }
473                println!("{}", serde_json::to_string_pretty(&output)?);
474            } else {
475                print_issue(&issue);
476
477                if comments {
478                    let comments = storage.get_comments(&id)?;
479                    if !comments.is_empty() {
480                        println!("\nComments:");
481                        for comment in comments {
482                            println!("  [{} by {}]: {}", comment.created_at.format("%Y-%m-%d"), comment.author, comment.text);
483                        }
484                    }
485                }
486            }
487        }
488
489        Command::List {
490            status,
491            issue_type,
492            assignee,
493            label,
494            all,
495            limit,
496        } => {
497            let storage = open_storage()?;
498
499            let mut filter = if all {
500                IssueFilter::new().include_deleted()
501            } else {
502                IssueFilter::active()
503            };
504
505            if let Some(ref s) = status {
506                filter.status = s.parse().ok();
507            }
508            if let Some(ref t) = issue_type {
509                filter.issue_type = t.parse().ok();
510            }
511            filter.assignee = assignee;
512            filter.label = label;
513            filter.limit = limit;
514
515            let issues = storage.search_issues(&filter)?;
516
517            if json_output {
518                println!("{}", serde_json::to_string(&issues)?);
519            } else {
520                if issues.is_empty() {
521                    println!("No issues found");
522                } else {
523                    for issue in &issues {
524                        print_issue_summary(issue);
525                    }
526                    println!("\n{} issue(s)", issues.len());
527                }
528            }
529        }
530
531        Command::Ready { limit } => {
532            let storage = open_storage()?;
533
534            let mut issues = storage.get_ready_work()?;
535            if let Some(n) = limit {
536                issues.truncate(n);
537            }
538
539            if json_output {
540                println!("{}", serde_json::to_string(&issues)?);
541            } else {
542                if issues.is_empty() {
543                    println!("No ready issues");
544                } else {
545                    println!("Ready issues:");
546                    for issue in &issues {
547                        print_issue_summary(issue);
548                    }
549                    println!("\n{} ready issue(s)", issues.len());
550                }
551            }
552        }
553
554        Command::Update {
555            id,
556            title,
557            description,
558            status,
559            priority,
560            assignee,
561        } => {
562            let storage = open_storage()?;
563
564            let mut issue = storage.get_issue(&id)?
565                .context(format!("Issue not found: {}", id))?;
566
567            if let Some(t) = title {
568                issue.title = t;
569            }
570            if let Some(d) = description {
571                issue.description = Some(d);
572            }
573            if let Some(s) = status {
574                issue.status = s.parse().unwrap_or(issue.status);
575            }
576            if let Some(p) = priority {
577                issue.priority = p.clamp(0, 4);
578            }
579            if let Some(a) = assignee {
580                issue.assignee = Some(a);
581            }
582
583            issue.touch();
584            issue.update_content_hash();
585            storage.update_issue(&issue)?;
586
587            if json_output {
588                println!("{}", serde_json::to_string(&issue)?);
589            } else {
590                println!("Updated issue: {}", id);
591            }
592        }
593
594        Command::Close { id, reason } => {
595            let storage = open_storage()?;
596            storage.close_issue(&id, &actor, reason.as_deref())?;
597
598            if json_output {
599                println!(r#"{{"success": true, "id": "{}"}}"#, id);
600            } else {
601                println!("Closed issue: {}", id);
602            }
603        }
604
605        Command::Delete { id, reason } => {
606            let storage = open_storage()?;
607            storage.delete_issue(&id, &actor, reason.as_deref())?;
608
609            if json_output {
610                println!(r#"{{"success": true, "id": "{}"}}"#, id);
611            } else {
612                println!("Deleted issue: {}", id);
613            }
614        }
615
616        Command::Dep { action } => {
617            let storage = open_storage()?;
618
619            match action {
620                DepAction::Add { issue_id, depends_on, dep_type } => {
621                    let dep = Dependency {
622                        issue_id: issue_id.clone(),
623                        depends_on_id: depends_on.clone(),
624                        dep_type: dep_type.parse().unwrap_or_default(),
625                        created_at: chrono::Utc::now(),
626                        created_by: Some(actor),
627                        metadata: None,
628                        thread_id: None,
629                    };
630                    storage.add_dependency(&dep)?;
631
632                    if json_output {
633                        println!(r#"{{"success": true}}"#);
634                    } else {
635                        println!("Added dependency: {} -> {}", issue_id, depends_on);
636                    }
637                }
638
639                DepAction::Remove { issue_id, depends_on } => {
640                    storage.remove_dependency(&issue_id, &depends_on)?;
641
642                    if json_output {
643                        println!(r#"{{"success": true}}"#);
644                    } else {
645                        println!("Removed dependency: {} -> {}", issue_id, depends_on);
646                    }
647                }
648
649                DepAction::List { issue_id } => {
650                    let deps = storage.get_dependencies(&issue_id)?;
651
652                    if json_output {
653                        println!("{}", serde_json::to_string(&deps)?);
654                    } else {
655                        if deps.is_empty() {
656                            println!("No dependencies");
657                        } else {
658                            println!("Dependencies for {}:", issue_id);
659                            for dep in &deps {
660                                println!("  {} ({})", dep.depends_on_id, dep.dep_type);
661                            }
662                        }
663                    }
664                }
665            }
666        }
667
668        Command::Label { action } => {
669            let storage = open_storage()?;
670
671            match action {
672                LabelAction::Add { issue_id, label } => {
673                    storage.add_label(&issue_id, &label)?;
674
675                    if json_output {
676                        println!(r#"{{"success": true}}"#);
677                    } else {
678                        println!("Added label '{}' to {}", label, issue_id);
679                    }
680                }
681
682                LabelAction::Remove { issue_id, label } => {
683                    storage.remove_label(&issue_id, &label)?;
684
685                    if json_output {
686                        println!(r#"{{"success": true}}"#);
687                    } else {
688                        println!("Removed label '{}' from {}", label, issue_id);
689                    }
690                }
691
692                LabelAction::List { issue_id } => {
693                    let labels = storage.get_labels(&issue_id)?;
694
695                    if json_output {
696                        println!("{}", serde_json::to_string(&labels)?);
697                    } else {
698                        if labels.is_empty() {
699                            println!("No labels");
700                        } else {
701                            println!("Labels: {}", labels.join(", "));
702                        }
703                    }
704                }
705            }
706        }
707
708        Command::Stats => {
709            let storage = open_storage()?;
710            let stats = storage.get_statistics()?;
711
712            if json_output {
713                println!("{}", serde_json::to_string(&stats)?);
714            } else {
715                println!("Issues:");
716                println!("  Total:       {}", stats.total_issues);
717                println!("  Open:        {}", stats.open_issues);
718                println!("  In Progress: {}", stats.in_progress_issues);
719                println!("  Blocked:     {}", stats.blocked_issues);
720                println!("  Closed:      {}", stats.closed_issues);
721                println!("  Ready:       {}", stats.ready_issues);
722                println!("\nDependencies: {}", stats.total_dependencies);
723            }
724        }
725
726        Command::Daemon { action } => {
727            let beads_dir = find_beads_dir()
728                .context("Not in a beads repository")?;
729
730            match action {
731                DaemonAction::Start { foreground, auto_commit, auto_push } => {
732                    if !foreground {
733                        // Fork to background
734                        println!("Starting daemon in background...");
735
736                        let db_path = beads_dir.join("beads.db");
737                        let storage = SqliteStorage::open(&db_path)?;
738                        let config = DaemonConfig {
739                            auto_commit,
740                            auto_push,
741                            ..Default::default()
742                        };
743
744                        let daemon = Daemon::new(storage, beads_dir, config);
745                        daemon.start().await?;
746                    } else {
747                        // Run in foreground
748                        let db_path = beads_dir.join("beads.db");
749                        let storage = SqliteStorage::open(&db_path)?;
750                        let config = DaemonConfig {
751                            auto_commit,
752                            auto_push,
753                            ..Default::default()
754                        };
755
756                        let daemon = Daemon::new(storage, beads_dir, config);
757                        daemon.start().await?;
758                    }
759                }
760
761                DaemonAction::Stop => {
762                    let client = DaemonClient::new(&beads_dir);
763                    client.shutdown().await?;
764                    println!("Daemon stopped");
765                }
766
767                DaemonAction::Status => {
768                    let client = DaemonClient::new(&beads_dir);
769                    match client.health().await {
770                        Ok(health) => {
771                            if json_output {
772                                println!("{}", serde_json::to_string(&health)?);
773                            } else {
774                                println!("Daemon running:");
775                                println!("  PID:     {}", health.pid);
776                                println!("  Uptime:  {}s", health.uptime_secs);
777                                println!("  Version: {}", health.version);
778                            }
779                        }
780                        Err(_) => {
781                            if json_output {
782                                println!(r#"{{"running": false}}"#);
783                            } else {
784                                println!("Daemon not running");
785                            }
786                        }
787                    }
788                }
789            }
790        }
791
792        Command::Compact { level, id } => {
793            let storage = Arc::new(open_storage()?);
794            let compactor = Compactor::new(storage);
795
796            let stats = if let Some(issue_id) = id {
797                compactor.compact_issue(&issue_id, level)?
798            } else {
799                compactor.compact_completed(level)?
800            };
801
802            if json_output {
803                println!("{}", serde_json::to_string(&stats)?);
804            } else {
805                println!("Compaction complete:");
806                println!("  Compacted:   {}", stats.compacted);
807                println!("  Skipped:     {}", stats.skipped);
808                println!("  Bytes saved: {}", stats.bytes_saved);
809                if !stats.errors.is_empty() {
810                    println!("  Errors:      {}", stats.errors.len());
811                }
812            }
813        }
814
815        Command::Config { key, value, delete } => {
816            let storage = open_storage()?;
817
818            match (key, value, delete) {
819                (None, None, _) => {
820                    // List all config
821                    let config = storage.get_all_config()?;
822                    if json_output {
823                        println!("{}", serde_json::to_string(&config)?);
824                    } else {
825                        for (k, v) in &config {
826                            println!("{} = {}", k, v);
827                        }
828                    }
829                }
830                (Some(k), None, false) => {
831                    // Get config value
832                    if let Some(v) = storage.get_config(&k)? {
833                        if json_output {
834                            println!(r#"{{"{}": "{}"}}"#, k, v);
835                        } else {
836                            println!("{}", v);
837                        }
838                    } else if !json_output {
839                        println!("Not set");
840                    }
841                }
842                (Some(k), Some(v), _) => {
843                    // Set config value
844                    storage.set_config(&k, &v)?;
845                    if json_output {
846                        println!(r#"{{"success": true}}"#);
847                    } else {
848                        println!("Set {} = {}", k, v);
849                    }
850                }
851                (Some(k), None, true) => {
852                    // Delete config value
853                    storage.delete_config(&k)?;
854                    if json_output {
855                        println!(r#"{{"success": true}}"#);
856                    } else {
857                        println!("Deleted {}", k);
858                    }
859                }
860                (None, Some(_), _) => {
861                    // Invalid: value without key
862                    anyhow::bail!("Cannot set a value without a key");
863                }
864            }
865        }
866
867        Command::Context { action } => {
868            let ctx_store = open_context_store()?;
869
870            match action {
871                ContextAction::Get { key } => {
872                    if let Some(entry) = ctx_store.get(&key)? {
873                        if json_output {
874                            println!("{}", serde_json::to_string(&entry)?);
875                        } else {
876                            println!("Key:     {}", entry.key);
877                            println!("Value:   {}", serde_json::to_string_pretty(&entry.value)?);
878                            println!("Created: {}", entry.created_at.format("%Y-%m-%d %H:%M"));
879                            println!("Updated: {}", entry.updated_at.format("%Y-%m-%d %H:%M"));
880                            if let Some(ref expires) = entry.expires_at {
881                                println!("Expires: {}", expires.format("%Y-%m-%d %H:%M"));
882                            }
883                        }
884                    } else if json_output {
885                        println!("null");
886                    } else {
887                        println!("Not found: {}", key);
888                    }
889                }
890
891                ContextAction::Set { key, value, ttl, file } => {
892                    let json_value: serde_json::Value = serde_json::from_str(&value)
893                        .context("Invalid JSON value")?;
894
895                    let mut entry = ContextEntry::new(&key, json_value);
896
897                    if let Some(ttl_secs) = ttl {
898                        entry = entry.with_ttl(ttl_secs);
899                    }
900
901                    if let Some(file_path) = file {
902                        if let Some(mtime) = ctx_store.get_file_mtime(&file_path) {
903                            entry = entry.with_file_info(&file_path, mtime);
904                        }
905                    }
906
907                    ctx_store.set(entry)?;
908
909                    if json_output {
910                        println!(r#"{{"success": true}}"#);
911                    } else {
912                        println!("Set: {}", key);
913                    }
914                }
915
916                ContextAction::Delete { key } => {
917                    ctx_store.delete(&key)?;
918
919                    if json_output {
920                        println!(r#"{{"success": true}}"#);
921                    } else {
922                        println!("Deleted: {}", key);
923                    }
924                }
925
926                ContextAction::List { namespace, prefix, limit } => {
927                    let ns = namespace.as_deref().and_then(parse_namespace);
928                    let entries = ctx_store.list_simple(ns, prefix.as_deref())?;
929
930                    let entries: Vec<_> = if let Some(n) = limit {
931                        entries.into_iter().take(n).collect()
932                    } else {
933                        entries
934                    };
935
936                    if json_output {
937                        println!("{}", serde_json::to_string(&entries)?);
938                    } else {
939                        if entries.is_empty() {
940                            println!("No entries found");
941                        } else {
942                            for entry in &entries {
943                                let expired = if entry.is_expired() { " [EXPIRED]" } else { "" };
944                                println!("  {}{}", entry.key, expired);
945                            }
946                            println!("\n{} entries", entries.len());
947                        }
948                    }
949                }
950
951                ContextAction::Clear { namespace, expired } => {
952                    let count = if expired {
953                        ctx_store.cleanup_expired()?
954                    } else if let Some(ref ns_str) = namespace {
955                        if let Some(ns) = parse_namespace(ns_str) {
956                            ctx_store.clear_namespace(ns)?
957                        } else {
958                            anyhow::bail!("Invalid namespace: {}", ns_str);
959                        }
960                    } else {
961                        ctx_store.clear_all()?
962                    };
963
964                    if json_output {
965                        println!(r#"{{"cleared": {}}}"#, count);
966                    } else {
967                        println!("Cleared {} entries", count);
968                    }
969                }
970
971                ContextAction::Stats => {
972                    let stats = ctx_store.stats()?;
973
974                    if json_output {
975                        println!("{}", serde_json::to_string(&stats)?);
976                    } else {
977                        println!("Context Store Statistics:");
978                        println!("  Total entries:   {}", stats.total_entries);
979                        println!("  Expired entries: {}", stats.expired_entries);
980                        println!("\nBy namespace:");
981                        for (ns, count) in &stats.by_namespace {
982                            println!("  {}: {}", ns, count);
983                        }
984                    }
985                }
986
987                ContextAction::File { path } => {
988                    if let Some(file_ctx) = ctx_store.get_file_context(&path)? {
989                        if json_output {
990                            println!("{}", serde_json::to_string(&file_ctx)?);
991                        } else {
992                            println!("File: {}", file_ctx.path);
993                            if let Some(ref summary) = file_ctx.summary {
994                                println!("Summary: {}", summary);
995                            }
996                            if let Some(ref lang) = file_ctx.language {
997                                println!("Language: {}", lang);
998                            }
999                            if !file_ctx.symbols.is_empty() {
1000                                println!("Symbols: {}", file_ctx.symbols.len());
1001                                for sym in &file_ctx.symbols {
1002                                    println!("  - {} ({:?})", sym.name, sym.kind);
1003                                }
1004                            }
1005                        }
1006                    } else if json_output {
1007                        println!("null");
1008                    } else {
1009                        println!("No context for file: {}", path);
1010                    }
1011                }
1012
1013                ContextAction::Project { set } => {
1014                    if let Some(value) = set {
1015                        let project_ctx: ProjectContext = serde_json::from_str(&value)
1016                            .context("Invalid project context JSON")?;
1017                        ctx_store.set_project_context(&project_ctx)?;
1018
1019                        if json_output {
1020                            println!(r#"{{"success": true}}"#);
1021                        } else {
1022                            println!("Project context updated");
1023                        }
1024                    } else {
1025                        if let Some(project_ctx) = ctx_store.get_project_context()? {
1026                            if json_output {
1027                                println!("{}", serde_json::to_string(&project_ctx)?);
1028                            } else {
1029                                if let Some(ref name) = project_ctx.name {
1030                                    println!("Name: {}", name);
1031                                }
1032                                if let Some(ref desc) = project_ctx.description {
1033                                    println!("Description: {}", desc);
1034                                }
1035                                if !project_ctx.languages.is_empty() {
1036                                    println!("Languages: {}", project_ctx.languages.join(", "));
1037                                }
1038                                if !project_ctx.frameworks.is_empty() {
1039                                    println!("Frameworks: {}", project_ctx.frameworks.join(", "));
1040                                }
1041                            }
1042                        } else if json_output {
1043                            println!("null");
1044                        } else {
1045                            println!("No project context set");
1046                        }
1047                    }
1048                }
1049
1050                ContextAction::Session { session_id } => {
1051                    if let Some(session) = ctx_store.get_session(&session_id)? {
1052                        if json_output {
1053                            println!("{}", serde_json::to_string(&session)?);
1054                        } else {
1055                            println!("Session: {}", session.session_id);
1056                            if let Some(ref agent) = session.agent_id {
1057                                println!("Agent: {}", agent);
1058                            }
1059                            if let Some(ref task) = session.current_task {
1060                                println!("Task: {}", task);
1061                            }
1062                            if !session.working_files.is_empty() {
1063                                println!("Working files:");
1064                                for f in &session.working_files {
1065                                    println!("  - {}", f);
1066                                }
1067                            }
1068                            if !session.decisions.is_empty() {
1069                                println!("Decisions: {}", session.decisions.len());
1070                            }
1071                        }
1072                    } else if json_output {
1073                        println!("null");
1074                    } else {
1075                        println!("No session found: {}", session_id);
1076                    }
1077                }
1078
1079                ContextAction::Refresh => {
1080                    ctx_store.refresh_invalidation()?;
1081
1082                    if json_output {
1083                        println!(r#"{{"success": true}}"#);
1084                    } else {
1085                        println!("Git state refreshed");
1086                    }
1087                }
1088            }
1089        }
1090    }
1091
1092    Ok(())
1093}
1094
1095/// Open the storage database.
1096fn open_storage() -> Result<SqliteStorage> {
1097    let db_path = find_database_path()
1098        .context("Not in a beads repository. Run 'bd init' first.")?;
1099    SqliteStorage::open(&db_path).context("Failed to open database")
1100}
1101
1102/// Open the context store database.
1103fn open_context_store() -> Result<ContextStore> {
1104    let beads_dir = find_beads_dir()
1105        .context("Not in a beads repository. Run 'bd init' first.")?;
1106    let ctx_path = beads_dir.join("context.db");
1107    ContextStore::open(&ctx_path).context("Failed to open context store")
1108}
1109
1110/// Parse namespace from string.
1111fn parse_namespace(s: &str) -> Option<Namespace> {
1112    match s.to_lowercase().as_str() {
1113        "file" => Some(Namespace::File),
1114        "symbol" => Some(Namespace::Symbol),
1115        "project" => Some(Namespace::Project),
1116        "session" => Some(Namespace::Session),
1117        "agent" => Some(Namespace::Agent),
1118        "custom" => Some(Namespace::Custom),
1119        _ => None,
1120    }
1121}
1122
1123/// Get the default actor name.
1124fn get_default_actor() -> String {
1125    std::env::var("BD_ACTOR")
1126        .or_else(|_| std::env::var("BEADS_ACTOR"))
1127        .or_else(|_| std::env::var("USER"))
1128        .unwrap_or_else(|_| "unknown".to_string())
1129}
1130
1131/// Print an issue summary line.
1132fn print_issue_summary(issue: &Issue) {
1133    let status_icon = match issue.status {
1134        Status::Open => "○",
1135        Status::InProgress => "◐",
1136        Status::Blocked => "◌",
1137        Status::Closed => "●",
1138        Status::Deferred => "◇",
1139        _ => "○",
1140    };
1141
1142    let priority_str = match issue.priority {
1143        0 => "P0",
1144        1 => "P1",
1145        2 => "P2",
1146        3 => "P3",
1147        _ => "P4",
1148    };
1149
1150    println!(
1151        "{} {} [{}] {} - {}",
1152        status_icon,
1153        issue.id,
1154        priority_str,
1155        issue.issue_type,
1156        issue.title
1157    );
1158}
1159
1160/// Print full issue details.
1161fn print_issue(issue: &Issue) {
1162    println!("ID:          {}", issue.id);
1163    println!("Title:       {}", issue.title);
1164    println!("Status:      {}", issue.status);
1165    println!("Type:        {}", issue.issue_type);
1166    println!("Priority:    {}", issue.priority);
1167
1168    if let Some(ref assignee) = issue.assignee {
1169        println!("Assignee:    {}", assignee);
1170    }
1171
1172    if !issue.labels.is_empty() {
1173        println!("Labels:      {}", issue.labels.join(", "));
1174    }
1175
1176    if let Some(ref desc) = issue.description {
1177        println!("\nDescription:");
1178        println!("{}", desc);
1179    }
1180
1181    println!("\nCreated:     {} by {}", issue.created_at.format("%Y-%m-%d %H:%M"), issue.created_by);
1182    println!("Updated:     {}", issue.updated_at.format("%Y-%m-%d %H:%M"));
1183
1184    if let Some(ref closed_at) = issue.closed_at {
1185        println!("Closed:      {}", closed_at.format("%Y-%m-%d %H:%M"));
1186        if let Some(ref reason) = issue.close_reason {
1187            println!("Reason:      {}", reason);
1188        }
1189    }
1190}