Skip to main content

sc/cli/commands/
plan.rs

1//! Plan management commands.
2//!
3//! Commands for managing SaveContext plans (PRDs, specs, feature docs):
4//! - `sc plan create <title>` - Create a new plan
5//! - `sc plan list` - List plans
6//! - `sc plan show <id>` - Show plan details
7//! - `sc plan update <id>` - Update plan settings
8
9use crate::cli::{PlanCommands, PlanCreateArgs, PlanUpdateArgs};
10use crate::config::plan_discovery::{self, AgentKind};
11use crate::config::{default_actor, resolve_db_path, resolve_project_path, resolve_session_id};
12use crate::error::{Error, Result};
13use crate::model::{Plan, PlanStatus};
14use crate::storage::SqliteStorage;
15use serde::Serialize;
16use std::path::{Path, PathBuf};
17use std::time::Duration;
18
19#[derive(Serialize)]
20struct PlanOutput {
21    id: String,
22    short_id: Option<String>,
23    project_path: String,
24    title: String,
25    status: String,
26    #[serde(skip_serializing_if = "Option::is_none")]
27    session_id: Option<String>,
28    content_preview: Option<String>,
29    success_criteria: Option<String>,
30    created_at: String,
31    updated_at: String,
32    completed_at: Option<String>,
33}
34
35impl From<Plan> for PlanOutput {
36    fn from(p: Plan) -> Self {
37        // Create a content preview (first 200 chars)
38        let content_preview = p.content.as_ref().map(|c| {
39            if c.len() > 200 {
40                format!("{}...", &c[..200])
41            } else {
42                c.clone()
43            }
44        });
45
46        Self {
47            id: p.id,
48            short_id: p.short_id,
49            project_path: p.project_path,
50            title: p.title,
51            status: p.status.as_str().to_string(),
52            session_id: p.session_id,
53            content_preview,
54            success_criteria: p.success_criteria,
55            created_at: format_timestamp(p.created_at),
56            updated_at: format_timestamp(p.updated_at),
57            completed_at: p.completed_at.map(format_timestamp),
58        }
59    }
60}
61
62#[derive(Serialize)]
63struct PlanDetailOutput {
64    id: String,
65    short_id: Option<String>,
66    project_path: String,
67    title: String,
68    status: String,
69    #[serde(skip_serializing_if = "Option::is_none")]
70    session_id: Option<String>,
71    content: Option<String>,
72    success_criteria: Option<String>,
73    created_in_session: Option<String>,
74    completed_in_session: Option<String>,
75    #[serde(skip_serializing_if = "Option::is_none")]
76    source_path: Option<String>,
77    created_at: String,
78    updated_at: String,
79    completed_at: Option<String>,
80}
81
82impl From<Plan> for PlanDetailOutput {
83    fn from(p: Plan) -> Self {
84        Self {
85            id: p.id,
86            short_id: p.short_id,
87            project_path: p.project_path,
88            title: p.title,
89            status: p.status.as_str().to_string(),
90            session_id: p.session_id,
91            content: p.content,
92            success_criteria: p.success_criteria,
93            created_in_session: p.created_in_session,
94            completed_in_session: p.completed_in_session,
95            source_path: p.source_path,
96            created_at: format_timestamp(p.created_at),
97            updated_at: format_timestamp(p.updated_at),
98            completed_at: p.completed_at.map(format_timestamp),
99        }
100    }
101}
102
103#[derive(Serialize)]
104struct PlanListOutput {
105    plans: Vec<PlanOutput>,
106    count: usize,
107}
108
109fn format_timestamp(ts: i64) -> String {
110    chrono::DateTime::from_timestamp_millis(ts)
111        .map(|dt| dt.to_rfc3339())
112        .unwrap_or_else(|| ts.to_string())
113}
114
115/// Execute a plan command.
116pub fn execute(
117    command: &PlanCommands,
118    db_path: Option<&PathBuf>,
119    actor: Option<&str>,
120    json_output: bool,
121) -> Result<()> {
122    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
123        .ok_or(Error::NotInitialized)?;
124
125    if !db_path.exists() {
126        return Err(Error::NotInitialized);
127    }
128
129    let mut storage = SqliteStorage::open(&db_path)?;
130    let actor = actor.map(String::from).unwrap_or_else(default_actor);
131
132    match command {
133        PlanCommands::Create(args) => execute_create(&mut storage, args, json_output, &actor),
134        PlanCommands::List { status, limit, session } => execute_list(&storage, status, *limit, session.as_deref(), json_output),
135        PlanCommands::Show { id } => execute_show(&storage, id, json_output),
136        PlanCommands::Update(args) => execute_update(&mut storage, args, json_output, &actor),
137        PlanCommands::Capture { agent, max_age, file } => {
138            execute_capture(&mut storage, agent.as_deref(), *max_age, file.as_deref(), json_output, &actor)
139        }
140    }
141}
142
143fn execute_create(
144    storage: &mut SqliteStorage,
145    args: &PlanCreateArgs,
146    json_output: bool,
147    actor: &str,
148) -> Result<()> {
149    // Resolve project from DB (matches CWD against registered projects)
150    let project_path = resolve_project_path(storage, None)?;
151
152    // Get or create project
153    let project = storage.get_or_create_project(&project_path, actor)?;
154
155    // Create plan
156    let status = PlanStatus::from_str(&args.status);
157    let mut plan = Plan::new(project.id.clone(), project_path, args.title.clone())
158        .with_status(status);
159
160    if let Some(ref content) = args.content {
161        plan = plan.with_content(content);
162    }
163
164    if let Some(ref criteria) = args.success_criteria {
165        plan = plan.with_success_criteria(criteria);
166    }
167
168    // Auto-resolve session: explicit flag > TTY cache > no binding
169    if let Ok(session_id) = resolve_session_id(args.session.as_deref()) {
170        plan = plan.with_session(&session_id);
171    }
172
173    storage.create_plan(&plan, actor)?;
174
175    if crate::is_silent() {
176        println!("{}", plan.id);
177        return Ok(());
178    }
179
180    if json_output {
181        let output = PlanOutput::from(plan);
182        println!("{}", serde_json::to_string_pretty(&output)?);
183    } else {
184        println!("Created plan: {}", plan.title);
185        println!("  ID:     {}", plan.id);
186        println!("  Status: {}", plan.status.as_str());
187    }
188
189    Ok(())
190}
191
192fn execute_list(
193    storage: &SqliteStorage,
194    status: &str,
195    limit: usize,
196    session: Option<&str>,
197    json_output: bool,
198) -> Result<()> {
199    // Resolve project from DB (matches CWD against registered projects)
200    let project_path = resolve_project_path(storage, None)?;
201
202    // Resolve session filter: "current" means active TTY session
203    let session_id = session.and_then(|s| {
204        if s == "current" {
205            resolve_session_id(None).ok()
206        } else {
207            Some(s.to_string())
208        }
209    });
210
211    let status_filter = if status == "all" { Some("all") } else { Some(status) };
212    let mut plans = storage.list_plans(&project_path, status_filter, limit)?;
213
214    // Filter by session if specified
215    if let Some(ref sid) = session_id {
216        plans.retain(|p| p.session_id.as_deref() == Some(sid.as_str()));
217    }
218
219    if crate::is_csv() {
220        println!("id,title,status");
221        for plan in &plans {
222            println!("{},{},{}", plan.id, crate::csv_escape(&plan.title), plan.status.as_str());
223        }
224    } else if json_output {
225        let output = PlanListOutput {
226            count: plans.len(),
227            plans: plans.into_iter().map(PlanOutput::from).collect(),
228        };
229        println!("{}", serde_json::to_string_pretty(&output)?);
230    } else if plans.is_empty() {
231        println!("No plans found.");
232        println!("\nCreate one with: sc plan create \"Plan Title\"");
233    } else {
234        println!("Plans ({}):\n", plans.len());
235        for plan in plans {
236            let status_icon = match plan.status {
237                PlanStatus::Draft => "📝",
238                PlanStatus::Active => "🔵",
239                PlanStatus::Completed => "✓",
240            };
241            println!("  {} {} [{}]", status_icon, plan.title, plan.status.as_str());
242            println!("    ID: {}", plan.id);
243            if let Some(criteria) = &plan.success_criteria {
244                let preview = if criteria.len() > 60 {
245                    format!("{}...", &criteria[..60])
246                } else {
247                    criteria.clone()
248                };
249                println!("    Success: {preview}");
250            }
251            println!();
252        }
253    }
254
255    Ok(())
256}
257
258fn execute_show(
259    storage: &SqliteStorage,
260    id: &str,
261    json_output: bool,
262) -> Result<()> {
263    let plan = storage.get_plan(id)?
264        .ok_or_else(|| Error::Other(format!("Plan not found: {id}")))?;
265
266    if json_output {
267        let output = PlanDetailOutput::from(plan);
268        println!("{}", serde_json::to_string_pretty(&output)?);
269    } else {
270        let status_icon = match plan.status {
271            PlanStatus::Draft => "📝",
272            PlanStatus::Active => "🔵",
273            PlanStatus::Completed => "✓",
274        };
275
276        println!("Plan: {} {}", status_icon, plan.title);
277        println!("  ID:     {}", plan.id);
278        println!("  Status: {}", plan.status.as_str());
279        println!("  Path:   {}", plan.project_path);
280
281        if let Some(criteria) = &plan.success_criteria {
282            println!();
283            println!("Success Criteria:");
284            println!("  {criteria}");
285        }
286
287        if let Some(content) = &plan.content {
288            println!();
289            println!("Content:");
290            println!("─────────────────────────────────────");
291            for line in content.lines() {
292                println!("  {line}");
293            }
294            println!("─────────────────────────────────────");
295        }
296
297        println!();
298        println!("Created: {}", format_timestamp(plan.created_at));
299        println!("Updated: {}", format_timestamp(plan.updated_at));
300        if let Some(completed_at) = plan.completed_at {
301            println!("Completed: {}", format_timestamp(completed_at));
302        }
303    }
304
305    Ok(())
306}
307
308fn execute_update(
309    storage: &mut SqliteStorage,
310    args: &PlanUpdateArgs,
311    json_output: bool,
312    actor: &str,
313) -> Result<()> {
314    // Verify plan exists
315    let plan = storage.get_plan(&args.id)?
316        .ok_or_else(|| Error::Other(format!("Plan not found: {}", args.id)))?;
317
318    // Update
319    storage.update_plan(
320        &plan.id,
321        args.title.as_deref(),
322        args.content.as_deref(),
323        args.status.as_deref(),
324        args.success_criteria.as_deref(),
325        actor,
326    )?;
327
328    // Fetch updated plan
329    let updated = storage.get_plan(&plan.id)?.unwrap();
330
331    if json_output {
332        let output = PlanOutput::from(updated);
333        println!("{}", serde_json::to_string_pretty(&output)?);
334    } else {
335        println!("Updated plan: {}", updated.title);
336        if args.title.is_some() {
337            println!("  Title: {}", updated.title);
338        }
339        if args.status.is_some() {
340            println!("  Status: {}", updated.status.as_str());
341        }
342        if args.success_criteria.is_some() {
343            println!("  Success criteria updated");
344        }
345        if args.content.is_some() {
346            println!("  Content updated");
347        }
348    }
349
350    Ok(())
351}
352
353fn execute_capture(
354    storage: &mut SqliteStorage,
355    agent: Option<&str>,
356    max_age_minutes: u64,
357    file: Option<&Path>,
358    json_output: bool,
359    actor: &str,
360) -> Result<()> {
361    // Resolve project from DB (matches CWD against registered projects)
362    let project_path = resolve_project_path(storage, None)?;
363
364    // Get or create project
365    let project = storage.get_or_create_project(&project_path, actor)?;
366
367    // Discover or read the plan
368    let discovered = if let Some(file_path) = file {
369        // Explicit file: read directly
370        let content = std::fs::read_to_string(file_path)
371            .map_err(|e| Error::Other(format!("Failed to read plan file: {e}")))?;
372        let filename = file_path
373            .file_stem()
374            .map(|s| s.to_string_lossy().to_string())
375            .unwrap_or_else(|| "unnamed".to_string());
376        let title = plan_discovery::extract_title(&content, &filename);
377        let modified = std::fs::metadata(file_path)
378            .and_then(|m| m.modified())
379            .unwrap_or_else(|_| std::time::SystemTime::now());
380
381        vec![plan_discovery::DiscoveredPlan {
382            path: file_path.to_path_buf(),
383            agent: AgentKind::ClaudeCode, // Default for explicit files
384            title,
385            content,
386            modified_at: modified,
387        }]
388    } else {
389        // Auto-discover from agent directories
390        let agent_filter = agent
391            .map(|a| AgentKind::from_arg(a)
392                .ok_or_else(|| Error::Other(format!(
393                    "Unknown agent: {a}. Use: claude, gemini, opencode, cursor, factory"
394                ))))
395            .transpose()?;
396
397        let max_age = Duration::from_secs(max_age_minutes * 60);
398        plan_discovery::discover_plans(Path::new(&project_path), agent_filter, max_age)
399    };
400
401    if discovered.is_empty() {
402        if !crate::is_silent() {
403            if json_output {
404                println!(r#"{{"captured":false,"reason":"no_plans_found"}}"#);
405            } else {
406                println!("No recent plan files found.");
407                if let Some(agent_name) = agent {
408                    println!("  Searched agent: {agent_name}");
409                } else {
410                    println!("  Searched: Claude Code, Gemini CLI, OpenCode, Cursor, Factory AI");
411                }
412                println!("  Max age: {max_age_minutes} minutes");
413            }
414        }
415        return Ok(());
416    }
417
418    // Take the most recent plan
419    let plan_file = &discovered[0];
420    let source_hash = plan_discovery::compute_content_hash(&plan_file.content);
421    let source_path = plan_file.path.to_string_lossy().to_string();
422
423    // Check for existing plan with same content hash (dedup)
424    if let Some(existing) = storage.find_plan_by_source_hash(&source_hash)? {
425        // Content hasn't changed - update title and content
426        storage.update_plan(
427            &existing.id,
428            Some(&plan_file.title),
429            Some(&plan_file.content),
430            None, // keep status
431            None, // keep criteria
432            actor,
433        )?;
434
435        if crate::is_silent() {
436            println!("{}", existing.id);
437        } else if json_output {
438            let updated = storage.get_plan(&existing.id)?.unwrap();
439            let output = PlanOutput::from(updated);
440            println!("{}", serde_json::to_string_pretty(&serde_json::json!({
441                "captured": true,
442                "action": "updated",
443                "agent": plan_file.agent.display_name(),
444                "plan": output,
445            }))?);
446        } else {
447            println!("Updated existing plan: {}", existing.title);
448            println!("  ID:     {}", existing.id);
449            println!("  Agent:  {}", plan_file.agent.display_name());
450            println!("  Source: {source_path}");
451        }
452
453        return Ok(());
454    }
455
456    // Create new plan
457    let mut plan = Plan::new(project.id.clone(), project_path, plan_file.title.clone())
458        .with_content(&plan_file.content)
459        .with_status(PlanStatus::Active)
460        .with_source(&source_path, &source_hash);
461
462    // Auto-resolve session
463    if let Ok(session_id) = resolve_session_id(None) {
464        plan = plan.with_session(&session_id);
465    }
466
467    storage.create_plan(&plan, actor)?;
468
469    if crate::is_silent() {
470        println!("{}", plan.id);
471    } else if json_output {
472        let output = PlanOutput::from(plan.clone());
473        println!("{}", serde_json::to_string_pretty(&serde_json::json!({
474            "captured": true,
475            "action": "created",
476            "agent": plan_file.agent.display_name(),
477            "plan": output,
478        }))?);
479    } else {
480        println!("Captured plan: {}", plan.title);
481        println!("  ID:     {}", plan.id);
482        println!("  Agent:  {}", plan_file.agent.display_name());
483        println!("  Source: {source_path}");
484        if let Some(ref sid) = plan.session_id {
485            println!("  Session: {sid}");
486        }
487    }
488
489    Ok(())
490}