Skip to content
Open
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
180 changes: 178 additions & 2 deletions crates/chat-cli/src/cli/agent/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ use crate::util::{
self,
MCP_SERVER_TOOL_DELIMITER,
directories,
file_uri,
};

pub const DEFAULT_AGENT_NAME: &str = "q_cli_default";
Expand All @@ -90,6 +91,12 @@ pub enum AgentConfigError {
Io(#[from] std::io::Error),
#[error("Failed to parse legacy mcp config: {0}")]
BadLegacyMcpConfig(#[from] eyre::Report),
#[error("File URI not found: {uri} (resolved to {path})")]
FileUriNotFound { uri: String, path: PathBuf },
#[error("Failed to read file URI: {uri} (resolved to {path}): {error}")]
FileUriReadError { uri: String, path: PathBuf, error: std::io::Error },
#[error("Invalid file URI format: {uri}")]
InvalidFileUri { uri: String },
}

/// An [Agent] is a declarative way of configuring a given instance of q chat. Currently, it is
Expand Down Expand Up @@ -223,10 +230,15 @@ impl Agent {
legacy_mcp_config: Option<&McpServerConfig>,
output: &mut impl Write,
) -> Result<(), AgentConfigError> {
let Self { mcp_servers, .. } = self;

self.path = Some(path.to_path_buf());

// Resolve file:// URIs in the prompt field
if let Some(resolved_prompt) = self.resolve_prompt()? {
self.prompt = Some(resolved_prompt);
}

let Self { mcp_servers, .. } = self;

if let (true, Some(legacy_mcp_config)) = (self.use_legacy_mcp_json, legacy_mcp_config) {
for (name, legacy_server) in &legacy_mcp_config.mcp_servers {
if mcp_servers.mcp_servers.contains_key(name) {
Expand Down Expand Up @@ -285,6 +297,48 @@ impl Agent {
Ok(serde_json::to_string_pretty(&agent_clone)?)
}

/// Resolves the prompt field, handling file:// URIs if present.
/// Returns the prompt content as-is if it doesn't start with file://,
/// or resolves the file URI and returns the file content.
pub fn resolve_prompt(&self) -> Result<Option<String>, AgentConfigError> {
match &self.prompt {
None => Ok(None),
Some(prompt_str) => {
if prompt_str.starts_with("file://") {
// Get the base path from the agent config file path
let base_path = match &self.path {
Some(path) => path.parent().unwrap_or(Path::new(".")),
None => Path::new("."),
};

// Resolve the file URI
match file_uri::resolve_file_uri(prompt_str, base_path) {
Ok(content) => Ok(Some(content)),
Err(file_uri::FileUriError::InvalidUri { uri }) => {
Err(AgentConfigError::InvalidFileUri { uri })
}
Err(file_uri::FileUriError::FileNotFound { path }) => {
Err(AgentConfigError::FileUriNotFound {
uri: prompt_str.clone(),
path
})
}
Err(file_uri::FileUriError::ReadError { path, source }) => {
Err(AgentConfigError::FileUriReadError {
uri: prompt_str.clone(),
path,
error: source
})
}
}
} else {
// Return the prompt as-is for backward compatibility
Ok(Some(prompt_str.clone()))
}
}
}
}

/// Retrieves an agent by name. It does so via first seeking the given agent under local dir,
/// and falling back to global dir if it does not exist in local.
pub async fn get_agent_by_name(os: &Os, agent_name: &str) -> eyre::Result<(Agent, PathBuf)> {
Expand Down Expand Up @@ -939,6 +993,8 @@ fn validate_agent_name(name: &str) -> eyre::Result<()> {
#[cfg(test)]
mod tests {
use serde_json::json;
use std::fs;
use tempfile::TempDir;

use super::*;
use crate::cli::agent::hook::Source;
Expand Down Expand Up @@ -1402,4 +1458,124 @@ mod tests {
}
}
}

#[test]
fn test_resolve_prompt_file_uri_relative() {
let temp_dir = TempDir::new().unwrap();

// Create a prompt file
let prompt_content = "You are a test agent with specific instructions.";
let prompt_file = temp_dir.path().join("test-prompt.md");
fs::write(&prompt_file, prompt_content).unwrap();

// Create agent config file path
let config_file = temp_dir.path().join("test-agent.json");

// Create agent with file:// URI prompt
let agent = Agent {
name: "test-agent".to_string(),
prompt: Some("file://./test-prompt.md".to_string()),
path: Some(config_file),
..Default::default()
};

// Test resolve_prompt
let resolved = agent.resolve_prompt().unwrap();
assert_eq!(resolved, Some(prompt_content.to_string()));
}

#[test]
fn test_resolve_prompt_file_uri_absolute() {
let temp_dir = TempDir::new().unwrap();

// Create a prompt file
let prompt_content = "Absolute path prompt content.";
let prompt_file = temp_dir.path().join("absolute-prompt.md");
fs::write(&prompt_file, prompt_content).unwrap();

// Create agent with absolute file:// URI
let agent = Agent {
name: "test-agent".to_string(),
prompt: Some(format!("file://{}", prompt_file.display())),
path: Some(temp_dir.path().join("test-agent.json")),
..Default::default()
};

// Test resolve_prompt
let resolved = agent.resolve_prompt().unwrap();
assert_eq!(resolved, Some(prompt_content.to_string()));
}

#[test]
fn test_resolve_prompt_inline_unchanged() {
let temp_dir = TempDir::new().unwrap();

// Create agent with inline prompt
let inline_prompt = "This is an inline prompt.";
let agent = Agent {
name: "test-agent".to_string(),
prompt: Some(inline_prompt.to_string()),
path: Some(temp_dir.path().join("test-agent.json")),
..Default::default()
};

// Test resolve_prompt
let resolved = agent.resolve_prompt().unwrap();
assert_eq!(resolved, Some(inline_prompt.to_string()));
}

#[test]
fn test_resolve_prompt_file_not_found_error() {
let temp_dir = TempDir::new().unwrap();

// Create agent with non-existent file URI
let agent = Agent {
name: "test-agent".to_string(),
prompt: Some("file://./nonexistent.md".to_string()),
path: Some(temp_dir.path().join("test-agent.json")),
..Default::default()
};

// Test resolve_prompt should fail
let result = agent.resolve_prompt();
assert!(result.is_err());

if let Err(AgentConfigError::FileUriNotFound { uri, .. }) = result {
assert_eq!(uri, "file://./nonexistent.md");
} else {
panic!("Expected FileUriNotFound error, got: {:?}", result);
}
}

#[test]
fn test_resolve_prompt_no_prompt_field() {
let temp_dir = TempDir::new().unwrap();

// Create agent without prompt field
let agent = Agent {
name: "test-agent".to_string(),
prompt: None,
path: Some(temp_dir.path().join("test-agent.json")),
..Default::default()
};

// Test resolve_prompt
let resolved = agent.resolve_prompt().unwrap();
assert_eq!(resolved, None);
}

#[test]
fn test_resolve_prompt_no_path_set() {
// Create agent without path set (should not happen in practice)
let agent = Agent {
name: "test-agent".to_string(),
prompt: Some("file://./test.md".to_string()),
path: None,
..Default::default()
};

// Test resolve_prompt should fail gracefully
let result = agent.resolve_prompt();
assert!(result.is_err());
}
}
140 changes: 140 additions & 0 deletions crates/chat-cli/src/util/file_uri.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
use std::fs;
use std::path::{Path, PathBuf};

use eyre::Result;
use thiserror::Error;

#[derive(Debug, Error)]
pub enum FileUriError {
#[error("Invalid file URI format: {uri}")]
InvalidUri { uri: String },
#[error("File not found: {path}")]
FileNotFound { path: PathBuf },
#[error("Failed to read file {path}: {source}")]
ReadError { path: PathBuf, source: std::io::Error },
}

/// Resolves a file:// URI to its content, supporting both relative and absolute paths.
///
/// # Arguments
/// * `uri` - The file:// URI to resolve
/// * `base_path` - Base path for resolving relative URIs (typically the agent config file directory)
///
/// # Returns
/// The content of the file as a String
pub fn resolve_file_uri(uri: &str, base_path: &Path) -> Result<String, FileUriError> {
// Validate URI format
if !uri.starts_with("file://") {
return Err(FileUriError::InvalidUri { uri: uri.to_string() });
}

// Extract the path part after "file://"
let path_str = uri.trim_start_matches("file://");

// Handle empty path
if path_str.is_empty() {
return Err(FileUriError::InvalidUri { uri: uri.to_string() });
}

// Resolve the path
let resolved_path = if path_str.starts_with('/') {
// Absolute path
PathBuf::from(path_str)
} else {
// Relative path - resolve relative to base_path
base_path.join(path_str)
};

// Check if file exists
if !resolved_path.exists() {
return Err(FileUriError::FileNotFound { path: resolved_path });
}

// Check if it's a file (not a directory)
if !resolved_path.is_file() {
return Err(FileUriError::FileNotFound { path: resolved_path });
}

// Read the file content
fs::read_to_string(&resolved_path)
.map_err(|source| FileUriError::ReadError {
path: resolved_path,
source
})
}

#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;

#[test]
fn test_invalid_uri_format() {
let base = Path::new("/tmp");

// Not a file:// URI
let result = resolve_file_uri("http://example.com", base);
assert!(matches!(result, Err(FileUriError::InvalidUri { .. })));

// Empty path
let result = resolve_file_uri("file://", base);
assert!(matches!(result, Err(FileUriError::InvalidUri { .. })));
}

#[test]
fn test_file_not_found() {
let base = Path::new("/tmp");

let result = resolve_file_uri("file:///nonexistent/file.txt", base);
assert!(matches!(result, Err(FileUriError::FileNotFound { .. })));
}

#[test]
fn test_absolute_path_resolution() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = TempDir::new()?;
let file_path = temp_dir.path().join("test.txt");
let content = "Hello, World!";
fs::write(&file_path, content)?;

let uri = format!("file://{}", file_path.display());
let base = Path::new("/some/other/path");

let result = resolve_file_uri(&uri, base)?;
assert_eq!(result, content);

Ok(())
}

#[test]
fn test_relative_path_resolution() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = TempDir::new()?;
let file_path = temp_dir.path().join("subdir").join("test.txt");
fs::create_dir_all(file_path.parent().unwrap())?;
let content = "Relative content";
fs::write(&file_path, content)?;

let uri = "file://subdir/test.txt";
let base = temp_dir.path();

let result = resolve_file_uri(uri, base)?;
assert_eq!(result, content);

Ok(())
}

#[test]
fn test_directory_instead_of_file() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = TempDir::new()?;
let dir_path = temp_dir.path().join("testdir");
fs::create_dir(&dir_path)?;

let uri = format!("file://{}", dir_path.display());
let base = Path::new("/tmp");

let result = resolve_file_uri(&uri, base);
assert!(matches!(result, Err(FileUriError::FileNotFound { .. })));

Ok(())
}
}
1 change: 1 addition & 0 deletions crates/chat-cli/src/util/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pub mod consts;
pub mod directories;
pub mod file_uri;
pub mod knowledge_store;
pub mod open;
pub mod pattern_matching;
Expand Down
Loading