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