systemprompt_cli/commands/core/playbooks/
list.rs1use anyhow::{anyhow, Context, Result};
2use clap::Args;
3use std::path::Path;
4
5use super::path_helpers::{path_to_playbook_info, playbook_id_to_path, scan_all_playbooks};
6use super::types::{ListOrDetail, PlaybookDetailOutput, PlaybookListOutput, PlaybookSummary};
7use crate::shared::CommandResult;
8
9#[derive(Debug, Clone, Args)]
10pub struct ListArgs {
11 #[arg(help = "Playbook ID to show details (optional)")]
12 pub playbook_id: Option<String>,
13
14 #[arg(long, help = "Filter by category")]
15 pub category: Option<String>,
16}
17
18pub fn execute(args: ListArgs) -> Result<CommandResult<ListOrDetail>> {
19 let playbooks_path = get_playbooks_path()?;
20
21 if let Some(playbook_id) = args.playbook_id {
22 return show_playbook_detail(&playbook_id, &playbooks_path);
23 }
24
25 let playbooks = scan_playbooks(&playbooks_path, args.category.as_deref());
26
27 let output = PlaybookListOutput { playbooks };
28
29 Ok(CommandResult::table(ListOrDetail::List(output))
30 .with_title("Playbooks")
31 .with_columns(vec![
32 "playbook_id".to_string(),
33 "name".to_string(),
34 "category".to_string(),
35 "domain".to_string(),
36 "enabled".to_string(),
37 "file_path".to_string(),
38 ]))
39}
40
41fn get_playbooks_path() -> Result<std::path::PathBuf> {
42 let profile = systemprompt_models::ProfileBootstrap::get().context("Failed to get profile")?;
43 Ok(std::path::PathBuf::from(format!(
44 "{}/playbook",
45 profile.paths.services
46 )))
47}
48
49fn show_playbook_detail(
50 playbook_id: &str,
51 playbooks_path: &Path,
52) -> Result<CommandResult<ListOrDetail>> {
53 let md_path = playbook_id_to_path(playbooks_path, playbook_id)?;
54
55 if !md_path.exists() {
56 return Err(anyhow!(
57 "Playbook '{}' not found at {}",
58 playbook_id,
59 md_path.display()
60 ));
61 }
62
63 let path_info = path_to_playbook_info(playbooks_path, &md_path)?;
64 let parsed = parse_playbook_markdown(&md_path, &path_info.category, &path_info.domain)?;
65
66 let instructions_preview = parsed.instructions.chars().take(200).collect::<String>()
67 + if parsed.instructions.len() > 200 {
68 "..."
69 } else {
70 ""
71 };
72
73 let output = PlaybookDetailOutput {
74 playbook_id: playbook_id.to_string(),
75 name: parsed.name,
76 description: parsed.description,
77 category: path_info.category,
78 domain: path_info.domain,
79 enabled: parsed.enabled,
80 tags: parsed.tags,
81 file_path: md_path.to_string_lossy().to_string(),
82 instructions_preview,
83 };
84
85 Ok(CommandResult::card(ListOrDetail::Detail(output))
86 .with_title(format!("Playbook: {}", playbook_id)))
87}
88
89fn scan_playbooks(playbooks_path: &Path, filter_category: Option<&str>) -> Vec<PlaybookSummary> {
90 let all_playbooks = scan_all_playbooks(playbooks_path);
91
92 let mut playbooks = Vec::new();
93
94 for info in all_playbooks {
95 if let Some(filter) = filter_category {
96 if info.category != filter {
97 continue;
98 }
99 }
100
101 match parse_playbook_markdown(&info.file_path, &info.category, &info.domain) {
102 Ok(parsed) => {
103 playbooks.push(PlaybookSummary {
104 playbook_id: info.playbook_id,
105 name: parsed.name,
106 category: info.category,
107 domain: info.domain,
108 enabled: parsed.enabled,
109 tags: parsed.tags,
110 file_path: info.file_path.to_string_lossy().to_string(),
111 });
112 },
113 Err(e) => {
114 tracing::warn!(
115 path = %info.file_path.display(),
116 error = %e,
117 "Failed to parse playbook"
118 );
119 },
120 }
121 }
122
123 playbooks
124}
125
126struct ParsedPlaybook {
127 name: String,
128 description: String,
129 enabled: bool,
130 tags: Vec<String>,
131 instructions: String,
132}
133
134fn parse_playbook_markdown(
135 md_path: &Path,
136 _category: &str,
137 _domain: &str,
138) -> Result<ParsedPlaybook> {
139 let content = std::fs::read_to_string(md_path)
140 .with_context(|| format!("Failed to read {}", md_path.display()))?;
141
142 let parts: Vec<&str> = content.splitn(3, "---").collect();
143 if parts.len() < 3 {
144 return Err(anyhow!(
145 "Invalid frontmatter format in {}",
146 md_path.display()
147 ));
148 }
149
150 let frontmatter: serde_yaml::Value = serde_yaml::from_str(parts[1])
151 .with_context(|| format!("Invalid YAML in {}", md_path.display()))?;
152
153 let name = frontmatter
154 .get("title")
155 .and_then(|v| v.as_str())
156 .ok_or_else(|| anyhow!("Missing title in {}", md_path.display()))?
157 .to_string();
158
159 let description = frontmatter
160 .get("description")
161 .and_then(|v| v.as_str())
162 .unwrap_or("")
163 .to_string();
164
165 let enabled = frontmatter
166 .get("enabled")
167 .and_then(serde_yaml::Value::as_bool)
168 .unwrap_or(true);
169
170 let tags = frontmatter
171 .get("keywords")
172 .or_else(|| frontmatter.get("tags"))
173 .and_then(|v| v.as_sequence())
174 .map_or_else(Vec::new, |seq| {
175 seq.iter()
176 .filter_map(|v| v.as_str().map(String::from))
177 .collect()
178 });
179
180 Ok(ParsedPlaybook {
181 name,
182 description,
183 enabled,
184 tags,
185 instructions: parts[2].trim().to_string(),
186 })
187}