Skip to main content

systemprompt_cli/commands/core/playbooks/
list.rs

1use 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}