Skip to main content

systemprompt_cli/commands/cloud/sync/
skills.rs

1use super::SkillsSyncArgs;
2use crate::cli_settings::CliConfig;
3use anyhow::{Context, Result};
4use dialoguer::{Confirm, Select};
5use std::sync::Arc;
6use systemprompt_database::{Database, DbPool};
7use systemprompt_logging::CliService;
8use systemprompt_models::{AppPaths, SecretsBootstrap};
9use systemprompt_sync::{LocalSyncDirection, LocalSyncResult, SkillsDiffResult, SkillsLocalSync};
10
11fn get_skills_path() -> Result<std::path::PathBuf> {
12    let paths = AppPaths::get().map_err(|e| anyhow::anyhow!("{}", e))?;
13    Ok(paths.system().skills().to_path_buf())
14}
15
16async fn create_db_provider(database_url: Option<&str>) -> Result<DbPool> {
17    let url = match database_url {
18        Some(url) => url.to_string(),
19        None => SecretsBootstrap::database_url()?.to_string(),
20    };
21
22    let write_url = SecretsBootstrap::database_write_url()
23        .ok()
24        .flatten()
25        .map(str::to_string);
26
27    let database = Database::from_config_with_write("postgres", &url, write_url.as_deref())
28        .await
29        .context("Failed to connect to database")?;
30
31    Ok(Arc::new(database))
32}
33
34pub async fn execute(args: SkillsSyncArgs, config: &CliConfig) -> Result<()> {
35    CliService::section("Skills Sync");
36
37    let spinner = CliService::spinner("Connecting to database...");
38    let db = create_db_provider(args.database_url.as_deref()).await?;
39    spinner.finish_and_clear();
40
41    let skills_path = get_skills_path()?;
42
43    if !skills_path.exists() {
44        anyhow::bail!(
45            "Profile Error: Skills path does not exist\n\n  Path: {}\n  Field: paths.skills\n\n  \
46             To fix: Ensure the path exists or update your profile",
47            skills_path.display()
48        );
49    }
50
51    let sync = SkillsLocalSync::new(Arc::clone(&db), skills_path.clone());
52    let spinner = CliService::spinner("Calculating diff...");
53    let diff = sync
54        .calculate_diff()
55        .await
56        .context("Failed to calculate skills diff")?;
57    spinner.finish_and_clear();
58
59    display_diff_summary(&diff);
60
61    if !diff.has_changes() {
62        CliService::success("Skills are in sync - no changes needed");
63        return Ok(());
64    }
65
66    let direction = match args.direction {
67        Some(crate::cloud::sync::CliLocalSyncDirection::ToDisk) => LocalSyncDirection::ToDisk,
68        Some(crate::cloud::sync::CliLocalSyncDirection::ToDb) => LocalSyncDirection::ToDatabase,
69        None => {
70            if !config.is_interactive() {
71                anyhow::bail!("--direction is required in non-interactive mode");
72            }
73            if let Some(dir) = prompt_sync_direction()? {
74                dir
75            } else {
76                CliService::info("Sync cancelled");
77                return Ok(());
78            }
79        },
80    };
81
82    if args.dry_run {
83        CliService::info("Dry run - no changes made");
84        return Ok(());
85    }
86
87    if !args.yes && config.is_interactive() {
88        let confirmed = Confirm::new()
89            .with_prompt("Proceed with sync?")
90            .default(false)
91            .interact()?;
92
93        if !confirmed {
94            CliService::info("Sync cancelled");
95            return Ok(());
96        }
97    }
98
99    let spinner = CliService::spinner("Syncing skills...");
100    let result = match direction {
101        LocalSyncDirection::ToDisk => sync.sync_to_disk(&diff, args.delete_orphans).await?,
102        LocalSyncDirection::ToDatabase => sync.sync_to_db(&diff, args.delete_orphans).await?,
103    };
104    spinner.finish_and_clear();
105
106    display_sync_result(&result);
107
108    Ok(())
109}
110
111fn display_diff_summary(diff: &SkillsDiffResult) {
112    CliService::section("Skills Status");
113    CliService::info(&format!("{} unchanged", diff.unchanged));
114    if !diff.added.is_empty() {
115        CliService::info(&format!("+ {} (on disk, not in DB)", diff.added.len()));
116        for item in &diff.added {
117            let name = item.name.as_deref().unwrap_or("unnamed");
118            CliService::info(&format!("    + {} ({})", item.skill_id, name));
119        }
120    }
121    if !diff.removed.is_empty() {
122        CliService::info(&format!("- {} (in DB, not on disk)", diff.removed.len()));
123        for item in &diff.removed {
124            let name = item.name.as_deref().unwrap_or("unnamed");
125            CliService::info(&format!("    - {} ({})", item.skill_id, name));
126        }
127    }
128    if !diff.modified.is_empty() {
129        CliService::info(&format!("~ {} (modified)", diff.modified.len()));
130        for item in &diff.modified {
131            let name = item.name.as_deref().unwrap_or("unnamed");
132            CliService::info(&format!("    ~ {} ({})", item.skill_id, name));
133        }
134    }
135}
136
137fn prompt_sync_direction() -> Result<Option<LocalSyncDirection>> {
138    let options = vec![
139        "Sync to disk (DB -> Disk)",
140        "Sync to database (Disk -> DB)",
141        "Cancel",
142    ];
143
144    let selection = Select::new()
145        .with_prompt("Choose sync direction")
146        .items(&options)
147        .default(0)
148        .interact()?;
149
150    match selection {
151        0 => Ok(Some(LocalSyncDirection::ToDisk)),
152        1 => Ok(Some(LocalSyncDirection::ToDatabase)),
153        _ => Ok(None),
154    }
155}
156
157fn display_sync_result(result: &LocalSyncResult) {
158    CliService::section("Sync Complete");
159    CliService::key_value("Direction", &result.direction.to_string());
160    CliService::key_value("Synced", &result.items_synced.to_string());
161    CliService::key_value("Deleted", &result.items_deleted.to_string());
162    CliService::key_value("Skipped", &result.items_skipped.to_string());
163
164    if !result.errors.is_empty() {
165        CliService::warning(&format!("Errors ({})", result.errors.len()));
166        for error in &result.errors {
167            CliService::error(&format!("    {}", error));
168        }
169    }
170}