| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547 |
- use std::collections::BTreeMap;
- use std::env;
- use std::fs;
- use std::path::{Path, PathBuf};
- use plugins::{PluginError, 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 aliases: &'static [&'static str],
- pub summary: &'static str,
- pub argument_hint: Option<&'static str>,
- pub resume_supported: bool,
- }
- const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
- SlashCommandSpec {
- name: "help",
- aliases: &[],
- summary: "Show available slash commands",
- argument_hint: None,
- resume_supported: true,
- },
- SlashCommandSpec {
- name: "status",
- aliases: &[],
- summary: "Show current session status",
- argument_hint: None,
- resume_supported: true,
- },
- SlashCommandSpec {
- name: "compact",
- aliases: &[],
- summary: "Compact local session history",
- argument_hint: None,
- resume_supported: true,
- },
- SlashCommandSpec {
- name: "model",
- aliases: &[],
- summary: "Show or switch the active model",
- argument_hint: Some("[model]"),
- resume_supported: false,
- },
- SlashCommandSpec {
- name: "permissions",
- aliases: &[],
- summary: "Show or switch the active permission mode",
- argument_hint: Some("[read-only|workspace-write|danger-full-access]"),
- resume_supported: false,
- },
- SlashCommandSpec {
- name: "clear",
- aliases: &[],
- summary: "Start a fresh local session",
- argument_hint: Some("[--confirm]"),
- resume_supported: true,
- },
- SlashCommandSpec {
- name: "cost",
- aliases: &[],
- summary: "Show cumulative token usage for this session",
- argument_hint: None,
- resume_supported: true,
- },
- SlashCommandSpec {
- name: "resume",
- aliases: &[],
- summary: "Load a saved session into the REPL",
- argument_hint: Some("<session-path>"),
- resume_supported: false,
- },
- SlashCommandSpec {
- name: "config",
- aliases: &[],
- summary: "Inspect Claude config files or merged sections",
- argument_hint: Some("[env|hooks|model|plugins]"),
- resume_supported: true,
- },
- SlashCommandSpec {
- name: "memory",
- aliases: &[],
- summary: "Inspect loaded Claude instruction memory files",
- argument_hint: None,
- resume_supported: true,
- },
- SlashCommandSpec {
- name: "init",
- aliases: &[],
- summary: "Create a starter CLAUDE.md for this repo",
- argument_hint: None,
- resume_supported: true,
- },
- SlashCommandSpec {
- name: "diff",
- aliases: &[],
- summary: "Show git diff for current workspace changes",
- argument_hint: None,
- resume_supported: true,
- },
- SlashCommandSpec {
- name: "version",
- aliases: &[],
- summary: "Show CLI version and build information",
- argument_hint: None,
- resume_supported: true,
- },
- SlashCommandSpec {
- name: "bughunter",
- aliases: &[],
- summary: "Inspect the codebase for likely bugs",
- argument_hint: Some("[scope]"),
- resume_supported: false,
- },
- SlashCommandSpec {
- name: "commit",
- aliases: &[],
- summary: "Generate a commit message and create a git commit",
- argument_hint: None,
- resume_supported: false,
- },
- SlashCommandSpec {
- name: "pr",
- aliases: &[],
- summary: "Draft or create a pull request from the conversation",
- argument_hint: Some("[context]"),
- resume_supported: false,
- },
- SlashCommandSpec {
- name: "issue",
- aliases: &[],
- summary: "Draft or create a GitHub issue from the conversation",
- argument_hint: Some("[context]"),
- resume_supported: false,
- },
- SlashCommandSpec {
- name: "ultraplan",
- aliases: &[],
- summary: "Run a deep planning prompt with multi-step reasoning",
- argument_hint: Some("[task]"),
- resume_supported: false,
- },
- SlashCommandSpec {
- name: "teleport",
- aliases: &[],
- summary: "Jump to a file or symbol by searching the workspace",
- argument_hint: Some("<symbol-or-path>"),
- resume_supported: false,
- },
- SlashCommandSpec {
- name: "debug-tool-call",
- aliases: &[],
- summary: "Replay the last tool call with debug details",
- argument_hint: None,
- resume_supported: false,
- },
- SlashCommandSpec {
- name: "export",
- aliases: &[],
- summary: "Export the current conversation to a file",
- argument_hint: Some("[file]"),
- resume_supported: true,
- },
- SlashCommandSpec {
- name: "session",
- aliases: &[],
- summary: "List or switch managed local sessions",
- argument_hint: Some("[list|switch <session-id>]"),
- resume_supported: false,
- },
- SlashCommandSpec {
- name: "plugin",
- aliases: &["plugins", "marketplace"],
- summary: "Manage Claude Code plugins",
- argument_hint: Some(
- "[list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]",
- ),
- resume_supported: false,
- },
- SlashCommandSpec {
- name: "agents",
- aliases: &[],
- summary: "Manage agent configurations",
- argument_hint: None,
- resume_supported: false,
- },
- SlashCommandSpec {
- name: "skills",
- aliases: &[],
- summary: "List available skills",
- argument_hint: None,
- resume_supported: false,
- },
- ];
- #[derive(Debug, Clone, PartialEq, Eq)]
- pub enum SlashCommand {
- Help,
- Status,
- Compact,
- Bughunter {
- scope: Option<String>,
- },
- Commit,
- Pr {
- context: Option<String>,
- },
- Issue {
- context: Option<String>,
- },
- Ultraplan {
- task: Option<String>,
- },
- Teleport {
- target: Option<String>,
- },
- DebugToolCall,
- 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>,
- },
- Agents {
- args: Option<String>,
- },
- Skills {
- args: 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,
- "bughunter" => Self::Bughunter {
- scope: remainder_after_command(trimmed, command),
- },
- "commit" => Self::Commit,
- "pr" => Self::Pr {
- context: remainder_after_command(trimmed, command),
- },
- "issue" => Self::Issue {
- context: remainder_after_command(trimmed, command),
- },
- "ultraplan" => Self::Ultraplan {
- task: remainder_after_command(trimmed, command),
- },
- "teleport" => Self::Teleport {
- target: remainder_after_command(trimmed, command),
- },
- "debug-tool-call" => Self::DebugToolCall,
- "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),
- },
- "plugin" | "plugins" | "marketplace" => Self::Plugins {
- action: parts.next().map(ToOwned::to_owned),
- target: {
- let remainder = parts.collect::<Vec<_>>().join(" ");
- (!remainder.is_empty()).then_some(remainder)
- },
- },
- "agents" => Self::Agents {
- args: remainder_after_command(trimmed, command),
- },
- "skills" => Self::Skills {
- args: remainder_after_command(trimmed, command),
- },
- other => Self::Unknown(other.to_string()),
- })
- }
- }
- fn remainder_after_command(input: &str, command: &str) -> Option<String> {
- input
- .trim()
- .strip_prefix(&format!("/{command}"))
- .map(str::trim)
- .filter(|value| !value.is_empty())
- .map(ToOwned::to_owned)
- }
- #[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 alias_suffix = if spec.aliases.is_empty() {
- String::new()
- } else {
- format!(
- " (aliases: {})",
- spec.aliases
- .iter()
- .map(|alias| format!("/{alias}"))
- .collect::<Vec<_>>()
- .join(", ")
- )
- };
- let resume = if spec.resume_supported {
- " [resume]"
- } else {
- ""
- };
- lines.push(format!(
- " {name:<20} {}{alias_suffix}{resume}",
- spec.summary
- ));
- }
- 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,
- }
- #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
- enum DefinitionSource {
- ProjectCodex,
- ProjectClaude,
- UserCodexHome,
- UserCodex,
- UserClaude,
- }
- impl DefinitionSource {
- fn label(self) -> &'static str {
- match self {
- Self::ProjectCodex => "Project (.codex)",
- Self::ProjectClaude => "Project (.claude)",
- Self::UserCodexHome => "User ($CODEX_HOME)",
- Self::UserCodex => "User (~/.codex)",
- Self::UserClaude => "User (~/.claude)",
- }
- }
- }
- #[derive(Debug, Clone, PartialEq, Eq)]
- struct AgentSummary {
- name: String,
- description: Option<String>,
- model: Option<String>,
- reasoning_effort: Option<String>,
- source: DefinitionSource,
- shadowed_by: Option<DefinitionSource>,
- }
- #[derive(Debug, Clone, PartialEq, Eq)]
- struct SkillSummary {
- name: String,
- description: Option<String>,
- source: DefinitionSource,
- shadowed_by: Option<DefinitionSource>,
- }
- #[allow(clippy::too_many_lines)]
- 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,
- }),
- }
- }
- pub fn handle_agents_slash_command(args: Option<&str>, cwd: &Path) -> std::io::Result<String> {
- if let Some(args) = args.filter(|value| !value.trim().is_empty()) {
- return Ok(format!("Usage: /agents\nUnexpected arguments: {args}"));
- }
- let roots = discover_definition_roots(cwd, "agents");
- let agents = load_agents_from_roots(&roots)?;
- Ok(render_agents_report(&agents))
- }
- pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::Result<String> {
- if let Some(args) = args.filter(|value| !value.trim().is_empty()) {
- return Ok(format!("Usage: /skills\nUnexpected arguments: {args}"));
- }
- let roots = discover_definition_roots(cwd, "skills");
- let skills = load_skills_from_roots(&roots)?;
- Ok(render_skills_report(&skills))
- }
- #[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 enabled = if plugin.enabled {
- "enabled"
- } else {
- "disabled"
- };
- lines.push(format!(
- " {name:<20} v{version:<10} {enabled}",
- name = plugin.metadata.name,
- 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"
- ))),
- }
- }
- fn discover_definition_roots(cwd: &Path, leaf: &str) -> Vec<(DefinitionSource, PathBuf)> {
- let mut roots = Vec::new();
- for ancestor in cwd.ancestors() {
- push_unique_root(
- &mut roots,
- DefinitionSource::ProjectCodex,
- ancestor.join(".codex").join(leaf),
- );
- push_unique_root(
- &mut roots,
- DefinitionSource::ProjectClaude,
- ancestor.join(".claude").join(leaf),
- );
- }
- if let Ok(codex_home) = env::var("CODEX_HOME") {
- push_unique_root(
- &mut roots,
- DefinitionSource::UserCodexHome,
- PathBuf::from(codex_home).join(leaf),
- );
- }
- if let Some(home) = env::var_os("HOME") {
- let home = PathBuf::from(home);
- push_unique_root(
- &mut roots,
- DefinitionSource::UserCodex,
- home.join(".codex").join(leaf),
- );
- push_unique_root(
- &mut roots,
- DefinitionSource::UserClaude,
- home.join(".claude").join(leaf),
- );
- }
- roots
- }
- fn push_unique_root(
- roots: &mut Vec<(DefinitionSource, PathBuf)>,
- source: DefinitionSource,
- path: PathBuf,
- ) {
- if path.is_dir() && !roots.iter().any(|(_, existing)| existing == &path) {
- roots.push((source, path));
- }
- }
- fn load_agents_from_roots(
- roots: &[(DefinitionSource, PathBuf)],
- ) -> std::io::Result<Vec<AgentSummary>> {
- let mut agents = Vec::new();
- let mut active_sources = BTreeMap::<String, DefinitionSource>::new();
- for (source, root) in roots {
- let mut root_agents = Vec::new();
- for entry in fs::read_dir(root)? {
- let entry = entry?;
- if entry.path().extension().is_none_or(|ext| ext != "toml") {
- continue;
- }
- let contents = fs::read_to_string(entry.path())?;
- let fallback_name = entry.path().file_stem().map_or_else(
- || entry.file_name().to_string_lossy().to_string(),
- |stem| stem.to_string_lossy().to_string(),
- );
- root_agents.push(AgentSummary {
- name: parse_toml_string(&contents, "name").unwrap_or(fallback_name),
- description: parse_toml_string(&contents, "description"),
- model: parse_toml_string(&contents, "model"),
- reasoning_effort: parse_toml_string(&contents, "model_reasoning_effort"),
- source: *source,
- shadowed_by: None,
- });
- }
- root_agents.sort_by(|left, right| left.name.cmp(&right.name));
- for mut agent in root_agents {
- let key = agent.name.to_ascii_lowercase();
- if let Some(existing) = active_sources.get(&key) {
- agent.shadowed_by = Some(*existing);
- } else {
- active_sources.insert(key, agent.source);
- }
- agents.push(agent);
- }
- }
- Ok(agents)
- }
- fn load_skills_from_roots(
- roots: &[(DefinitionSource, PathBuf)],
- ) -> std::io::Result<Vec<SkillSummary>> {
- let mut skills = Vec::new();
- let mut active_sources = BTreeMap::<String, DefinitionSource>::new();
- for (source, root) in roots {
- let mut root_skills = Vec::new();
- for entry in fs::read_dir(root)? {
- let entry = entry?;
- if !entry.path().is_dir() {
- continue;
- }
- let skill_path = entry.path().join("SKILL.md");
- if !skill_path.is_file() {
- continue;
- }
- let contents = fs::read_to_string(skill_path)?;
- let (name, description) = parse_skill_frontmatter(&contents);
- root_skills.push(SkillSummary {
- name: name.unwrap_or_else(|| entry.file_name().to_string_lossy().to_string()),
- description,
- source: *source,
- shadowed_by: None,
- });
- }
- root_skills.sort_by(|left, right| left.name.cmp(&right.name));
- for mut skill in root_skills {
- let key = skill.name.to_ascii_lowercase();
- if let Some(existing) = active_sources.get(&key) {
- skill.shadowed_by = Some(*existing);
- } else {
- active_sources.insert(key, skill.source);
- }
- skills.push(skill);
- }
- }
- Ok(skills)
- }
- fn parse_toml_string(contents: &str, key: &str) -> Option<String> {
- let prefix = format!("{key} =");
- for line in contents.lines() {
- let trimmed = line.trim();
- if trimmed.starts_with('#') {
- continue;
- }
- let Some(value) = trimmed.strip_prefix(&prefix) else {
- continue;
- };
- let value = value.trim();
- let Some(value) = value
- .strip_prefix('"')
- .and_then(|value| value.strip_suffix('"'))
- else {
- continue;
- };
- if !value.is_empty() {
- return Some(value.to_string());
- }
- }
- None
- }
- fn parse_skill_frontmatter(contents: &str) -> (Option<String>, Option<String>) {
- let mut lines = contents.lines();
- if lines.next().map(str::trim) != Some("---") {
- return (None, None);
- }
- let mut name = None;
- let mut description = None;
- for line in lines {
- let trimmed = line.trim();
- if trimmed == "---" {
- break;
- }
- if let Some(value) = trimmed.strip_prefix("name:") {
- let value = value.trim();
- if !value.is_empty() {
- name = Some(value.to_string());
- }
- continue;
- }
- if let Some(value) = trimmed.strip_prefix("description:") {
- let value = value.trim();
- if !value.is_empty() {
- description = Some(value.to_string());
- }
- }
- }
- (name, description)
- }
- fn render_agents_report(agents: &[AgentSummary]) -> String {
- if agents.is_empty() {
- return "No agents found.".to_string();
- }
- let total_active = agents
- .iter()
- .filter(|agent| agent.shadowed_by.is_none())
- .count();
- let mut lines = vec![
- "Agents".to_string(),
- format!(" {total_active} active agents"),
- String::new(),
- ];
- for source in [
- DefinitionSource::ProjectCodex,
- DefinitionSource::ProjectClaude,
- DefinitionSource::UserCodexHome,
- DefinitionSource::UserCodex,
- DefinitionSource::UserClaude,
- ] {
- let group = agents
- .iter()
- .filter(|agent| agent.source == source)
- .collect::<Vec<_>>();
- if group.is_empty() {
- continue;
- }
- lines.push(format!("{}:", source.label()));
- for agent in group {
- let detail = agent_detail(agent);
- match agent.shadowed_by {
- Some(winner) => lines.push(format!(" (shadowed by {}) {detail}", winner.label())),
- None => lines.push(format!(" {detail}")),
- }
- }
- lines.push(String::new());
- }
- lines.join("\n").trim_end().to_string()
- }
- fn agent_detail(agent: &AgentSummary) -> String {
- let mut parts = vec![agent.name.clone()];
- if let Some(description) = &agent.description {
- parts.push(description.clone());
- }
- if let Some(model) = &agent.model {
- parts.push(model.clone());
- }
- if let Some(reasoning) = &agent.reasoning_effort {
- parts.push(reasoning.clone());
- }
- parts.join(" · ")
- }
- fn render_skills_report(skills: &[SkillSummary]) -> String {
- if skills.is_empty() {
- return "No skills found.".to_string();
- }
- let total_active = skills
- .iter()
- .filter(|skill| skill.shadowed_by.is_none())
- .count();
- let mut lines = vec![
- "Skills".to_string(),
- format!(" {total_active} available skills"),
- String::new(),
- ];
- for source in [
- DefinitionSource::ProjectCodex,
- DefinitionSource::ProjectClaude,
- DefinitionSource::UserCodexHome,
- DefinitionSource::UserCodex,
- DefinitionSource::UserClaude,
- ] {
- let group = skills
- .iter()
- .filter(|skill| skill.source == source)
- .collect::<Vec<_>>();
- if group.is_empty() {
- continue;
- }
- lines.push(format!("{}:", source.label()));
- for skill in group {
- let detail = match &skill.description {
- Some(description) => format!("{} · {}", skill.name, description),
- None => skill.name.clone(),
- };
- match skill.shadowed_by {
- Some(winner) => lines.push(format!(" (shadowed by {}) {detail}", winner.label())),
- None => lines.push(format!(" {detail}")),
- }
- }
- lines.push(String::new());
- }
- lines.join("\n").trim_end().to_string()
- }
- #[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::Bughunter { .. }
- | SlashCommand::Commit
- | SlashCommand::Pr { .. }
- | SlashCommand::Issue { .. }
- | SlashCommand::Ultraplan { .. }
- | SlashCommand::Teleport { .. }
- | SlashCommand::DebugToolCall
- | 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::Agents { .. }
- | SlashCommand::Skills { .. }
- | SlashCommand::Unknown(_) => None,
- }
- }
- #[cfg(test)]
- mod tests {
- use super::{
- handle_plugins_slash_command, handle_slash_command, load_agents_from_roots,
- load_skills_from_roots, render_agents_report, render_plugins_report, render_skills_report,
- render_slash_command_help, resume_supported_slash_commands, slash_command_specs,
- DefinitionSource, 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");
- }
- fn write_agent(root: &Path, name: &str, description: &str, model: &str, reasoning: &str) {
- fs::create_dir_all(root).expect("agent root");
- fs::write(
- root.join(format!("{name}.toml")),
- format!(
- "name = \"{name}\"\ndescription = \"{description}\"\nmodel = \"{model}\"\nmodel_reasoning_effort = \"{reasoning}\"\n"
- ),
- )
- .expect("write agent");
- }
- fn write_skill(root: &Path, name: &str, description: &str) {
- let skill_root = root.join(name);
- fs::create_dir_all(&skill_root).expect("skill root");
- fs::write(
- skill_root.join("SKILL.md"),
- format!("---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n"),
- )
- .expect("write skill");
- }
- #[allow(clippy::too_many_lines)]
- #[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("/bughunter runtime"),
- Some(SlashCommand::Bughunter {
- scope: Some("runtime".to_string())
- })
- );
- assert_eq!(SlashCommand::parse("/commit"), Some(SlashCommand::Commit));
- assert_eq!(
- SlashCommand::parse("/pr ready for review"),
- Some(SlashCommand::Pr {
- context: Some("ready for review".to_string())
- })
- );
- assert_eq!(
- SlashCommand::parse("/issue flaky test"),
- Some(SlashCommand::Issue {
- context: Some("flaky test".to_string())
- })
- );
- assert_eq!(
- SlashCommand::parse("/ultraplan ship both features"),
- Some(SlashCommand::Ultraplan {
- task: Some("ship both features".to_string())
- })
- );
- assert_eq!(
- SlashCommand::parse("/teleport conversation.rs"),
- Some(SlashCommand::Teleport {
- target: Some("conversation.rs".to_string())
- })
- );
- assert_eq!(
- SlashCommand::parse("/debug-tool-call"),
- Some(SlashCommand::DebugToolCall)
- );
- 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
- })
- );
- assert_eq!(
- SlashCommand::parse("/plugins enable demo"),
- Some(SlashCommand::Plugins {
- action: Some("enable".to_string()),
- target: Some("demo".to_string())
- })
- );
- assert_eq!(
- SlashCommand::parse("/plugins disable demo"),
- Some(SlashCommand::Plugins {
- action: Some("disable".to_string()),
- target: Some("demo".to_string())
- })
- );
- }
- #[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("/bughunter [scope]"));
- assert!(help.contains("/commit"));
- assert!(help.contains("/pr [context]"));
- assert!(help.contains("/issue [context]"));
- assert!(help.contains("/ultraplan [task]"));
- assert!(help.contains("/teleport <symbol-or-path>"));
- assert!(help.contains("/debug-tool-call"));
- 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(
- "/plugin [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]"
- ));
- assert!(help.contains("aliases: /plugins, /marketplace"));
- assert!(help.contains("/agents"));
- assert!(help.contains("/skills"));
- assert_eq!(slash_command_specs().len(), 25);
- 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("/bughunter", &session, CompactionConfig::default()).is_none()
- );
- assert!(handle_slash_command("/commit", &session, CompactionConfig::default()).is_none());
- assert!(handle_slash_command("/pr", &session, CompactionConfig::default()).is_none());
- assert!(handle_slash_command("/issue", &session, CompactionConfig::default()).is_none());
- assert!(
- handle_slash_command("/ultraplan", &session, CompactionConfig::default()).is_none()
- );
- assert!(
- handle_slash_command("/teleport foo", &session, CompactionConfig::default()).is_none()
- );
- assert!(
- handle_slash_command("/debug-tool-call", &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"));
- assert!(rendered.contains("v1.2.3"));
- assert!(rendered.contains("enabled"));
- assert!(rendered.contains("sample"));
- assert!(rendered.contains("v0.9.0"));
- assert!(rendered.contains("disabled"));
- }
- #[test]
- fn lists_agents_from_project_and_user_roots() {
- let workspace = temp_dir("agents-workspace");
- let project_agents = workspace.join(".codex").join("agents");
- let user_home = temp_dir("agents-home");
- let user_agents = user_home.join(".codex").join("agents");
- write_agent(
- &project_agents,
- "planner",
- "Project planner",
- "gpt-5.4",
- "medium",
- );
- write_agent(
- &user_agents,
- "planner",
- "User planner",
- "gpt-5.4-mini",
- "high",
- );
- write_agent(
- &user_agents,
- "verifier",
- "Verification agent",
- "gpt-5.4-mini",
- "high",
- );
- let roots = vec![
- (DefinitionSource::ProjectCodex, project_agents),
- (DefinitionSource::UserCodex, user_agents),
- ];
- let report =
- render_agents_report(&load_agents_from_roots(&roots).expect("agent roots should load"));
- assert!(report.contains("Agents"));
- assert!(report.contains("2 active agents"));
- assert!(report.contains("Project (.codex):"));
- assert!(report.contains("planner · Project planner · gpt-5.4 · medium"));
- assert!(report.contains("User (~/.codex):"));
- assert!(report.contains("(shadowed by Project (.codex)) planner · User planner"));
- assert!(report.contains("verifier · Verification agent · gpt-5.4-mini · high"));
- let _ = fs::remove_dir_all(workspace);
- let _ = fs::remove_dir_all(user_home);
- }
- #[test]
- fn lists_skills_from_project_and_user_roots() {
- let workspace = temp_dir("skills-workspace");
- let project_skills = workspace.join(".codex").join("skills");
- let user_home = temp_dir("skills-home");
- let user_skills = user_home.join(".codex").join("skills");
- write_skill(&project_skills, "plan", "Project planning guidance");
- write_skill(&user_skills, "plan", "User planning guidance");
- write_skill(&user_skills, "help", "Help guidance");
- let roots = vec![
- (DefinitionSource::ProjectCodex, project_skills),
- (DefinitionSource::UserCodex, user_skills),
- ];
- let report =
- render_skills_report(&load_skills_from_roots(&roots).expect("skill roots should load"));
- assert!(report.contains("Skills"));
- assert!(report.contains("2 available skills"));
- assert!(report.contains("Project (.codex):"));
- assert!(report.contains("plan · Project planning guidance"));
- assert!(report.contains("User (~/.codex):"));
- assert!(report.contains("(shadowed by Project (.codex)) plan · User planning guidance"));
- assert!(report.contains("help · Help guidance"));
- let _ = fs::remove_dir_all(workspace);
- let _ = fs::remove_dir_all(user_home);
- }
- #[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"));
- 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"));
- 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"));
- 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"));
- assert!(list.message.contains("v0.1.0"));
- assert!(list.message.contains("disabled"));
- let _ = fs::remove_dir_all(config_home);
- let _ = fs::remove_dir_all(bundled_root);
- }
- }
|