Skip to main content

murk_cli/
agent.rs

1//! Agent-oriented schema output for AI agent prompts.
2//!
3//! `murk agent plan` emits the vault schema (key names, descriptions, examples,
4//! tags) without any secret values, recipient pubkeys, or other identifying
5//! metadata. The output is safe to paste into an agent prompt: an agent can be
6//! given enough context to write code against the required env vars without
7//! ever decrypting the vault.
8
9use std::fmt::Write;
10
11use serde::Serialize;
12
13use crate::types::Vault;
14
15/// Schema-only view of a vault, suitable for agent prompt context.
16#[derive(Debug, Clone, Serialize)]
17pub struct AgentPlan {
18    pub entries: Vec<AgentPlanKey>,
19}
20
21/// One key in the schema. Mirrors `info::InfoEntry` minus any field that names
22/// a recipient or carries recipient-derived metadata.
23#[derive(Debug, Clone, Serialize)]
24pub struct AgentPlanKey {
25    pub key: String,
26    pub description: String,
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub example: Option<String>,
29    #[serde(skip_serializing_if = "Vec::is_empty")]
30    pub tags: Vec<String>,
31}
32
33/// Build an `AgentPlan` from a vault's schema. If `tags` is non-empty an entry
34/// is included only when it carries one of the requested tags.
35pub fn agent_plan(vault: &Vault, tags: &[String]) -> AgentPlan {
36    let entries = vault
37        .schema
38        .iter()
39        .filter(|(_, e)| tags.is_empty() || e.tags.iter().any(|t| tags.contains(t)))
40        .map(|(name, entry)| AgentPlanKey {
41            key: name.clone(),
42            description: entry.description.clone(),
43            example: entry.example.clone(),
44            tags: entry.tags.clone(),
45        })
46        .collect();
47
48    AgentPlan { entries }
49}
50
51/// Format an `AgentPlan` as plain text (no ANSI). Columns are aligned.
52pub fn format_agent_plan_text(plan: &AgentPlan) -> String {
53    let mut out = format!(
54        "plan: {} key{}\n",
55        plan.entries.len(),
56        if plan.entries.len() == 1 { "" } else { "s" }
57    );
58
59    if plan.entries.is_empty() {
60        return out;
61    }
62
63    let key_width = plan.entries.iter().map(|e| e.key.len()).max().unwrap_or(0);
64    let desc_width = plan
65        .entries
66        .iter()
67        .map(|e| e.description.len())
68        .max()
69        .unwrap_or(0);
70    let example_width = plan
71        .entries
72        .iter()
73        .map(|e| {
74            e.example
75                .as_ref()
76                .map_or(0, |ex| format!("(e.g. {ex})").len())
77        })
78        .max()
79        .unwrap_or(0);
80    let any_tags = plan.entries.iter().any(|e| !e.tags.is_empty());
81
82    out.push('\n');
83    for entry in &plan.entries {
84        let example_str = entry
85            .example
86            .as_ref()
87            .map(|ex| format!("(e.g. {ex})"))
88            .unwrap_or_default();
89
90        let key_padded = format!("{:<key_width$}", entry.key);
91        let desc_padded = format!("{:<desc_width$}", entry.description);
92        let ex_padded = format!("{example_str:<example_width$}");
93
94        let tag_str = if entry.tags.is_empty() {
95            String::new()
96        } else {
97            format!("  [{}]", entry.tags.join(", "))
98        };
99
100        if any_tags {
101            let _ = writeln!(out, "  {key_padded}  {desc_padded}  {ex_padded}{tag_str}");
102        } else {
103            let _ = writeln!(out, "  {key_padded}  {desc_padded}  {ex_padded}");
104        }
105    }
106
107    out
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use crate::types::{SchemaEntry, VAULT_VERSION};
114    use std::collections::BTreeMap;
115
116    fn make_vault() -> Vault {
117        let mut schema = BTreeMap::new();
118        schema.insert(
119            "DATABASE_URL".into(),
120            SchemaEntry {
121                description: "Postgres connection string".into(),
122                example: Some("postgres://localhost/db".into()),
123                tags: vec!["db".into()],
124                created: None,
125                updated: None,
126                ..Default::default()
127            },
128        );
129        schema.insert(
130            "STRIPE_SECRET_KEY".into(),
131            SchemaEntry {
132                description: "Stripe API key".into(),
133                example: None,
134                tags: vec!["payments".into()],
135                created: None,
136                updated: None,
137                ..Default::default()
138            },
139        );
140        Vault {
141            version: VAULT_VERSION.into(),
142            created: "2026-01-01T00:00:00Z".into(),
143            vault_name: "myapp".into(),
144            repo: String::new(),
145            recipients: vec!["age1exampleabc".into()],
146            schema,
147            policy: None,
148            secrets: BTreeMap::new(),
149            meta: "encrypted-meta-blob".into(),
150        }
151    }
152
153    #[test]
154    fn plan_includes_all_keys_when_no_tag_filter() {
155        let vault = make_vault();
156        let plan = agent_plan(&vault, &[]);
157        assert_eq!(plan.entries.len(), 2);
158    }
159
160    #[test]
161    fn plan_filters_by_tag() {
162        let vault = make_vault();
163        let plan = agent_plan(&vault, &["db".to_string()]);
164        assert_eq!(plan.entries.len(), 1);
165        assert_eq!(plan.entries[0].key, "DATABASE_URL");
166    }
167
168    #[test]
169    fn plan_empty_when_filter_matches_nothing() {
170        let vault = make_vault();
171        let plan = agent_plan(&vault, &["nonexistent".to_string()]);
172        assert!(plan.entries.is_empty());
173    }
174
175    #[test]
176    fn json_does_not_leak_recipients_meta_or_vault_name() {
177        let vault = make_vault();
178        let plan = agent_plan(&vault, &[]);
179        let json = serde_json::to_string(&plan).unwrap();
180        assert!(!json.contains("age1exampleabc"));
181        assert!(!json.contains("encrypted-meta-blob"));
182        assert!(!json.contains("recipient"));
183        assert!(!json.contains("\"meta\""));
184        assert!(!json.contains("myapp"));
185        assert!(!json.contains("vault_name"));
186    }
187
188    #[test]
189    fn text_format_includes_key_description_example_and_tag() {
190        let vault = make_vault();
191        let plan = agent_plan(&vault, &[]);
192        let text = format_agent_plan_text(&plan);
193        assert!(text.contains("plan: 2 keys"));
194        assert!(!text.contains("myapp"));
195        assert!(text.contains("DATABASE_URL"));
196        assert!(text.contains("Postgres connection string"));
197        assert!(text.contains("(e.g. postgres://localhost/db)"));
198        assert!(text.contains("[db]"));
199    }
200
201    #[test]
202    fn text_format_handles_empty_schema() {
203        let mut vault = make_vault();
204        vault.schema = BTreeMap::new();
205        let plan = agent_plan(&vault, &[]);
206        let text = format_agent_plan_text(&plan);
207        assert!(text.contains("plan: 0 keys"));
208    }
209
210    #[test]
211    fn text_format_singularizes_one_key() {
212        let mut vault = make_vault();
213        vault.schema.remove("STRIPE_SECRET_KEY");
214        let plan = agent_plan(&vault, &[]);
215        let text = format_agent_plan_text(&plan);
216        assert!(text.contains("plan: 1 key"));
217        assert!(!text.contains("1 keys"));
218    }
219}