systemprompt_cli/commands/core/playbooks/
sync.rs1use anyhow::{Context, Result};
2use clap::{Args, ValueEnum};
3use dialoguer::theme::ColorfulTheme;
4use dialoguer::{Confirm, Select};
5use std::sync::Arc;
6
7use super::types::PlaybookSyncOutput;
8use crate::shared::CommandResult;
9use crate::CliConfig;
10use systemprompt_database::{Database, DbPool};
11use systemprompt_logging::CliService;
12use systemprompt_models::{ProfileBootstrap, SecretsBootstrap};
13use systemprompt_sync::{LocalSyncDirection, PlaybooksDiffResult, PlaybooksLocalSync};
14
15#[derive(Debug, Clone, Copy, ValueEnum)]
16pub enum SyncDirection {
17 ToDb,
18 ToDisk,
19}
20
21#[derive(Debug, Clone, Copy, Args)]
22pub struct SyncArgs {
23 #[arg(long, value_enum, help = "Sync direction")]
24 pub direction: Option<SyncDirection>,
25
26 #[arg(long, help = "Show what would happen without making changes")]
27 pub dry_run: bool,
28
29 #[arg(long, help = "Delete items that only exist in target")]
30 pub delete_orphans: bool,
31
32 #[arg(short = 'y', long, help = "Skip confirmation prompts")]
33 pub yes: bool,
34}
35
36pub async fn execute(
37 args: SyncArgs,
38 config: &CliConfig,
39) -> Result<CommandResult<PlaybookSyncOutput>> {
40 CliService::section("Playbooks Sync");
41
42 let spinner = CliService::spinner("Connecting to database...");
43 let db = create_db_provider().await?;
44 spinner.finish_and_clear();
45
46 let playbooks_path = get_playbooks_path()?;
47
48 if !playbooks_path.exists() {
49 anyhow::bail!(
50 "Playbooks path does not exist: {}\nEnsure the path exists or update your profile",
51 playbooks_path.display()
52 );
53 }
54
55 let sync = PlaybooksLocalSync::new(Arc::clone(&db), playbooks_path.clone());
56 let spinner = CliService::spinner("Calculating diff...");
57 let diff = sync
58 .calculate_diff()
59 .await
60 .context("Failed to calculate playbooks diff")?;
61 spinner.finish_and_clear();
62
63 display_diff_summary(&diff);
64
65 if !diff.has_changes() {
66 CliService::success("Playbooks are in sync - no changes needed");
67 return Ok(CommandResult::text(PlaybookSyncOutput {
68 direction: "none".to_string(),
69 synced: 0,
70 skipped: 0,
71 deleted: 0,
72 errors: vec![],
73 })
74 .with_title("Playbooks Sync"));
75 }
76
77 let direction = match args.direction {
78 Some(SyncDirection::ToDisk) => LocalSyncDirection::ToDisk,
79 Some(SyncDirection::ToDb) => LocalSyncDirection::ToDatabase,
80 None => {
81 if args.dry_run {
82 LocalSyncDirection::ToDatabase
83 } else if !config.is_interactive() {
84 anyhow::bail!("--direction is required in non-interactive mode");
85 } else {
86 let Some(dir) = prompt_sync_direction()? else {
87 CliService::info("Sync cancelled");
88 return Ok(CommandResult::text(PlaybookSyncOutput {
89 direction: "cancelled".to_string(),
90 synced: 0,
91 skipped: 0,
92 deleted: 0,
93 errors: vec![],
94 })
95 .with_title("Playbooks Sync"));
96 };
97 dir
98 }
99 },
100 };
101
102 if args.dry_run {
103 CliService::info("[Dry Run] No changes made");
104 let direction_str = match direction {
105 LocalSyncDirection::ToDisk => "to-disk",
106 LocalSyncDirection::ToDatabase => "to-db",
107 };
108 return Ok(CommandResult::text(PlaybookSyncOutput {
109 direction: format!("{} (dry-run)", direction_str),
110 synced: 0,
111 skipped: 0,
112 deleted: 0,
113 errors: vec![],
114 })
115 .with_title("Playbooks Sync (Dry Run)"));
116 }
117
118 if !args.yes && config.is_interactive() {
119 let confirmed = Confirm::with_theme(&ColorfulTheme::default())
120 .with_prompt("Proceed with sync?")
121 .default(false)
122 .interact()
123 .context("Failed to get confirmation")?;
124
125 if !confirmed {
126 CliService::info("Sync cancelled");
127 return Ok(CommandResult::text(PlaybookSyncOutput {
128 direction: "cancelled".to_string(),
129 synced: 0,
130 skipped: 0,
131 deleted: 0,
132 errors: vec![],
133 })
134 .with_title("Playbooks Sync"));
135 }
136 }
137
138 let spinner = CliService::spinner("Syncing playbooks...");
139 let result = match direction {
140 LocalSyncDirection::ToDisk => sync.sync_to_disk(&diff, args.delete_orphans).await?,
141 LocalSyncDirection::ToDatabase => sync.sync_to_db(&diff, args.delete_orphans).await?,
142 };
143 spinner.finish_and_clear();
144
145 CliService::section("Sync Complete");
146 CliService::key_value("Direction", &result.direction);
147 CliService::key_value("Synced", &result.items_synced.to_string());
148 CliService::key_value("Deleted", &result.items_deleted.to_string());
149 CliService::key_value("Skipped", &result.items_skipped.to_string());
150
151 if !result.errors.is_empty() {
152 CliService::warning(&format!("Errors ({})", result.errors.len()));
153 for error in &result.errors {
154 CliService::error(&format!(" {}", error));
155 }
156 }
157
158 let output = PlaybookSyncOutput {
159 direction: result.direction,
160 synced: result.items_synced,
161 skipped: result.items_skipped,
162 deleted: result.items_deleted,
163 errors: result.errors,
164 };
165
166 Ok(CommandResult::text(output).with_title("Playbooks Sync"))
167}
168
169fn get_playbooks_path() -> Result<std::path::PathBuf> {
170 let profile = ProfileBootstrap::get().context("Failed to get profile")?;
171 Ok(std::path::PathBuf::from(format!(
172 "{}/playbook",
173 profile.paths.services
174 )))
175}
176
177async fn create_db_provider() -> Result<DbPool> {
178 let url = SecretsBootstrap::database_url()
179 .context("Database URL not configured")?
180 .to_string();
181
182 let database = Database::from_config("postgres", &url)
183 .await
184 .context("Failed to connect to database")?;
185
186 Ok(Arc::new(database))
187}
188
189fn display_diff_summary(diff: &PlaybooksDiffResult) {
190 CliService::section("Playbooks Status");
191 CliService::info(&format!("{} unchanged", diff.unchanged));
192
193 if !diff.added.is_empty() {
194 CliService::info(&format!("+ {} (on disk, not in DB)", diff.added.len()));
195 for item in &diff.added {
196 let name = item.name.as_deref().unwrap_or("unnamed");
197 CliService::info(&format!(" + {} ({})", item.playbook_id, name));
198 }
199 }
200
201 if !diff.removed.is_empty() {
202 CliService::info(&format!("- {} (in DB, not on disk)", diff.removed.len()));
203 for item in &diff.removed {
204 let name = item.name.as_deref().unwrap_or("unnamed");
205 CliService::info(&format!(" - {} ({})", item.playbook_id, name));
206 }
207 }
208
209 if !diff.modified.is_empty() {
210 CliService::info(&format!("~ {} (modified)", diff.modified.len()));
211 for item in &diff.modified {
212 let name = item.name.as_deref().unwrap_or("unnamed");
213 CliService::info(&format!(" ~ {} ({})", item.playbook_id, name));
214 }
215 }
216}
217
218fn prompt_sync_direction() -> Result<Option<LocalSyncDirection>> {
219 let options = vec![
220 "Sync to database (Disk -> DB)",
221 "Sync to disk (DB -> Disk)",
222 "Cancel",
223 ];
224
225 let selection = Select::with_theme(&ColorfulTheme::default())
226 .with_prompt("Choose sync direction")
227 .items(&options)
228 .default(0)
229 .interact()
230 .context("Failed to get direction selection")?;
231
232 match selection {
233 0 => Ok(Some(LocalSyncDirection::ToDatabase)),
234 1 => Ok(Some(LocalSyncDirection::ToDisk)),
235 _ => Ok(None),
236 }
237}