use alog::{MessageLevel, alog_channel, use_channel};
use anyhow::Result;
use crate::capabilities::{CAPABILITY_REGISTRY, CapabilitySource};
use crate::config::validation::RefKind;
use crate::dependency::Configured;
use crate::launchers::LAUNCHER_REGISTRY;
use crate::utils::prompt_from_schema;
use_channel!("LNCHR");
pub struct LauncherCommands;
impl LauncherCommands {
pub fn catalog(ctx: &crate::AppContext) -> Result<()> {
let launchers = LAUNCHER_REGISTRY.entries();
let mut rows: Vec<Vec<String>> = launchers
.iter()
.map(|(id, l)| vec![id.to_string(), l.default_command.clone()])
.collect();
rows.sort_by(|a, b| a[0].cmp(&b[0]));
ctx.ui.table(
&format!("Launcher Catalog ({} launchers)", launchers.len()),
&["ID", "DEFAULT COMMAND"],
&rows,
);
Ok(())
}
pub fn list(ctx: &crate::AppContext) -> Result<()> {
let notes = crate::commands::shared::remediation::dangling_notes(ctx, RefKind::Launcher);
let mut rows: Vec<Vec<String>> = ctx
.config
.launchers
.iter()
.map(|(id, cfg)| {
let command = cfg
.config
.get("command_path")
.and_then(|v| v.as_str())
.unwrap_or("(PATH)")
.to_string();
vec![
id.clone(),
cfg.launcher_type.clone(),
command,
notes.get(id).cloned().unwrap_or_default(),
]
})
.collect();
rows.sort_by(|a, b| {
let type_cmp = a[1].cmp(&b[1]);
if type_cmp != std::cmp::Ordering::Equal {
return type_cmp;
}
a[0].cmp(&b[0])
});
ctx.ui.table(
&format!("Configured Launchers ({} launchers)", rows.len()),
&["ID", "TYPE", "COMMAND", "NOTES"],
&rows,
);
Ok(())
}
pub fn info(ctx: &crate::AppContext, id: &str) -> Result<()> {
let configured = ctx.config.get_launcher(id);
let metadata = configured
.and_then(|c| LAUNCHER_REGISTRY.get(&c.launcher_type))
.or_else(|| LAUNCHER_REGISTRY.get(id));
match metadata {
Some(md) => {
let mut type_fields: Vec<(&str, String)> = vec![
("Name", md.name.clone()),
("Description", md.description.clone()),
("Default Command", md.default_command.clone()),
];
let mut caps: Vec<_> = md
.supported_capabilities
.iter()
.map(|c| c.to_string())
.collect();
if !caps.is_empty() {
caps.sort();
type_fields.push(("Supported Capabilities", caps.join(", ")));
}
if !md.tags.is_empty() {
type_fields.push(("Tags", md.tags.join(", ")));
}
ctx.ui.detail("Type Metadata", &type_fields);
if let Some(cfg) = configured {
let mut instance_fields: Vec<(&str, String)> = Vec::new();
instance_fields.push(("Config: Type", cfg.launcher_type.clone()));
if !cfg.enabled_capabilities.is_empty() {
instance_fields
.push(("Enabled Capabilities", cfg.enabled_capabilities.join(", ")));
}
if let Some(obj) = cfg.config.as_object() {
for (k, v) in obj {
instance_fields.push(("Config", format!("{k} = {v}")))
}
}
ctx.ui.detail(id, &instance_fields);
}
Ok(())
}
None => {
if configured.is_some() {
let fields: Vec<(&str, String)> = vec![(
"Note",
"Configured but its type is not found in the bundled registry".to_string(),
)];
ctx.ui.detail(id, &fields);
Ok(())
} else {
ctx.ui
.info(&format!("Launcher '{id}' not found in registry."));
let available: Vec<_> = crate::launchers::LAUNCHER_REGISTRY
.entries()
.keys()
.map(|k| k.to_string())
.collect();
ctx.ui
.info(&format!("Available launchers: {}", available.join(", ")));
anyhow::bail!("Launcher not found");
}
}
}
}
pub async fn setup(
ctx: &mut crate::AppContext,
launcher_type: &str,
instance_id: Option<&str>,
) -> Result<()> {
let launcher_def = match LAUNCHER_REGISTRY.get(launcher_type) {
Some(def) => def,
None => {
ctx.ui.error(&format!(
"Launcher type '{launcher_type}' not found in registry."
));
let available: Vec<String> = {
let mut entries: Vec<String> = LAUNCHER_REGISTRY
.entries()
.iter()
.map(|(id, l)| format!("{} ({})", id, l.name))
.collect();
entries.sort();
entries
};
ctx.ui
.info(&format!("Available types: {}", available.join(", ")));
anyhow::bail!("Launcher type not found");
}
};
ctx.ui
.info(&format!("\nSetting up launcher: {launcher_type}"));
ctx.ui.info(&launcher_def.description);
ctx.ui.info(&format!(
"Default command: {} (leave command_path blank to use PATH lookup)",
launcher_def.default_command
));
let instance_id = match instance_id {
Some(id) => id.to_string(),
None => ctx.ui.text("Instance name: ", launcher_type)?,
};
let same_type_existing: Vec<String> = ctx
.config
.launchers
.values()
.filter(|lc| lc.launcher_type == launcher_type && lc.launcher_id != instance_id)
.map(|lc| lc.launcher_id.clone())
.collect();
let instance_id = if !same_type_existing.is_empty() {
ctx.ui.info(&format!(
"\nNote: a launcher of type '{}' already exists: {}",
launcher_type,
same_type_existing.join(", ")
));
let update_existing = ctx.ui.confirm(
&format!(
"Update '{}' instead of creating '{}'?",
same_type_existing[0], instance_id
),
false,
)?;
if update_existing {
same_type_existing[0].clone()
} else {
instance_id
}
} else {
instance_id
};
if ctx.config.get_launcher(&instance_id).is_some() {
let overwrite = ctx.ui.confirm(
&format!("Launcher '{instance_id}' is already configured. Overwrite?"),
false,
)?;
if !overwrite {
ctx.ui.info("Launcher setup skipped.");
return Ok(());
}
}
let schema = LAUNCHER_REGISTRY
.config_schema(launcher_type)
.ok_or_else(|| {
anyhow::anyhow!("No config schema registered for launcher type '{launcher_type}'")
})?;
let defaults = ctx
.config
.get_launcher(&instance_id)
.map(|lc| lc.config.clone())
.or_else(|| LAUNCHER_REGISTRY.default_config(launcher_type))
.unwrap_or_else(|| serde_json::json!({}));
alog_channel!(MessageLevel::Debug3, "Defaults: {:#?}", defaults);
let config = prompt_from_schema(&*ctx.ui, &schema, &defaults)?;
let launcher = LAUNCHER_REGISTRY
.construct(launcher_type, &instance_id, &config, &ctx.config)
.map_err(|e| anyhow::anyhow!("Failed to construct launcher: {e}"))?;
match launcher.validate_command() {
Ok(path) => {
ctx.ui.info(&format!(" Binary found: {}", path.display()));
}
Err(e) => {
anyhow::bail!(
"Binary validation failed: {e}\n\
Set command_path to the full path of the binary and re-run setup."
);
}
}
let previously_enabled: Vec<String> = ctx
.config
.get_launcher(&instance_id)
.map(|lc| lc.enabled_capabilities.clone())
.unwrap_or_default();
let enabled_capabilities =
select_capabilities(ctx, &launcher_def, &previously_enabled).await?;
let launcher_config = crate::config::LauncherConfig {
launcher_id: instance_id.clone(),
launcher_type: launcher_type.to_string(),
enabled_capabilities,
config,
};
if let Err(e) = ctx.config.insert_launcher(&instance_id, launcher_config) {
ctx.ui.warn(&format!("Failed to save launcher config: {e}"));
}
ctx.ui.info(&format!(
"\nLauncher '{instance_id}' configured successfully!"
));
if !launcher_def.supported_capabilities.is_empty() {
ctx.ui.info("Supported capabilities:");
for cap in &launcher_def.supported_capabilities {
ctx.ui.info(&format!(" - {cap}"));
}
}
Ok(())
}
pub async fn prelaunch(ctx: &mut crate::AppContext, launcher_id: &str) -> Result<()> {
let outcome = crate::commands::shared::remediation::remediate(
ctx,
RefKind::Launcher,
launcher_id,
crate::commands::shared::remediation::OnDecline::Abort,
true,
)
.await?;
if outcome == crate::commands::shared::remediation::Outcome::Unresolved {
anyhow::bail!(
"Launch aborted: launcher '{launcher_id}' has a configuration problem \
that was not fixed."
);
}
Ok(())
}
pub fn remove(ctx: &mut crate::AppContext, launcher_id: &str) -> Result<()> {
if ctx.config.get_launcher(launcher_id).is_none() {
anyhow::bail!("No launcher configured with id '{launcher_id}'. Nothing to remove.");
}
match crate::commands::shared::remediation::confirm_removal(
ctx,
RefKind::Launcher,
launcher_id,
)? {
crate::commands::shared::remediation::Removal::Cancel => {
ctx.ui.info(&format!("Keeping launcher '{launcher_id}'."));
return Ok(());
}
crate::commands::shared::remediation::Removal::Proceed { with } => {
crate::commands::shared::remediation::remove_all(ctx, &with)?;
}
}
if let Err(e) = ctx.config.remove_launcher(launcher_id) {
ctx.ui
.warn(&format!("failed to persist launcher removal: {e}"));
}
ctx.ui.info(&format!("Launcher '{launcher_id}' removed."));
Ok(())
}
}
async fn select_capabilities(
ctx: &mut crate::AppContext,
launcher_def: &crate::launchers::LauncherMetadata,
previously_enabled: &[String],
) -> Result<Vec<String>> {
if launcher_def.supported_capabilities.is_empty() {
ctx.ui.warn(
"This launcher does not support any capabilities. \
No capabilities will be enabled.",
);
return Ok(vec![]);
}
let mut enabled: Vec<String> = previously_enabled.to_vec();
let mut result: Vec<String> = vec![];
let mut announced = false;
loop {
let source = CapabilitySource::from_config(&ctx.config);
let mut compatible_instances: Vec<String> = source
.instances()
.into_iter()
.filter(|(_, cap)| {
cap.binding_types()
.iter()
.any(|bt| launcher_def.supported_capabilities.contains(bt))
})
.map(|(id, _)| id)
.collect();
compatible_instances.sort();
let carried: Vec<String> = previously_enabled
.iter()
.filter(|id| !compatible_instances.contains(id))
.cloned()
.collect();
if !carried.is_empty() && !announced {
ctx.ui.warn(&format!(
"Left enabled but not listed below: {}. Each one is missing or \
has a reference that does not resolve.",
carried.join(", ")
));
announced = true;
}
let compatible_types: Vec<&'static str> = {
let mut types: Vec<&'static str> = CAPABILITY_REGISTRY
.entries()
.into_iter()
.filter(|(_, meta)| {
meta.supported_binding_types
.iter()
.any(|bt| launcher_def.supported_capabilities.contains(bt))
})
.map(|(name, _)| name)
.collect();
types.sort();
types
};
if compatible_instances.is_empty() && compatible_types.is_empty() {
ctx.ui
.info("No compatible capabilities are configured or available for this launcher.");
return Ok(vec![]);
}
const CONFIGURE_NEW: &str = "Configure a new capability...";
let mut items: Vec<String> = compatible_instances.clone();
if !compatible_types.is_empty() {
items.push(CONFIGURE_NEW.to_string());
}
let defaults: Vec<bool> = items
.iter()
.map(|item| item != CONFIGURE_NEW && enabled.contains(item))
.collect();
let selected = ctx
.ui
.multi_select("Select capabilities to enable", &items, &defaults)?;
result.clear();
let mut configure_new_chosen = false;
for idx in selected {
if items[idx] == CONFIGURE_NEW {
configure_new_chosen = true;
} else {
result.push(items[idx].clone());
}
}
if !configure_new_chosen {
for id in carried {
if !result.contains(&id) {
result.push(id);
}
}
break;
}
let cap_type = if compatible_types.len() == 1 {
compatible_types[0]
} else {
let type_options: Vec<String> =
compatible_types.iter().map(|s| s.to_string()).collect();
let idx = ctx
.ui
.select("Select a capability type to configure", &type_options, 0)?;
compatible_types[idx]
};
let nickname = ctx.ui.text("Name this capability instance", cap_type)?;
crate::commands::CapabilityCommands::setup(ctx, cap_type, Some(&nickname)).await?;
enabled.push(nickname.clone());
result.push(nickname);
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{Config, LauncherConfig};
use crate::utils::ui::base::tests::CaptureUi;
use std::sync::Arc;
fn test_ctx() -> crate::AppContext {
crate::AppContext {
config: Config::default(),
ui: Arc::new(CaptureUi::default()),
}
}
fn ctx_with_launcher(id: &str, launcher_type: &str) -> crate::AppContext {
let mut ctx = test_ctx();
ctx.config.launchers.insert(
id.to_string(),
LauncherConfig {
launcher_id: id.to_string(),
launcher_type: launcher_type.to_string(),
..LauncherConfig::default()
},
);
ctx
}
macro_rules! tables {
($ctx:expr) => {
(&*($ctx.ui) as &dyn std::any::Any)
.downcast_ref::<CaptureUi>()
.unwrap()
.tables
.borrow()
};
}
macro_rules! details {
($ctx:expr) => {
(&*($ctx.ui) as &dyn std::any::Any)
.downcast_ref::<CaptureUi>()
.unwrap()
.details
.borrow()
};
}
macro_rules! infos {
($ctx:expr) => {
(&*($ctx.ui) as &dyn std::any::Any)
.downcast_ref::<CaptureUi>()
.unwrap()
.infos
.borrow()
};
}
#[test]
fn catalog_has_id_and_default_command_columns() {
let ctx = test_ctx();
LauncherCommands::catalog(&ctx).unwrap();
let tables = tables!(ctx);
assert_eq!(tables.len(), 1);
let (_, headers, _) = &tables[0];
assert!(headers.contains(&"ID".to_string()));
assert!(headers.contains(&"DEFAULT COMMAND".to_string()));
}
#[test]
fn catalog_contains_claude_bob_pi_and_opencode() {
let ctx = test_ctx();
LauncherCommands::catalog(&ctx).unwrap();
let tables = tables!(ctx);
let (_, _, rows) = &tables[0];
assert!(rows.iter().any(|r| r[0] == "claude"));
assert!(rows.iter().any(|r| r[0] == "bob"));
assert!(rows.iter().any(|r| r[0] == "pi"));
assert!(rows.iter().any(|r| r[0] == "opencode"));
}
#[test]
fn list_empty_config_has_zero_rows() {
let ctx = test_ctx();
LauncherCommands::list(&ctx).unwrap();
let tables = tables!(ctx);
let (_, _, rows) = &tables[0];
assert_eq!(rows.len(), 0);
}
#[test]
fn list_configured_launcher_shows_path_sentinel() {
let ctx = ctx_with_launcher("my-claude", "claude");
LauncherCommands::list(&ctx).unwrap();
let tables = tables!(ctx);
let (_, _, rows) = &tables[0];
assert_eq!(rows.len(), 1);
assert!(rows[0].iter().any(|c| c == "(PATH)"));
}
#[test]
fn list_columns_are_id_type_command() {
let ctx = ctx_with_launcher("my-claude", "claude");
LauncherCommands::list(&ctx).unwrap();
let tables = tables!(ctx);
let (_, headers, _) = &tables[0];
assert!(headers.contains(&"ID".to_string()));
assert!(headers.contains(&"TYPE".to_string()));
assert!(!headers.contains(&"ENABLED".to_string()));
assert!(headers.contains(&"COMMAND".to_string()));
}
#[test]
fn list_sorted_by_type_then_id() {
let mut ctx = test_ctx();
for (id, t) in [
("z-claude", "claude"),
("a-claude", "claude"),
("my-bob", "bob"),
] {
ctx.config.launchers.insert(
id.to_string(),
LauncherConfig {
launcher_id: id.to_string(),
launcher_type: t.to_string(),
..LauncherConfig::default()
},
);
}
LauncherCommands::list(&ctx).unwrap();
let tables = tables!(ctx);
let (_, _, rows) = &tables[0];
assert_eq!(rows[0][1], "bob");
assert_eq!(rows[1][0], "a-claude");
assert_eq!(rows[2][0], "z-claude");
}
#[test]
fn info_unknown_launcher_returns_err() {
let ctx = test_ctx();
let result = LauncherCommands::info(&ctx, "does-not-exist");
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Launcher not found")
);
}
#[test]
fn info_catalog_launcher_renders_detail() {
let ctx = test_ctx();
let result = LauncherCommands::info(&ctx, "claude");
assert!(result.is_ok());
let details = details!(ctx);
assert_eq!(details.len(), 1);
let (id, fields) = &details[0];
assert_eq!(id, "Type Metadata");
assert!(fields.iter().any(|(k, _)| *k == "Name"));
assert!(!fields.iter().any(|(k, _)| k.starts_with("Config")));
}
#[test]
fn info_configured_launcher_renders_detail_with_config() {
let mut ctx = ctx_with_launcher("my-claude", "claude");
if let Some(cfg) = ctx.config.launchers.get_mut("my-claude") {
cfg.enabled_capabilities = vec!["chat".to_string(), "plan".to_string()];
}
let result = LauncherCommands::info(&ctx, "my-claude");
assert!(result.is_ok());
let details = details!(ctx);
assert_eq!(details.len(), 2);
let (id1, fields1) = &details[0];
assert_eq!(id1, "Type Metadata");
assert!(fields1.iter().any(|(k, _)| *k == "Name"));
let (id2, fields2) = &details[1];
assert_eq!(id2, "my-claude");
assert!(
fields2
.iter()
.any(|(k, v)| *k == "Config: Type" && v == "claude")
);
assert!(
fields2
.iter()
.any(|(k, v)| *k == "Enabled Capabilities" && v == "chat, plan")
);
}
#[test]
fn info_configured_unknown_type_renders_note() {
let mut ctx = test_ctx();
ctx.config.launchers.insert(
"custom-launcher".to_string(),
LauncherConfig {
launcher_id: "custom-launcher".to_string(),
launcher_type: "not-a-real-type".to_string(),
..LauncherConfig::default()
},
);
let result = LauncherCommands::info(&ctx, "custom-launcher");
assert!(result.is_ok());
let details = details!(ctx);
assert_eq!(details.len(), 1);
let (id, fields) = &details[0];
assert_eq!(id, "custom-launcher");
assert!(
fields
.iter()
.any(|(k, v)| *k == "Note" && v.contains("not found in the bundled registry"))
);
}
#[tokio::test]
async fn setup_warns_on_same_type_existing_instance() {
let _home = crate::config::TestConfigHome::new();
let mut ctx = ctx_with_launcher("claude-old", "claude");
let _ = LauncherCommands::setup(&mut ctx, "claude", Some("claude-new")).await;
let infos = infos!(ctx);
assert!(
infos.iter().any(|m| m.contains("claude-old")),
"expected clash warning to mention the existing instance"
);
}
#[tokio::test]
async fn setup_unknown_type_returns_err() {
let mut ctx = test_ctx();
let result = LauncherCommands::setup(&mut ctx, "no-such-type", Some("test")).await;
assert!(result.is_err());
}
#[test]
fn remove_existing_launcher_succeeds_and_disappears_from_list() {
let _home = crate::config::TestConfigHome::new();
let mut ctx = ctx_with_launcher("my-claude", "claude");
assert!(ctx.config.get_launcher("my-claude").is_some());
LauncherCommands::remove(&mut ctx, "my-claude").unwrap();
assert!(ctx.config.get_launcher("my-claude").is_none());
let infos = infos!(ctx);
assert!(
infos
.iter()
.any(|m| m.contains("my-claude") && m.contains("removed"))
);
}
#[test]
fn remove_nonexistent_launcher_returns_err() {
let mut ctx = test_ctx();
let result = LauncherCommands::remove(&mut ctx, "doesnt-exist");
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Nothing to remove")
);
}
#[test]
fn list_does_not_show_removed_launcher() {
let _home = crate::config::TestConfigHome::new();
let mut ctx = ctx_with_launcher("my-claude", "claude");
LauncherCommands::remove(&mut ctx, "my-claude").unwrap();
LauncherCommands::list(&ctx).unwrap();
let tables = tables!(ctx);
let (_, _, rows) = &tables[0];
assert!(rows.is_empty());
}
macro_rules! capture_ui {
($ctx:expr) => {
(&*($ctx.ui) as &dyn std::any::Any)
.downcast_ref::<CaptureUi>()
.unwrap()
};
}
fn claude_launcher_def() -> crate::launchers::LauncherMetadata {
crate::launchers::LAUNCHER_REGISTRY
.get("claude")
.unwrap()
.clone()
}
fn bob_launcher_def() -> crate::launchers::LauncherMetadata {
crate::launchers::LAUNCHER_REGISTRY
.get("bob")
.unwrap()
.clone()
}
fn no_capabilities_launcher_def() -> crate::launchers::LauncherMetadata {
crate::launchers::LauncherMetadata {
supported_capabilities: std::collections::HashSet::new(),
..bob_launcher_def()
}
}
fn add_capability(ctx: &mut crate::AppContext, cap_id: &str, model_id: &str) {
ctx.config.providers.insert(
"ollama".to_string(),
crate::config::ProviderConfig {
provider_id: "ollama".to_string(),
provider_type: "ollama".to_string(),
config: serde_json::json!({}),
},
);
ctx.config.models.insert(
model_id.to_string(),
crate::config::ModelConfig {
model_id: model_id.to_string(),
model_type: model_id.to_string(),
config: serde_json::json!({}),
provider_id: "ollama".to_string(),
variant: None,
},
);
ctx.config.capabilities.insert(
cap_id.to_string(),
crate::config::CapabilityConfig {
capability_id: cap_id.to_string(),
capability_type: "agent-model".to_string(),
config: serde_json::json!({ "model_id": model_id }),
},
);
}
#[tokio::test]
async fn select_capabilities_warns_and_skips_for_launcher_with_no_supported_capabilities() {
let mut ctx = test_ctx();
let launcher_def = no_capabilities_launcher_def();
let result = select_capabilities(&mut ctx, &launcher_def, &[])
.await
.unwrap();
assert!(result.is_empty());
let ui = capture_ui!(ctx);
assert!(
ui.warns
.borrow()
.iter()
.any(|w| w.contains("does not support any capabilities")),
"expected a warning about no supported capabilities"
);
assert!(
ui.multi_select_prompts.borrow().is_empty(),
"multi_select should not be called for a launcher with no supported capabilities"
);
}
#[tokio::test]
async fn select_capabilities_returns_empty_when_no_compatible_capabilities_exist() {
let mut ctx = test_ctx();
let launcher_def = claude_launcher_def();
let result = select_capabilities(&mut ctx, &launcher_def, &[])
.await
.unwrap();
assert!(result.is_empty());
let ui = capture_ui!(ctx);
let prompts = ui.multi_select_prompts.borrow();
assert_eq!(prompts.len(), 1);
assert!(
prompts[0]
.1
.iter()
.any(|i| i == "Configure a new capability...")
);
}
#[tokio::test]
async fn select_capabilities_shows_configured_compatible_instance() {
let mut ctx = test_ctx();
add_capability(&mut ctx, "my-agent", "granite-3.1-8b-instruct");
let launcher_def = claude_launcher_def();
let result = select_capabilities(&mut ctx, &launcher_def, &[])
.await
.unwrap();
assert!(result.is_empty()); let ui = capture_ui!(ctx);
let prompts = ui.multi_select_prompts.borrow();
assert_eq!(prompts.len(), 1);
assert!(
prompts[0].1.contains(&"my-agent".to_string()),
"expected instance id in items"
);
}
#[tokio::test]
async fn select_capabilities_carries_through_an_id_it_cannot_offer() {
let mut ctx = test_ctx();
add_capability(&mut ctx, "my-agent", "granite-3.1-8b-instruct");
let launcher_def = claude_launcher_def();
{
let ui = capture_ui!(ctx);
ui.multi_select_answers.borrow_mut().push_back(vec![0]);
}
let previously_enabled = vec!["my-agent".to_string(), "gone".to_string()];
let result = select_capabilities(&mut ctx, &launcher_def, &previously_enabled)
.await
.unwrap();
assert!(
result.contains(&"gone".to_string()),
"editing a launcher must not disable what it could not offer: {result:?}"
);
let ui = capture_ui!(ctx);
let items = &ui.multi_select_prompts.borrow()[0].1;
assert!(!items.contains(&"gone".to_string()), "{items:?}");
assert!(
ui.warns.borrow().iter().any(|w| w.contains("gone")),
"the carried id is named: {:?}",
ui.warns.borrow()
);
}
#[tokio::test]
async fn select_capabilities_pre_checks_previously_enabled_ids() {
let mut ctx = test_ctx();
add_capability(&mut ctx, "my-agent", "granite-3.1-8b-instruct");
let launcher_def = claude_launcher_def();
let previously_enabled = vec!["my-agent".to_string()];
let _ = select_capabilities(&mut ctx, &launcher_def, &previously_enabled)
.await
.unwrap();
let ui = capture_ui!(ctx);
let prompts = ui.multi_select_prompts.borrow();
assert_eq!(prompts.len(), 1);
let idx = prompts[0]
.1
.iter()
.position(|i| i == "my-agent")
.expect("my-agent should be in items");
assert!(
prompts[0].2[idx],
"my-agent should be pre-checked as it was previously enabled"
);
}
#[tokio::test]
async fn select_capabilities_returns_selected_instance_id() {
let mut ctx = test_ctx();
add_capability(&mut ctx, "my-agent", "granite-3.1-8b-instruct");
let launcher_def = claude_launcher_def();
{
let ui = capture_ui!(ctx);
ui.multi_select_answers.borrow_mut().push_back(vec![0]);
}
let result = select_capabilities(&mut ctx, &launcher_def, &[])
.await
.unwrap();
assert_eq!(result, vec!["my-agent".to_string()]);
}
}