Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 0 additions & 12 deletions crates/aether-cli/src/acp/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,3 @@ pub enum SessionError {
#[error("active agent runtime is not running")]
ActiveRuntimeNotRunning,
}

#[derive(Error, Debug)]
pub(super) enum SlashCommandError {
#[error("command channel error: {0}")]
CommandChannel(String),
#[error("MCP operation failed: {0}")]
McpOperation(String),
#[error("slash command '/{0}' not found")]
NotFound(String),
#[error("prompt result contains no text content")]
NoTextContent,
}
102 changes: 18 additions & 84 deletions crates/aether-cli/src/acp/slash_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@ use acp_utils::server::AcpServerError;
use agent_client_protocol::schema::{self as acp, SessionId};
use agent_client_protocol::{Client, ConnectionTo};
use llm::ContentBlock;
use std::collections::HashSet;
use tracing::{error, info};

use super::agent_runtime::AgentRuntime;
use super::error::{SessionError, SlashCommandError};
use crate::acp::error::SessionError;
pub(crate) use crate::slash_commands::dedupe_commands_by_name;
use crate::slash_commands::{
SlashCommandError, find_prompt_name, parse_slash_command, parse_slash_command_arguments, prompt_result_text,
};

pub(crate) async fn expand_slash_command_in_content(
runtime: &AgentRuntime,
Expand All @@ -15,31 +18,24 @@ pub(crate) async fn expand_slash_command_in_content(
if let Some(ContentBlock::Text { text }) = content.first()
&& text.starts_with('/')
{
let expanded = expand_slash_command_if_needed(runtime, text.clone()).await;
let expanded = expand_slash_command_text(runtime, text.clone()).await;
content[0] = ContentBlock::text(expanded);
}
content
}

async fn expand_slash_command_if_needed(runtime: &AgentRuntime, text: String) -> String {
let Some(slash_command_text) = text.strip_prefix('/') else {
async fn expand_slash_command_text(runtime: &AgentRuntime, text: String) -> String {
let Some(slash_command) = parse_slash_command(&text) else {
return text;
};

let (command_name, args_text) = if let Some(space_idx) = slash_command_text.find(char::is_whitespace) {
let (cmd, args) = slash_command_text.split_at(space_idx);
(cmd, args.trim())
} else {
(slash_command_text, "")
};

match expand_slash_command(runtime, command_name, args_text).await {
match expand_slash_command(runtime, slash_command.command_name, slash_command.args_text).await {
Ok(expanded) => {
info!("Expanded slash command '{}' -> {} chars", command_name, expanded.len());
info!("Expanded slash command -> {} chars", expanded.len());
expanded
}
Err(e) => {
error!("Failed to expand slash command '{}': {}", command_name, e);
error!("Failed to expand slash command: {}", e);
text
}
}
Expand All @@ -51,57 +47,20 @@ async fn expand_slash_command(
args_text: &str,
) -> Result<String, SlashCommandError> {
let arguments = parse_slash_command_arguments(args_text);
let prompts = runtime.list_prompts().await.map_err(slash_command_error)?;
let prompt_name = find_prompt_name(&prompts, command_name)?;
let prompt_result = runtime.get_prompt(prompt_name, arguments).await.map_err(slash_command_error)?;

let prompts = runtime.list_prompts().await.map_err(|error| match error {
SessionError::CommandChannel(message) => SlashCommandError::CommandChannel(message),
other => SlashCommandError::McpOperation(other.to_string()),
})?;

let matching_prompt = prompts
.iter()
.find(|p| p.name.split("__").last().unwrap_or("") == command_name)
.ok_or_else(|| SlashCommandError::NotFound(command_name.to_string()))?;

let namespaced_name = matching_prompt.name.clone();
prompt_result_text(&prompt_result)
}

let prompt_result = runtime.get_prompt(namespaced_name.clone(), arguments).await.map_err(|error| match error {
fn slash_command_error(error: super::error::SessionError) -> SlashCommandError {
match error {
SessionError::CommandChannel(message) => SlashCommandError::CommandChannel(message),
other => SlashCommandError::McpOperation(other.to_string()),
})?;

prompt_result
.messages
.first()
.and_then(|message| match &message.content {
rmcp::model::PromptMessageContent::Text { text } => Some(text.clone()),
_ => None,
})
.ok_or(SlashCommandError::NoTextContent)
}

/// Parse slash command arguments into a map with both positional and special variables.
///
/// Creates an argument map with:
/// - "ARGUMENTS": The full argument string
/// - "1", "2", "3", etc.: Individual positional arguments (1-based)
fn parse_slash_command_arguments(args_text: &str) -> Option<serde_json::Map<String, serde_json::Value>> {
if args_text.is_empty() {
None
} else {
let mut arg_map = serde_json::Map::new();
arg_map.insert("ARGUMENTS".to_string(), serde_json::Value::String(args_text.to_string()));
for (i, arg) in args_text.split_whitespace().enumerate() {
arg_map.insert((i + 1).to_string(), serde_json::Value::String(arg.to_string()));
}
Some(arg_map)
}
}

pub(crate) fn dedupe_commands_by_name(commands: Vec<acp::AvailableCommand>) -> Vec<acp::AvailableCommand> {
let mut seen_names = HashSet::new();
commands.into_iter().filter(|command| seen_names.insert(command.name.clone())).collect()
}

pub(crate) fn send_available_commands(
connection: &ConnectionTo<Client>,
acp_session_id: SessionId,
Expand All @@ -117,28 +76,3 @@ pub(crate) fn send_available_commands(
error!("Failed to send available commands update: {:?}", e);
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_argument_parsing() {
let arg_map = parse_slash_command_arguments("do a thing that has spaces").expect("Expected Some");
let expected = serde_json::Map::from_iter([
("ARGUMENTS".to_string(), serde_json::Value::String("do a thing that has spaces".to_string())),
("1".to_string(), serde_json::Value::String("do".to_string())),
("2".to_string(), serde_json::Value::String("a".to_string())),
("3".to_string(), serde_json::Value::String("thing".to_string())),
("4".to_string(), serde_json::Value::String("that".to_string())),
("5".to_string(), serde_json::Value::String("has".to_string())),
("6".to_string(), serde_json::Value::String("spaces".to_string())),
]);
assert_eq!(arg_map, expected);
}

#[test]
fn test_empty_arguments_returns_none() {
assert!(parse_slash_command_arguments("").is_none());
}
}
8 changes: 5 additions & 3 deletions crates/aether-cli/src/headless/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ use aether_project::AetherSettings;
use error::CliError;
use llm::{ProviderConnectionOverride, ProviderConnectionOverrides};
use mcp_utils::client::McpConfig;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::io::{IsTerminal, Read as _, stdin};
use std::path::{Path, PathBuf};
Expand All @@ -20,7 +22,7 @@ use crate::settings_args::SettingsSourceArgs;
use aether_auth::OAuthCredentialStorage;
use std::sync::Arc;

#[derive(Clone, Copy, PartialEq, Eq, Debug, clap::ValueEnum, serde::Deserialize, schemars::JsonSchema)]
#[derive(Clone, Copy, PartialEq, Eq, Debug, clap::ValueEnum, Deserialize, Serialize, JsonSchema)]
#[clap(rename_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum CliEventKind {
Expand Down Expand Up @@ -54,7 +56,7 @@ pub struct RunConfig {
pub oauth_credential_store: Arc<dyn OAuthCredentialStorage>,
}

#[derive(Clone, Debug, Default, serde::Deserialize, schemars::JsonSchema)]
#[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct HeadlessOptions {
#[serde(default, skip_serializing_if = "Option::is_none")]
Expand Down Expand Up @@ -89,7 +91,7 @@ pub async fn run_headless(args: HeadlessArgs) -> Result<ExitCode, CliError> {

#[derive(clap::Args)]
pub struct HeadlessArgs {
#[arg(long = "options-json", value_name = "JSON")]
#[arg(long = "options-json", value_name = "JSON", hide = true)]
pub options_json: Option<String>,

/// Prompt to send (reads stdin if omitted and stdin is not a TTY)
Expand Down
66 changes: 64 additions & 2 deletions crates/aether-cli/src/headless/run.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
use aether_core::core::Prompt;
use aether_core::events::{AgentMessage, Command};
use aether_core::mcp::run_mcp_task::McpCommand;
use rmcp::model::{GetPromptResult, Prompt as McpPrompt};
use std::io;
use std::process::ExitCode;
use tokio::sync::mpsc;
use tokio::sync::{mpsc, oneshot};
use tracing::error;

use super::error::CliError;
use super::{CliEventKind, RunConfig};
use crate::output::OutputFormat;
use crate::runtime::RuntimeBuilder;
use crate::slash_commands::{
SlashCommandError, find_prompt_name, parse_slash_command, parse_slash_command_arguments, prompt_result_text,
};

pub async fn run(config: RunConfig) -> Result<ExitCode, CliError> {
setup_tracing(config.verbose);
Expand All @@ -23,15 +29,71 @@ pub async fn run(config: RunConfig) -> Result<ExitCode, CliError> {
.build_ready(vec![])
.await?;

let prompt = expand_prompt(&agent.mcp_tx, config.prompt).await;

agent
.agent_tx
.send(Command::text(&config.prompt))
.send(Command::text(&prompt))
.await
.map_err(|e| CliError::AgentError(format!("Failed to send prompt: {e}")))?;

Ok(stream_output(agent.agent_rx, config.output, &config.events).await)
}

async fn expand_prompt(mcp_tx: &mpsc::Sender<McpCommand>, prompt: String) -> String {
let Some(slash_command) = parse_slash_command(&prompt) else {
return prompt;
};

match expand_slash_command(mcp_tx, slash_command.command_name, slash_command.args_text).await {
Ok(expanded) => expanded,
Err(error) => {
error!("Failed to expand slash command: {error}");
prompt
}
}
}

async fn expand_slash_command(
mcp_tx: &mpsc::Sender<McpCommand>,
command_name: &str,
args_text: &str,
) -> Result<String, SlashCommandError> {
let arguments = parse_slash_command_arguments(args_text);
let prompts = list_prompts(mcp_tx).await?;
let prompt_name = find_prompt_name(&prompts, command_name)?;
let prompt_result = get_prompt(mcp_tx, prompt_name, arguments).await?;
prompt_result_text(&prompt_result)
}

async fn list_prompts(mcp_tx: &mpsc::Sender<McpCommand>) -> Result<Vec<McpPrompt>, SlashCommandError> {
let (tx, rx) = oneshot::channel();
mcp_tx
.send(McpCommand::ListPrompts { tx })
.await
.map_err(|e| SlashCommandError::CommandChannel(format!("failed to send ListPrompts command: {e}")))?;

rx.await
.map_err(|e| SlashCommandError::CommandChannel(format!("failed to receive prompts: {e}")))?
.map_err(SlashCommandError::McpOperation)
}

async fn get_prompt(
mcp_tx: &mpsc::Sender<McpCommand>,
name: String,
arguments: Option<serde_json::Map<String, serde_json::Value>>,
) -> Result<GetPromptResult, SlashCommandError> {
let (tx, rx) = oneshot::channel();
mcp_tx
.send(McpCommand::GetPrompt { name, arguments, tx })
.await
.map_err(|e| SlashCommandError::CommandChannel(format!("failed to send GetPrompt command: {e}")))?;

rx.await
.map_err(|e| SlashCommandError::CommandChannel(format!("failed to receive prompt: {e}")))?
.map_err(SlashCommandError::McpOperation)
}

async fn stream_output(
mut rx: mpsc::Receiver<AgentMessage>,
format: OutputFormat,
Expand Down
Loading