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