Files
claw-code/rust/crates/commands/src/lib.rs
2026-04-01 06:50:18 +00:00

853 lines
30 KiB
Rust

use plugins::{PluginError, PluginKind, PluginManager, PluginSummary};
use runtime::{compact_session, CompactionConfig, Session};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommandManifestEntry {
pub name: String,
pub source: CommandSource,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommandSource {
Builtin,
InternalOnly,
FeatureGated,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CommandRegistry {
entries: Vec<CommandManifestEntry>,
}
impl CommandRegistry {
#[must_use]
pub fn new(entries: Vec<CommandManifestEntry>) -> Self {
Self { entries }
}
#[must_use]
pub fn entries(&self) -> &[CommandManifestEntry] {
&self.entries
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SlashCommandSpec {
pub name: &'static str,
pub summary: &'static str,
pub argument_hint: Option<&'static str>,
pub resume_supported: bool,
}
const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
SlashCommandSpec {
name: "help",
summary: "Show available slash commands",
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "status",
summary: "Show current session status",
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "compact",
summary: "Compact local session history",
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "model",
summary: "Show or switch the active model",
argument_hint: Some("[model]"),
resume_supported: false,
},
SlashCommandSpec {
name: "permissions",
summary: "Show or switch the active permission mode",
argument_hint: Some("[read-only|workspace-write|danger-full-access]"),
resume_supported: false,
},
SlashCommandSpec {
name: "clear",
summary: "Start a fresh local session",
argument_hint: Some("[--confirm]"),
resume_supported: true,
},
SlashCommandSpec {
name: "cost",
summary: "Show cumulative token usage for this session",
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "resume",
summary: "Load a saved session into the REPL",
argument_hint: Some("<session-path>"),
resume_supported: false,
},
SlashCommandSpec {
name: "config",
summary: "Inspect Claude config files or merged sections",
argument_hint: Some("[env|hooks|model|plugins]"),
resume_supported: true,
},
SlashCommandSpec {
name: "memory",
summary: "Inspect loaded Claude instruction memory files",
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "init",
summary: "Create a starter CLAUDE.md for this repo",
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "diff",
summary: "Show git diff for current workspace changes",
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "version",
summary: "Show CLI version and build information",
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "export",
summary: "Export the current conversation to a file",
argument_hint: Some("[file]"),
resume_supported: true,
},
SlashCommandSpec {
name: "session",
summary: "List or switch managed local sessions",
argument_hint: Some("[list|switch <session-id>]"),
resume_supported: false,
},
SlashCommandSpec {
name: "plugins",
summary: "List or manage plugins",
argument_hint: Some(
"[list|install <source>|enable <id>|disable <id>|uninstall <id>|update <id>]",
),
resume_supported: false,
},
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SlashCommand {
Help,
Status,
Compact,
Model {
model: Option<String>,
},
Permissions {
mode: Option<String>,
},
Clear {
confirm: bool,
},
Cost,
Resume {
session_path: Option<String>,
},
Config {
section: Option<String>,
},
Memory,
Init,
Diff,
Version,
Export {
path: Option<String>,
},
Session {
action: Option<String>,
target: Option<String>,
},
Plugins {
action: Option<String>,
target: Option<String>,
},
Unknown(String),
}
impl SlashCommand {
#[must_use]
pub fn parse(input: &str) -> Option<Self> {
let trimmed = input.trim();
if !trimmed.starts_with('/') {
return None;
}
let mut parts = trimmed.trim_start_matches('/').split_whitespace();
let command = parts.next().unwrap_or_default();
Some(match command {
"help" => Self::Help,
"status" => Self::Status,
"compact" => Self::Compact,
"model" => Self::Model {
model: parts.next().map(ToOwned::to_owned),
},
"permissions" => Self::Permissions {
mode: parts.next().map(ToOwned::to_owned),
},
"clear" => Self::Clear {
confirm: parts.next() == Some("--confirm"),
},
"cost" => Self::Cost,
"resume" => Self::Resume {
session_path: parts.next().map(ToOwned::to_owned),
},
"config" => Self::Config {
section: parts.next().map(ToOwned::to_owned),
},
"memory" => Self::Memory,
"init" => Self::Init,
"diff" => Self::Diff,
"version" => Self::Version,
"export" => Self::Export {
path: parts.next().map(ToOwned::to_owned),
},
"session" => Self::Session {
action: parts.next().map(ToOwned::to_owned),
target: parts.next().map(ToOwned::to_owned),
},
"plugins" => Self::Plugins {
action: parts.next().map(ToOwned::to_owned),
target: {
let remainder = parts.collect::<Vec<_>>().join(" ");
(!remainder.is_empty()).then_some(remainder)
},
},
other => Self::Unknown(other.to_string()),
})
}
}
#[must_use]
pub fn slash_command_specs() -> &'static [SlashCommandSpec] {
SLASH_COMMAND_SPECS
}
#[must_use]
pub fn resume_supported_slash_commands() -> Vec<&'static SlashCommandSpec> {
slash_command_specs()
.iter()
.filter(|spec| spec.resume_supported)
.collect()
}
#[must_use]
pub fn render_slash_command_help() -> String {
let mut lines = vec![
"Slash commands".to_string(),
" [resume] means the command also works with --resume SESSION.json".to_string(),
];
for spec in slash_command_specs() {
let name = match spec.argument_hint {
Some(argument_hint) => format!("/{} {}", spec.name, argument_hint),
None => format!("/{}", spec.name),
};
let resume = if spec.resume_supported {
" [resume]"
} else {
""
};
lines.push(format!(" {name:<20} {}{}", spec.summary, resume));
}
lines.join("\n")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SlashCommandResult {
pub message: String,
pub session: Session,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginsCommandResult {
pub message: String,
pub reload_runtime: bool,
}
pub fn handle_plugins_slash_command(
action: Option<&str>,
target: Option<&str>,
manager: &mut PluginManager,
) -> Result<PluginsCommandResult, PluginError> {
match action {
None | Some("list") => Ok(PluginsCommandResult {
message: render_plugins_report(&manager.list_installed_plugins()?),
reload_runtime: false,
}),
Some("install") => {
let Some(target) = target else {
return Ok(PluginsCommandResult {
message: "Usage: /plugins install <path>".to_string(),
reload_runtime: false,
});
};
let install = manager.install(target)?;
let plugin = manager
.list_installed_plugins()?
.into_iter()
.find(|plugin| plugin.metadata.id == install.plugin_id);
Ok(PluginsCommandResult {
message: render_plugin_install_report(&install.plugin_id, plugin.as_ref()),
reload_runtime: true,
})
}
Some("enable") => {
let Some(target) = target else {
return Ok(PluginsCommandResult {
message: "Usage: /plugins enable <name>".to_string(),
reload_runtime: false,
});
};
let plugin = resolve_plugin_target(manager, target)?;
manager.enable(&plugin.metadata.id)?;
Ok(PluginsCommandResult {
message: format!(
"Plugins\n Result enabled {}\n Name {}\n Version {}\n Status enabled",
plugin.metadata.id, plugin.metadata.name, plugin.metadata.version
),
reload_runtime: true,
})
}
Some("disable") => {
let Some(target) = target else {
return Ok(PluginsCommandResult {
message: "Usage: /plugins disable <name>".to_string(),
reload_runtime: false,
});
};
let plugin = resolve_plugin_target(manager, target)?;
manager.disable(&plugin.metadata.id)?;
Ok(PluginsCommandResult {
message: format!(
"Plugins\n Result disabled {}\n Name {}\n Version {}\n Status disabled",
plugin.metadata.id, plugin.metadata.name, plugin.metadata.version
),
reload_runtime: true,
})
}
Some("uninstall") => {
let Some(target) = target else {
return Ok(PluginsCommandResult {
message: "Usage: /plugins uninstall <plugin-id>".to_string(),
reload_runtime: false,
});
};
manager.uninstall(target)?;
Ok(PluginsCommandResult {
message: format!("Plugins\n Result uninstalled {target}"),
reload_runtime: true,
})
}
Some("update") => {
let Some(target) = target else {
return Ok(PluginsCommandResult {
message: "Usage: /plugins update <plugin-id>".to_string(),
reload_runtime: false,
});
};
let update = manager.update(target)?;
let plugin = manager
.list_installed_plugins()?
.into_iter()
.find(|plugin| plugin.metadata.id == update.plugin_id);
Ok(PluginsCommandResult {
message: format!(
"Plugins\n Result updated {}\n Name {}\n Old version {}\n New version {}\n Status {}",
update.plugin_id,
plugin
.as_ref()
.map_or_else(|| update.plugin_id.clone(), |plugin| plugin.metadata.name.clone()),
update.old_version,
update.new_version,
plugin
.as_ref()
.map_or("unknown", |plugin| if plugin.enabled { "enabled" } else { "disabled" }),
),
reload_runtime: true,
})
}
Some(other) => Ok(PluginsCommandResult {
message: format!(
"Unknown /plugins action '{other}'. Use list, install, enable, disable, uninstall, or update."
),
reload_runtime: false,
}),
}
}
#[must_use]
pub fn render_plugins_report(plugins: &[PluginSummary]) -> String {
let mut lines = vec!["Plugins".to_string()];
if plugins.is_empty() {
lines.push(" No plugins installed.".to_string());
return lines.join("\n");
}
for plugin in plugins {
let kind = match plugin.metadata.kind {
PluginKind::Builtin => "builtin",
PluginKind::Bundled => "bundled",
PluginKind::External => "external",
};
let enabled = if plugin.enabled { "enabled" } else { "disabled" };
lines.push(format!(
" {id:<24} {kind:<8} {enabled:<8} v{version}",
id = plugin.metadata.id,
version = plugin.metadata.version,
));
}
lines.join("\n")
}
fn render_plugin_install_report(plugin_id: &str, plugin: Option<&PluginSummary>) -> String {
let name = plugin.map_or(plugin_id, |plugin| plugin.metadata.name.as_str());
let version = plugin.map_or("unknown", |plugin| plugin.metadata.version.as_str());
let enabled = plugin.is_some_and(|plugin| plugin.enabled);
format!(
"Plugins\n Result installed {plugin_id}\n Name {name}\n Version {version}\n Status {}",
if enabled { "enabled" } else { "disabled" }
)
}
fn resolve_plugin_target(
manager: &PluginManager,
target: &str,
) -> Result<PluginSummary, PluginError> {
let mut matches = manager
.list_installed_plugins()?
.into_iter()
.filter(|plugin| plugin.metadata.id == target || plugin.metadata.name == target)
.collect::<Vec<_>>();
match matches.len() {
1 => Ok(matches.remove(0)),
0 => Err(PluginError::NotFound(format!(
"plugin `{target}` is not installed or discoverable"
))),
_ => Err(PluginError::InvalidManifest(format!(
"plugin name `{target}` is ambiguous; use the full plugin id"
))),
}
}
#[must_use]
pub fn handle_slash_command(
input: &str,
session: &Session,
compaction: CompactionConfig,
) -> Option<SlashCommandResult> {
match SlashCommand::parse(input)? {
SlashCommand::Compact => {
let result = compact_session(session, compaction);
let message = if result.removed_message_count == 0 {
"Compaction skipped: session is below the compaction threshold.".to_string()
} else {
format!(
"Compacted {} messages into a resumable system summary.",
result.removed_message_count
)
};
Some(SlashCommandResult {
message,
session: result.compacted_session,
})
}
SlashCommand::Help => Some(SlashCommandResult {
message: render_slash_command_help(),
session: session.clone(),
}),
SlashCommand::Status
| SlashCommand::Model { .. }
| SlashCommand::Permissions { .. }
| SlashCommand::Clear { .. }
| SlashCommand::Cost
| SlashCommand::Resume { .. }
| SlashCommand::Config { .. }
| SlashCommand::Memory
| SlashCommand::Init
| SlashCommand::Diff
| SlashCommand::Version
| SlashCommand::Export { .. }
| SlashCommand::Session { .. }
| SlashCommand::Plugins { .. }
| SlashCommand::Unknown(_) => None,
}
}
#[cfg(test)]
mod tests {
use super::{
handle_plugins_slash_command, handle_slash_command, render_plugins_report,
render_slash_command_help, resume_supported_slash_commands, slash_command_specs,
SlashCommand,
};
use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary};
use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir(label: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time should be after epoch")
.as_nanos();
std::env::temp_dir().join(format!("commands-plugin-{label}-{nanos}"))
}
fn write_external_plugin(root: &Path, name: &str, version: &str) {
fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
fs::write(
root.join(".claude-plugin").join("plugin.json"),
format!(
"{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"commands plugin\"\n}}"
),
)
.expect("write manifest");
}
fn write_bundled_plugin(root: &Path, name: &str, version: &str, default_enabled: bool) {
fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
fs::write(
root.join(".claude-plugin").join("plugin.json"),
format!(
"{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"bundled commands plugin\",\n \"defaultEnabled\": {}\n}}",
if default_enabled { "true" } else { "false" }
),
)
.expect("write bundled manifest");
}
#[test]
fn parses_supported_slash_commands() {
assert_eq!(SlashCommand::parse("/help"), Some(SlashCommand::Help));
assert_eq!(SlashCommand::parse(" /status "), Some(SlashCommand::Status));
assert_eq!(
SlashCommand::parse("/model claude-opus"),
Some(SlashCommand::Model {
model: Some("claude-opus".to_string()),
})
);
assert_eq!(
SlashCommand::parse("/model"),
Some(SlashCommand::Model { model: None })
);
assert_eq!(
SlashCommand::parse("/permissions read-only"),
Some(SlashCommand::Permissions {
mode: Some("read-only".to_string()),
})
);
assert_eq!(
SlashCommand::parse("/clear"),
Some(SlashCommand::Clear { confirm: false })
);
assert_eq!(
SlashCommand::parse("/clear --confirm"),
Some(SlashCommand::Clear { confirm: true })
);
assert_eq!(SlashCommand::parse("/cost"), Some(SlashCommand::Cost));
assert_eq!(
SlashCommand::parse("/resume session.json"),
Some(SlashCommand::Resume {
session_path: Some("session.json".to_string()),
})
);
assert_eq!(
SlashCommand::parse("/config"),
Some(SlashCommand::Config { section: None })
);
assert_eq!(
SlashCommand::parse("/config env"),
Some(SlashCommand::Config {
section: Some("env".to_string())
})
);
assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory));
assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init));
assert_eq!(SlashCommand::parse("/diff"), Some(SlashCommand::Diff));
assert_eq!(SlashCommand::parse("/version"), Some(SlashCommand::Version));
assert_eq!(
SlashCommand::parse("/export notes.txt"),
Some(SlashCommand::Export {
path: Some("notes.txt".to_string())
})
);
assert_eq!(
SlashCommand::parse("/session switch abc123"),
Some(SlashCommand::Session {
action: Some("switch".to_string()),
target: Some("abc123".to_string())
})
);
assert_eq!(
SlashCommand::parse("/plugins install demo"),
Some(SlashCommand::Plugins {
action: Some("install".to_string()),
target: Some("demo".to_string())
})
);
assert_eq!(
SlashCommand::parse("/plugins list"),
Some(SlashCommand::Plugins {
action: Some("list".to_string()),
target: None
})
);
}
#[test]
fn renders_help_from_shared_specs() {
let help = render_slash_command_help();
assert!(help.contains("works with --resume SESSION.json"));
assert!(help.contains("/help"));
assert!(help.contains("/status"));
assert!(help.contains("/compact"));
assert!(help.contains("/model [model]"));
assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
assert!(help.contains("/clear [--confirm]"));
assert!(help.contains("/cost"));
assert!(help.contains("/resume <session-path>"));
assert!(help.contains("/config [env|hooks|model|plugins]"));
assert!(help.contains("/memory"));
assert!(help.contains("/init"));
assert!(help.contains("/diff"));
assert!(help.contains("/version"));
assert!(help.contains("/export [file]"));
assert!(help.contains("/session [list|switch <session-id>]"));
assert!(help.contains(
"/plugins [list|install <source>|enable <id>|disable <id>|uninstall <id>|update <id>]"
));
assert_eq!(slash_command_specs().len(), 16);
assert_eq!(resume_supported_slash_commands().len(), 11);
}
#[test]
fn compacts_sessions_via_slash_command() {
let session = Session {
version: 1,
messages: vec![
ConversationMessage::user_text("a ".repeat(200)),
ConversationMessage::assistant(vec![ContentBlock::Text {
text: "b ".repeat(200),
}]),
ConversationMessage::tool_result("1", "bash", "ok ".repeat(200), false),
ConversationMessage::assistant(vec![ContentBlock::Text {
text: "recent".to_string(),
}]),
],
};
let result = handle_slash_command(
"/compact",
&session,
CompactionConfig {
preserve_recent_messages: 2,
max_estimated_tokens: 1,
},
)
.expect("slash command should be handled");
assert!(result.message.contains("Compacted 2 messages"));
assert_eq!(result.session.messages[0].role, MessageRole::System);
}
#[test]
fn help_command_is_non_mutating() {
let session = Session::new();
let result = handle_slash_command("/help", &session, CompactionConfig::default())
.expect("help command should be handled");
assert_eq!(result.session, session);
assert!(result.message.contains("Slash commands"));
}
#[test]
fn ignores_unknown_or_runtime_bound_slash_commands() {
let session = Session::new();
assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none());
assert!(handle_slash_command("/status", &session, CompactionConfig::default()).is_none());
assert!(
handle_slash_command("/model claude", &session, CompactionConfig::default()).is_none()
);
assert!(handle_slash_command(
"/permissions read-only",
&session,
CompactionConfig::default()
)
.is_none());
assert!(handle_slash_command("/clear", &session, CompactionConfig::default()).is_none());
assert!(
handle_slash_command("/clear --confirm", &session, CompactionConfig::default())
.is_none()
);
assert!(handle_slash_command("/cost", &session, CompactionConfig::default()).is_none());
assert!(handle_slash_command(
"/resume session.json",
&session,
CompactionConfig::default()
)
.is_none());
assert!(handle_slash_command("/config", &session, CompactionConfig::default()).is_none());
assert!(
handle_slash_command("/config env", &session, CompactionConfig::default()).is_none()
);
assert!(handle_slash_command("/diff", &session, CompactionConfig::default()).is_none());
assert!(handle_slash_command("/version", &session, CompactionConfig::default()).is_none());
assert!(
handle_slash_command("/export note.txt", &session, CompactionConfig::default())
.is_none()
);
assert!(
handle_slash_command("/session list", &session, CompactionConfig::default()).is_none()
);
assert!(
handle_slash_command("/plugins list", &session, CompactionConfig::default()).is_none()
);
}
#[test]
fn renders_plugins_report_with_name_version_and_status() {
let rendered = render_plugins_report(&[
PluginSummary {
metadata: PluginMetadata {
id: "demo@external".to_string(),
name: "demo".to_string(),
version: "1.2.3".to_string(),
description: "demo plugin".to_string(),
kind: PluginKind::External,
source: "demo".to_string(),
default_enabled: false,
root: None,
},
enabled: true,
},
PluginSummary {
metadata: PluginMetadata {
id: "sample@external".to_string(),
name: "sample".to_string(),
version: "0.9.0".to_string(),
description: "sample plugin".to_string(),
kind: PluginKind::External,
source: "sample".to_string(),
default_enabled: false,
root: None,
},
enabled: false,
},
]);
assert!(rendered.contains("demo@external"));
assert!(rendered.contains("v1.2.3"));
assert!(rendered.contains("enabled"));
assert!(rendered.contains("sample@external"));
assert!(rendered.contains("v0.9.0"));
assert!(rendered.contains("disabled"));
}
#[test]
fn installs_plugin_from_path_and_lists_it() {
let config_home = temp_dir("home");
let source_root = temp_dir("source");
write_external_plugin(&source_root, "demo", "1.0.0");
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
let install = handle_plugins_slash_command(
Some("install"),
Some(source_root.to_str().expect("utf8 path")),
&mut manager,
)
.expect("install command should succeed");
assert!(install.reload_runtime);
assert!(install.message.contains("installed demo@external"));
assert!(install.message.contains("Name demo"));
assert!(install.message.contains("Version 1.0.0"));
assert!(install.message.contains("Status enabled"));
let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
.expect("list command should succeed");
assert!(!list.reload_runtime);
assert!(list.message.contains("demo@external"));
assert!(list.message.contains("v1.0.0"));
assert!(list.message.contains("enabled"));
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(source_root);
}
#[test]
fn enables_and_disables_plugin_by_name() {
let config_home = temp_dir("toggle-home");
let source_root = temp_dir("toggle-source");
write_external_plugin(&source_root, "demo", "1.0.0");
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
handle_plugins_slash_command(
Some("install"),
Some(source_root.to_str().expect("utf8 path")),
&mut manager,
)
.expect("install command should succeed");
let disable = handle_plugins_slash_command(Some("disable"), Some("demo"), &mut manager)
.expect("disable command should succeed");
assert!(disable.reload_runtime);
assert!(disable.message.contains("disabled demo@external"));
assert!(disable.message.contains("Name demo"));
assert!(disable.message.contains("Status disabled"));
let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
.expect("list command should succeed");
assert!(list.message.contains("demo@external"));
assert!(list.message.contains("disabled"));
let enable = handle_plugins_slash_command(Some("enable"), Some("demo"), &mut manager)
.expect("enable command should succeed");
assert!(enable.reload_runtime);
assert!(enable.message.contains("enabled demo@external"));
assert!(enable.message.contains("Name demo"));
assert!(enable.message.contains("Status enabled"));
let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
.expect("list command should succeed");
assert!(list.message.contains("demo@external"));
assert!(list.message.contains("enabled"));
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(source_root);
}
#[test]
fn lists_auto_installed_bundled_plugins_with_status() {
let config_home = temp_dir("bundled-home");
let bundled_root = temp_dir("bundled-root");
let bundled_plugin = bundled_root.join("starter");
write_bundled_plugin(&bundled_plugin, "starter", "0.1.0", false);
let mut config = PluginManagerConfig::new(&config_home);
config.bundled_root = Some(bundled_root.clone());
let mut manager = PluginManager::new(config);
let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
.expect("list command should succeed");
assert!(!list.reload_runtime);
assert!(list.message.contains("starter@bundled"));
assert!(list.message.contains("bundled"));
assert!(list.message.contains("disabled"));
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(bundled_root);
}
}