use std::path::Path;
use agent_client_protocol::schema::v1::{
AvailableCommand, AvailableCommandInput, UnstructuredCommandInput,
};
use rho_sdk::{
model::{ContentBlock, ToolCall},
UserInput,
};
use super::skills;
use crate::{
agent::{AgentCatalog, AgentRuntimeSpec},
app::workflow_cli::discover::{self as workflows, StartCommand},
commands::{init_turn, CommandId, CommandInvocation, CommandParseError, COMMANDS},
config::Config,
permission::PermissionMode,
prompt_templates,
tools::{
mcp::prompt_command::{self, McpPromptCommand},
sdk_registry::AppToolSet,
workflow as workflow_tool,
},
};
const BUILT_IN: [CommandId; 2] = [CommandId::Compact, CommandId::Init];
pub(super) struct CommandScope<'a> {
pub(super) cwd: &'a Path,
pub(super) home: Option<&'a Path>,
pub(super) tools: &'a AppToolSet,
pub(super) config: &'a Config,
pub(super) permission_mode: PermissionMode,
}
#[derive(Debug, PartialEq)]
pub(super) enum PromptAction {
Turn {
input: UserInput,
calls: Vec<ToolCall>,
},
Compact { instructions: Option<String> },
StartWorkflow(StartCommand),
RunAsAgent { agent_id: String, input: UserInput },
}
#[derive(Debug, thiserror::Error)]
#[error("{0}")]
pub(super) struct CommandRejected(pub(super) String);
pub(super) fn available_commands(scope: &CommandScope<'_>) -> Vec<AvailableCommand> {
let built_in = COMMANDS
.iter()
.filter(|spec| BUILT_IN.contains(&spec.id))
.map(|spec| command(spec.name.to_string(), spec.description, spec.usage));
let templates = templates(scope).into_iter().map(|(name, template)| {
let name = format!("prompt:{name}");
let usage = prompt_templates::usage(&name, &template);
command(name, &prompt_templates::description(&template), &usage)
});
let mcp_prompts = scope
.tools
.mcp_catalog()
.prompts()
.into_iter()
.map(|prompt| {
let description = prompt.description.as_deref().unwrap_or(prompt.label());
command(prompt.command_name(), description, &prompt.usage())
});
let mut commands = built_in
.chain(templates)
.chain(mcp_prompts)
.collect::<Vec<_>>();
if scope.tools.contains(workflow_tool::NAME) {
commands.extend(
workflows::command_workflows(scope.cwd)
.into_iter()
.filter_map(|workflow| {
Some(command(
workflow.command_name()?,
&workflow.command_description(),
&workflow.command_usage()?,
))
}),
);
}
if scope.tools.contains(skills::SKILL_TOOL) {
commands.extend(skills::available_commands(
&crate::skills::discover_with_home(scope.cwd, scope.home),
));
}
if let Ok(catalog) = agent_catalog(scope) {
commands.extend(catalog.iter().filter_map(|entry| {
let definition = &entry.definition;
match &definition.runtime {
AgentRuntimeSpec::Rho { .. } => Some(command(
format!("agent:{}", definition.id),
&definition.description,
&format!("/agent:{} <task>", definition.id),
)),
AgentRuntimeSpec::ClaudeCli(_)
| AgentRuntimeSpec::Cursor(_)
| AgentRuntimeSpec::Antigravity(_) => None,
}
}));
}
commands
}
pub(super) async fn resolve(
input: UserInput,
scope: &CommandScope<'_>,
) -> Result<PromptAction, CommandRejected> {
let Some(text) = leading_text(&input) else {
return Ok(turn(input, &[], scope));
};
if let Some(rest) = text.strip_prefix("/agent:") {
let (agent_id, task) = rest.split_once(char::is_whitespace).unwrap_or((rest, ""));
let (agent_id, task) = (agent_id.to_owned(), task.trim().to_owned());
return run_as_agent(input, &agent_id, task, scope);
}
match crate::commands::parse_command(text) {
Ok(None) => Ok(turn(input, &[], scope)),
Ok(Some(invocation)) => built_in(input, invocation, scope),
Err(CommandParseError::Unknown(name)) => dynamic(input, &name, scope).await,
}
}
fn built_in(
input: UserInput,
invocation: CommandInvocation,
scope: &CommandScope<'_>,
) -> Result<PromptAction, CommandRejected> {
match invocation.id {
CommandId::Compact => Ok(PromptAction::Compact {
instructions: (!invocation.args.is_empty()).then_some(invocation.args),
}),
CommandId::Init => {
let model_prompt = init_turn::model_prompt(scope.cwd, scope.permission_mode, |name| {
scope.tools.contains(name)
})
.map_err(|error| CommandRejected(error.to_string()))?;
Ok(PromptAction::Turn {
input: replace_leading_text(input, model_prompt),
calls: vec![skills::load_call(init_turn::SKILL)],
})
}
_ => Err(CommandRejected(format!(
"could not run /{}: not available in ACP sessions",
invocation.name
))),
}
}
fn run_as_agent(
input: UserInput,
agent_id: &str,
task: String,
scope: &CommandScope<'_>,
) -> Result<PromptAction, CommandRejected> {
if agent_id.is_empty() || task.is_empty() {
return Err(CommandRejected("usage: /agent:<name> <task>".into()));
}
let rejected =
|detail: String| CommandRejected(format!("could not run /agent:{agent_id}: {detail}"));
let catalog = agent_catalog(scope).map_err(|error| rejected(error.to_string()))?;
let definition = &catalog
.find(agent_id)
.map_err(|error| rejected(error.to_string()))?
.definition;
match &definition.runtime {
AgentRuntimeSpec::Rho { .. } => {}
AgentRuntimeSpec::ClaudeCli(_)
| AgentRuntimeSpec::Cursor(_)
| AgentRuntimeSpec::Antigravity(_) => {
return Err(rejected(format!(
"{agent_id} runs on {}, which an ACP session cannot host",
definition.runtime.runtime()
)));
}
}
Ok(PromptAction::RunAsAgent {
agent_id: agent_id.to_owned(),
input: replace_leading_text(input, task),
})
}
async fn dynamic(
input: UserInput,
name: &str,
scope: &CommandScope<'_>,
) -> Result<PromptAction, CommandRejected> {
if let Some(skill) = name.strip_prefix('$') {
if scope.tools.contains(skills::SKILL_TOOL)
&& crate::skills::discover_with_home(scope.cwd, scope.home)
.iter()
.any(|known| known.name == skill)
{
return Ok(turn(input, &[skill], scope));
}
}
let trailing = leading_text(&input).map_or("", command_args);
if let Some(resolved) = workflows::resolve_start_command(scope.cwd, name, trailing) {
let rejected = |detail: &str| CommandRejected(format!("could not run /{name}: {detail}"));
if !scope.tools.contains(workflow_tool::NAME) {
return Err(rejected("this session has no workflow tool"));
}
if scope.permission_mode == PermissionMode::Plan {
return Err(rejected("workflows cannot start in plan mode"));
}
return resolved
.map(PromptAction::StartWorkflow)
.map_err(|detail| rejected(&detail));
}
if let Some(template) =
prompt_templates::command_template_name(name).and_then(|template_name| {
prompt_templates::find(&templates(scope), template_name).map(str::to_owned)
})
{
let expanded = prompt_templates::expand(&template, trailing);
return Ok(turn(replace_leading_text(input, expanded), &[], scope));
}
let catalog = scope.tools.mcp_catalog();
let Some(prompt) = prompt_command::find(catalog, name) else {
return Ok(turn(input, &[], scope));
};
let rejected = |detail: String| {
CommandRejected(format!(
"could not run /{}: {detail}",
prompt.command_name()
))
};
match prompt_command::expand(catalog, &prompt, trailing, scope.config.max_output_bytes).await {
McpPromptCommand::MissingArguments(missing) => Err(rejected(format!(
"missing required arguments: {}",
missing.join(", ")
))),
McpPromptCommand::Failed(error) => Err(rejected(error.to_string())),
McpPromptCommand::Expanded(text) => Ok(turn(replace_leading_text(input, text), &[], scope)),
}
}
fn turn(input: UserInput, leading: &[&str], scope: &CommandScope<'_>) -> PromptAction {
let calls = if scope.tools.contains(skills::SKILL_TOOL) {
skills::load_calls(leading, &input, || {
crate::skills::discover_with_home(scope.cwd, scope.home)
})
} else {
Vec::new()
};
PromptAction::Turn { input, calls }
}
fn templates(scope: &CommandScope<'_>) -> prompt_templates::PromptTemplates {
prompt_templates::load(scope.cwd, scope.home, scope.config.prompt_templates.clone())
}
fn agent_catalog(
scope: &CommandScope<'_>,
) -> Result<AgentCatalog, crate::agent::AgentCatalogError> {
AgentCatalog::discover_with_home_and_trust(
scope.cwd,
scope.home,
crate::workspace::ProjectTrust::from_agents_env(),
)
}
fn command(name: String, description: &str, usage: &str) -> AvailableCommand {
let hint = command_args(usage);
let command = AvailableCommand::new(name, description);
if hint.is_empty() {
command
} else {
command.input(AvailableCommandInput::Unstructured(
UnstructuredCommandInput::new(hint),
))
}
}
fn command_args(text: &str) -> &str {
text.trim()
.split_once(char::is_whitespace)
.map_or("", |(_, args)| args.trim())
}
fn leading_text(input: &UserInput) -> Option<&str> {
match input.blocks().first()? {
ContentBlock::Text(text) => Some(text),
ContentBlock::Image(_) | ContentBlock::ToolCall(_) => None,
}
}
fn replace_leading_text(input: UserInput, text: String) -> UserInput {
let mut blocks = input.blocks().to_vec();
blocks[0] = ContentBlock::Text(text);
UserInput::content(blocks).expect("replacing a text block keeps the input valid")
}
#[cfg(test)]
#[path = "commands_tests.rs"]
mod tests;