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