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