Skip to main content

zag_agent/
plan.rs

1//! Library-level implementation of `zag plan`.
2//!
3//! Wraps a goal in the plan prompt template, runs it through a provider,
4//! and either streams the result to stdout (via the agent's default output
5//! handling) or captures it to a file path.
6//!
7//! # Example
8//!
9//! ```no_run
10//! use zag_agent::plan::{PlanParams, run_plan};
11//!
12//! # async fn example() -> anyhow::Result<()> {
13//! let result = run_plan(PlanParams {
14//!     provider: "claude".to_string(),
15//!     goal: "Add OAuth".to_string(),
16//!     output: Some("docs/oauth-plan.md".to_string()),
17//!     ..PlanParams::default()
18//! })
19//! .await?;
20//!
21//! if let Some(path) = result.written_to {
22//!     println!("plan written to {}", path.display());
23//! }
24//! # Ok(()) }
25//! ```
26
27use crate::factory::AgentFactory;
28use crate::progress::{ProgressHandler, SilentProgress};
29use anyhow::{Context, Result};
30use log::debug;
31use std::path::{Path, PathBuf};
32
33/// Plan prompt template — `{GOAL}`, `{CONTEXT_SECTION}`, `{PROMPT}` are
34/// replaced at run time.
35pub const PLAN_TEMPLATE: &str = include_str!("../prompts/plan/1_0.md");
36
37/// Parameters for [`run_plan`].
38pub struct PlanParams {
39    pub provider: String,
40    /// Goal to plan for.
41    pub goal: String,
42    /// Output path. If the path has no extension, a timestamped filename
43    /// is generated inside that directory. `None` streams to stdout via
44    /// the underlying agent's default output path.
45    pub output: Option<String>,
46    /// Additional instructions appended to the prompt.
47    pub instructions: Option<String>,
48    pub system_prompt: Option<String>,
49    pub model: Option<String>,
50    pub root: Option<String>,
51    pub auto_approve: bool,
52    pub add_dirs: Vec<String>,
53    /// Progress handler — defaults to [`SilentProgress`].
54    pub progress: Box<dyn ProgressHandler>,
55}
56
57impl Default for PlanParams {
58    fn default() -> Self {
59        Self {
60            provider: "claude".to_string(),
61            goal: String::new(),
62            output: None,
63            instructions: None,
64            system_prompt: None,
65            model: None,
66            root: None,
67            auto_approve: false,
68            add_dirs: Vec::new(),
69            progress: Box::new(SilentProgress),
70        }
71    }
72}
73
74/// Result of running a plan.
75#[derive(Debug, Clone, Default)]
76pub struct PlanResult {
77    /// Captured plan text, when `output` was set (otherwise the plan was
78    /// streamed to stdout by the underlying agent and nothing is captured
79    /// here).
80    pub text: Option<String>,
81    /// Path the plan was written to, when `output` was set.
82    pub written_to: Option<PathBuf>,
83}
84
85/// Render the plan prompt from [`PLAN_TEMPLATE`].
86pub fn build_plan_prompt(goal: &str, instructions: Option<&str>) -> String {
87    let context_section = String::new();
88    let prompt_section = match instructions {
89        Some(inst) => format!("## Additional Instructions\n\n{inst}"),
90        None => String::new(),
91    };
92
93    PLAN_TEMPLATE
94        .replace("{GOAL}", goal)
95        .replace("{CONTEXT_SECTION}", &context_section)
96        .replace("{PROMPT}", &prompt_section)
97}
98
99/// Resolve a caller-supplied output path. If the input has an extension the
100/// path is used verbatim; otherwise a timestamped `plan-YYYYMMDD-HHMMSS.md`
101/// is generated inside that directory.
102pub fn resolve_output_path(output: &str) -> PathBuf {
103    let path = PathBuf::from(output);
104    if path.extension().is_some() {
105        path
106    } else {
107        let timestamp = chrono::Utc::now().format("%Y%m%d-%H%M%S");
108        path.join(format!("plan-{timestamp}.md"))
109    }
110}
111
112/// Validate that `path` is inside the user's home directory — used when
113/// `ZAG_USER_HOME_DIR` is set (multi-user `zag serve` mode) to keep a user
114/// from writing outside their sandbox. In direct CLI mode `ZAG_USER_HOME_DIR`
115/// is unset and this function is a no-op.
116pub fn validate_output_path(path: &Path) -> Result<()> {
117    let home_dir = match std::env::var("ZAG_USER_HOME_DIR") {
118        Ok(dir) => dir,
119        Err(_) => return Ok(()),
120    };
121    let home = PathBuf::from(&home_dir);
122    let canonical_home = std::fs::canonicalize(&home).unwrap_or_else(|_| home.clone());
123    let check_path = if path.exists() {
124        path.to_path_buf()
125    } else {
126        path.parent()
127            .map(PathBuf::from)
128            .unwrap_or_else(|| PathBuf::from("."))
129    };
130    let canonical = std::fs::canonicalize(&check_path).unwrap_or_else(|_| check_path.clone());
131    if !canonical.starts_with(&canonical_home) {
132        anyhow::bail!(
133            "Output path '{}' is outside your home directory: {}",
134            path.display(),
135            canonical_home.display()
136        );
137    }
138    Ok(())
139}
140
141/// Run a plan, returning captured text and the path written (if any).
142pub async fn run_plan(params: PlanParams) -> Result<PlanResult> {
143    let PlanParams {
144        provider,
145        goal,
146        output,
147        instructions,
148        system_prompt,
149        model,
150        root,
151        auto_approve,
152        add_dirs,
153        progress,
154    } = params;
155
156    debug!("Starting plan via {provider} for goal: {goal}");
157
158    let output_path = match output {
159        Some(ref out) => {
160            let resolved = resolve_output_path(out);
161            validate_output_path(&resolved)?;
162            Some(resolved)
163        }
164        None => None,
165    };
166
167    let plan_prompt = build_plan_prompt(&goal, instructions.as_deref());
168
169    progress.on_spinner_start(&format!("Initializing {provider} for planning"));
170    let mut agent = AgentFactory::create(
171        &provider,
172        system_prompt,
173        model,
174        root.clone(),
175        auto_approve,
176        add_dirs,
177    )?;
178    progress.on_spinner_finish();
179
180    let model_name = agent.get_model().to_string();
181
182    if output_path.is_some() {
183        agent.set_capture_output(true);
184    }
185    progress.on_success(&format!("Plan initialized with model {model_name}"));
186
187    let agent_output = agent.run(Some(&plan_prompt)).await?;
188    agent.cleanup().await?;
189
190    if let Some(path) = output_path {
191        let plan_text = agent_output.and_then(|o| o.result).unwrap_or_default();
192        if let Some(parent) = path.parent() {
193            std::fs::create_dir_all(parent)
194                .with_context(|| format!("Failed to create directory: {}", parent.display()))?;
195        }
196        std::fs::write(&path, &plan_text)
197            .with_context(|| format!("Failed to write plan to: {}", path.display()))?;
198        progress.on_success(&format!("Plan written to {}", path.display()));
199        Ok(PlanResult {
200            text: Some(plan_text),
201            written_to: Some(path),
202        })
203    } else {
204        Ok(PlanResult::default())
205    }
206}
207
208#[cfg(test)]
209#[path = "plan_tests.rs"]
210mod tests;