|
| 1 | +use std::collections::HashMap; |
| 2 | + |
| 3 | +use dialoguer::Select; |
| 4 | +use eyre::bail; |
| 5 | +use tracing::{ |
| 6 | + error, |
| 7 | + info, |
| 8 | + warn, |
| 9 | +}; |
| 10 | + |
| 11 | +use super::{ |
| 12 | + Agent, |
| 13 | + McpServerConfig, |
| 14 | +}; |
| 15 | +use crate::cli::agent::{ |
| 16 | + CreateHooks, |
| 17 | + PromptHooks, |
| 18 | +}; |
| 19 | +use crate::cli::chat::cli::hooks::{ |
| 20 | + Hook, |
| 21 | + HookTrigger, |
| 22 | +}; |
| 23 | +use crate::cli::chat::context::ContextConfig; |
| 24 | +use crate::database::settings::Setting; |
| 25 | +use crate::os::Os; |
| 26 | +use crate::util::directories; |
| 27 | + |
| 28 | +pub(in crate::cli::agent) struct ContextMigrate<const S: char> { |
| 29 | + legacy_global_context: Option<ContextConfig>, |
| 30 | + legacy_profiles: HashMap<String, ContextConfig>, |
| 31 | + mcp_servers: Option<McpServerConfig>, |
| 32 | + new_agents: Vec<Agent>, |
| 33 | +} |
| 34 | + |
| 35 | +impl ContextMigrate<'a'> { |
| 36 | + pub async fn scan(os: &Os) -> eyre::Result<ContextMigrate<'b'>> { |
| 37 | + let legacy_global_context_path = directories::chat_global_context_path(os)?; |
| 38 | + let legacy_global_context: Option<ContextConfig> = 'global: { |
| 39 | + let Ok(content) = os.fs.read(&legacy_global_context_path).await else { |
| 40 | + break 'global None; |
| 41 | + }; |
| 42 | + serde_json::from_slice::<ContextConfig>(&content).ok() |
| 43 | + }; |
| 44 | + |
| 45 | + let legacy_profile_path = directories::chat_profiles_dir(os)?; |
| 46 | + let legacy_profiles: HashMap<String, ContextConfig> = 'profiles: { |
| 47 | + let mut profiles = HashMap::<String, ContextConfig>::new(); |
| 48 | + let Ok(mut read_dir) = os.fs.read_dir(&legacy_profile_path).await else { |
| 49 | + break 'profiles profiles; |
| 50 | + }; |
| 51 | + |
| 52 | + // Here we assume every profile is stored under their own folders |
| 53 | + // And that the profile config is in profile_name/context.json |
| 54 | + while let Ok(Some(entry)) = read_dir.next_entry().await { |
| 55 | + let config_file_path = entry.path().join("context.json"); |
| 56 | + if !os.fs.exists(&config_file_path) { |
| 57 | + continue; |
| 58 | + } |
| 59 | + let Some(profile_name) = entry.file_name().to_str().map(|s| s.to_string()) else { |
| 60 | + continue; |
| 61 | + }; |
| 62 | + let Ok(content) = tokio::fs::read_to_string(&config_file_path).await else { |
| 63 | + continue; |
| 64 | + }; |
| 65 | + let Ok(mut context_config) = serde_json::from_str::<ContextConfig>(content.as_str()) else { |
| 66 | + continue; |
| 67 | + }; |
| 68 | + |
| 69 | + // Combine with global context since you can now only choose one agent at a time |
| 70 | + // So this is how we make what is previously global available to every new agent migrated |
| 71 | + if let Some(context) = legacy_global_context.as_ref() { |
| 72 | + context_config.paths.extend(context.paths.clone()); |
| 73 | + context_config.hooks.extend(context.hooks.clone()); |
| 74 | + } |
| 75 | + |
| 76 | + profiles.insert(profile_name.clone(), context_config); |
| 77 | + } |
| 78 | + |
| 79 | + profiles |
| 80 | + }; |
| 81 | + |
| 82 | + let mcp_servers = { |
| 83 | + let config_path = directories::chat_legacy_mcp_config(os)?; |
| 84 | + if os.fs.exists(&config_path) { |
| 85 | + match McpServerConfig::load_from_file(os, config_path).await { |
| 86 | + Ok(config) => Some(config), |
| 87 | + Err(e) => { |
| 88 | + error!("Malformed legacy global mcp config detected: {e}. Skipping mcp migration."); |
| 89 | + None |
| 90 | + }, |
| 91 | + } |
| 92 | + } else { |
| 93 | + None |
| 94 | + } |
| 95 | + }; |
| 96 | + |
| 97 | + if legacy_global_context.is_some() || !legacy_profiles.is_empty() { |
| 98 | + Ok(ContextMigrate { |
| 99 | + legacy_global_context, |
| 100 | + legacy_profiles, |
| 101 | + mcp_servers, |
| 102 | + new_agents: vec![], |
| 103 | + }) |
| 104 | + } else { |
| 105 | + bail!("Nothing to migrate"); |
| 106 | + } |
| 107 | + } |
| 108 | +} |
| 109 | + |
| 110 | +impl ContextMigrate<'b'> { |
| 111 | + pub async fn prompt_migrate(self) -> eyre::Result<ContextMigrate<'c'>> { |
| 112 | + let ContextMigrate { |
| 113 | + legacy_global_context, |
| 114 | + legacy_profiles, |
| 115 | + mcp_servers, |
| 116 | + new_agents, |
| 117 | + } = self; |
| 118 | + |
| 119 | + let labels = vec!["Yes", "No"]; |
| 120 | + let selection: Option<_> = match Select::with_theme(&crate::util::dialoguer_theme()) |
| 121 | + .with_prompt("Legacy profiles detected. Would you like to migrate them?") |
| 122 | + .items(&labels) |
| 123 | + .default(1) |
| 124 | + .interact_on_opt(&dialoguer::console::Term::stdout()) |
| 125 | + { |
| 126 | + Ok(sel) => { |
| 127 | + let _ = crossterm::execute!( |
| 128 | + std::io::stdout(), |
| 129 | + crossterm::style::SetForegroundColor(crossterm::style::Color::Magenta) |
| 130 | + ); |
| 131 | + sel |
| 132 | + }, |
| 133 | + // Ctrl‑C -> Err(Interrupted) |
| 134 | + Err(dialoguer::Error::IO(ref e)) if e.kind() == std::io::ErrorKind::Interrupted => None, |
| 135 | + Err(e) => bail!("Failed to choose an option: {e}"), |
| 136 | + }; |
| 137 | + |
| 138 | + if let Some(0) = selection { |
| 139 | + Ok(ContextMigrate { |
| 140 | + legacy_global_context, |
| 141 | + legacy_profiles, |
| 142 | + mcp_servers, |
| 143 | + new_agents, |
| 144 | + }) |
| 145 | + } else { |
| 146 | + bail!("Aborting migration") |
| 147 | + } |
| 148 | + } |
| 149 | +} |
| 150 | + |
| 151 | +impl ContextMigrate<'c'> { |
| 152 | + pub async fn migrate(self, os: &Os) -> eyre::Result<ContextMigrate<'d'>> { |
| 153 | + const LEGACY_GLOBAL_AGENT_NAME: &str = "migrated_agent_from_global_context"; |
| 154 | + const DEFAULT_DESC: &str = "This is an agent migrated from global context"; |
| 155 | + const PROFILE_DESC: &str = "This is an agent migrated from profile context"; |
| 156 | + |
| 157 | + let ContextMigrate { |
| 158 | + legacy_global_context, |
| 159 | + mut legacy_profiles, |
| 160 | + mcp_servers, |
| 161 | + mut new_agents, |
| 162 | + } = self; |
| 163 | + |
| 164 | + let has_global_context = legacy_global_context.is_some(); |
| 165 | + |
| 166 | + // Migration of global context |
| 167 | + if let Some(context) = legacy_global_context { |
| 168 | + let (create_hooks, prompt_hooks) = |
| 169 | + context |
| 170 | + .hooks |
| 171 | + .into_iter() |
| 172 | + .partition::<HashMap<String, Hook>, _>(|(_, hook)| { |
| 173 | + matches!(hook.trigger, HookTrigger::ConversationStart) |
| 174 | + }); |
| 175 | + |
| 176 | + new_agents.push(Agent { |
| 177 | + name: LEGACY_GLOBAL_AGENT_NAME.to_string(), |
| 178 | + description: Some(DEFAULT_DESC.to_string()), |
| 179 | + path: Some(directories::chat_global_agent_path(os)?.join(format!("{LEGACY_GLOBAL_AGENT_NAME}.json"))), |
| 180 | + included_files: context.paths, |
| 181 | + create_hooks: CreateHooks::Map(create_hooks), |
| 182 | + prompt_hooks: PromptHooks::Map(prompt_hooks), |
| 183 | + mcp_servers: mcp_servers.clone().unwrap_or_default(), |
| 184 | + ..Default::default() |
| 185 | + }); |
| 186 | + } |
| 187 | + |
| 188 | + let global_agent_path = directories::chat_global_agent_path(os)?; |
| 189 | + |
| 190 | + // Migration of profile context |
| 191 | + for (profile_name, context) in legacy_profiles.drain() { |
| 192 | + let (create_hooks, prompt_hooks) = |
| 193 | + context |
| 194 | + .hooks |
| 195 | + .into_iter() |
| 196 | + .partition::<HashMap<String, Hook>, _>(|(_, hook)| { |
| 197 | + matches!(hook.trigger, HookTrigger::ConversationStart) |
| 198 | + }); |
| 199 | + |
| 200 | + new_agents.push(Agent { |
| 201 | + path: Some(global_agent_path.join(format!("{profile_name}.json"))), |
| 202 | + name: profile_name, |
| 203 | + description: Some(PROFILE_DESC.to_string()), |
| 204 | + included_files: context.paths, |
| 205 | + create_hooks: CreateHooks::Map(create_hooks), |
| 206 | + prompt_hooks: PromptHooks::Map(prompt_hooks), |
| 207 | + mcp_servers: mcp_servers.clone().unwrap_or_default(), |
| 208 | + ..Default::default() |
| 209 | + }); |
| 210 | + } |
| 211 | + |
| 212 | + if !os.fs.exists(&global_agent_path) { |
| 213 | + os.fs.create_dir_all(&global_agent_path).await?; |
| 214 | + } |
| 215 | + |
| 216 | + for agent in &new_agents { |
| 217 | + let content = serde_json::to_string_pretty(agent)?; |
| 218 | + if let Some(path) = agent.path.as_ref() { |
| 219 | + info!("Agent {} peristed in path {}", agent.name, path.to_string_lossy()); |
| 220 | + os.fs.write(path, content).await?; |
| 221 | + } else { |
| 222 | + warn!( |
| 223 | + "Agent with name {} does not have path associated and is thus not migrated.", |
| 224 | + agent.name |
| 225 | + ); |
| 226 | + } |
| 227 | + } |
| 228 | + |
| 229 | + let legacy_profile_config_path = directories::chat_profiles_dir(os)?; |
| 230 | + let profile_backup_path = legacy_profile_config_path |
| 231 | + .parent() |
| 232 | + .ok_or(eyre::eyre!("Failed to obtain profile config parent path"))? |
| 233 | + .join("profiles.bak"); |
| 234 | + os.fs.rename(legacy_profile_config_path, profile_backup_path).await?; |
| 235 | + |
| 236 | + if has_global_context { |
| 237 | + let legacy_global_config_path = directories::chat_global_context_path(os)?; |
| 238 | + let legacy_global_config_file_name = legacy_global_config_path |
| 239 | + .file_name() |
| 240 | + .ok_or(eyre::eyre!("Failed to obtain legacy global config name"))? |
| 241 | + .to_string_lossy(); |
| 242 | + let global_context_backup_path = legacy_global_config_path |
| 243 | + .parent() |
| 244 | + .ok_or(eyre::eyre!("Failed to obtain parent path for global context"))? |
| 245 | + .join(format!("{}.bak", legacy_global_config_file_name)); |
| 246 | + os.fs |
| 247 | + .rename(legacy_global_config_path, global_context_backup_path) |
| 248 | + .await?; |
| 249 | + } |
| 250 | + |
| 251 | + Ok(ContextMigrate { |
| 252 | + legacy_global_context: None, |
| 253 | + legacy_profiles, |
| 254 | + mcp_servers: None, |
| 255 | + new_agents, |
| 256 | + }) |
| 257 | + } |
| 258 | +} |
| 259 | + |
| 260 | +impl ContextMigrate<'d'> { |
| 261 | + pub async fn prompt_set_default(self, os: &mut Os) -> eyre::Result<(Option<String>, Vec<Agent>)> { |
| 262 | + let ContextMigrate { new_agents, .. } = self; |
| 263 | + |
| 264 | + let labels = new_agents |
| 265 | + .iter() |
| 266 | + .map(|a| a.name.as_str()) |
| 267 | + .chain(vec!["Let me do this on my own later"]) |
| 268 | + .collect::<Vec<_>>(); |
| 269 | + // This yields 0 if it's negative, which is acceptable. |
| 270 | + let later_idx = labels.len().saturating_sub(1); |
| 271 | + let selection: Option<_> = match Select::with_theme(&crate::util::dialoguer_theme()) |
| 272 | + .with_prompt( |
| 273 | + "Set an agent as default. This is the agent that q chat will launch with unless specified otherwise.", |
| 274 | + ) |
| 275 | + .default(0) |
| 276 | + .items(&labels) |
| 277 | + .interact_on_opt(&dialoguer::console::Term::stdout()) |
| 278 | + { |
| 279 | + Ok(sel) => { |
| 280 | + let _ = crossterm::execute!( |
| 281 | + std::io::stdout(), |
| 282 | + crossterm::style::SetForegroundColor(crossterm::style::Color::Magenta) |
| 283 | + ); |
| 284 | + sel |
| 285 | + }, |
| 286 | + // Ctrl‑C -> Err(Interrupted) |
| 287 | + Err(dialoguer::Error::IO(ref e)) if e.kind() == std::io::ErrorKind::Interrupted => None, |
| 288 | + Err(e) => bail!("Failed to choose an option: {e}"), |
| 289 | + }; |
| 290 | + |
| 291 | + let mut agent_to_load = None::<String>; |
| 292 | + if let Some(i) = selection { |
| 293 | + if later_idx != i { |
| 294 | + if let Some(name) = labels.get(i) { |
| 295 | + if let Ok(value) = serde_json::to_value(name) { |
| 296 | + if os.database.settings.set(Setting::ChatDefaultAgent, value).await.is_ok() { |
| 297 | + let chosen_name = (*name).to_string(); |
| 298 | + agent_to_load.replace(chosen_name); |
| 299 | + } |
| 300 | + } |
| 301 | + } |
| 302 | + } |
| 303 | + } |
| 304 | + |
| 305 | + Ok((agent_to_load, new_agents)) |
| 306 | + } |
| 307 | +} |
0 commit comments