Skip to content

Commit 6327829

Browse files
authored
feat: Add file:// URI support for agent prompts (#3024)
1 parent 28bc07a commit 6327829

File tree

5 files changed

+355
-4
lines changed

5 files changed

+355
-4
lines changed

crates/chat-cli/src/cli/agent/mod.rs

Lines changed: 178 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ use crate::util::{
6767
self,
6868
MCP_SERVER_TOOL_DELIMITER,
6969
directories,
70+
file_uri,
7071
};
7172

7273
pub const DEFAULT_AGENT_NAME: &str = "q_cli_default";
@@ -88,6 +89,12 @@ pub enum AgentConfigError {
8889
Io(#[from] std::io::Error),
8990
#[error("Failed to parse legacy mcp config: {0}")]
9091
BadLegacyMcpConfig(#[from] eyre::Report),
92+
#[error("File URI not found: {uri} (resolved to {path})")]
93+
FileUriNotFound { uri: String, path: PathBuf },
94+
#[error("Failed to read file URI: {uri} (resolved to {path}): {error}")]
95+
FileUriReadError { uri: String, path: PathBuf, error: std::io::Error },
96+
#[error("Invalid file URI format: {uri}")]
97+
InvalidFileUri { uri: String },
9198
}
9299

93100
/// An [Agent] is a declarative way of configuring a given instance of q chat. Currently, it is
@@ -221,10 +228,15 @@ impl Agent {
221228
legacy_mcp_config: Option<&McpServerConfig>,
222229
output: &mut impl Write,
223230
) -> Result<(), AgentConfigError> {
224-
let Self { mcp_servers, .. } = self;
225-
226231
self.path = Some(path.to_path_buf());
227232

233+
// Resolve file:// URIs in the prompt field
234+
if let Some(resolved_prompt) = self.resolve_prompt()? {
235+
self.prompt = Some(resolved_prompt);
236+
}
237+
238+
let Self { mcp_servers, .. } = self;
239+
228240
if let (true, Some(legacy_mcp_config)) = (self.use_legacy_mcp_json, legacy_mcp_config) {
229241
for (name, legacy_server) in &legacy_mcp_config.mcp_servers {
230242
if mcp_servers.mcp_servers.contains_key(name) {
@@ -283,6 +295,48 @@ impl Agent {
283295
Ok(serde_json::to_string_pretty(&agent_clone)?)
284296
}
285297

298+
/// Resolves the prompt field, handling file:// URIs if present.
299+
/// Returns the prompt content as-is if it doesn't start with file://,
300+
/// or resolves the file URI and returns the file content.
301+
pub fn resolve_prompt(&self) -> Result<Option<String>, AgentConfigError> {
302+
match &self.prompt {
303+
None => Ok(None),
304+
Some(prompt_str) => {
305+
if prompt_str.starts_with("file://") {
306+
// Get the base path from the agent config file path
307+
let base_path = match &self.path {
308+
Some(path) => path.parent().unwrap_or(Path::new(".")),
309+
None => Path::new("."),
310+
};
311+
312+
// Resolve the file URI
313+
match file_uri::resolve_file_uri(prompt_str, base_path) {
314+
Ok(content) => Ok(Some(content)),
315+
Err(file_uri::FileUriError::InvalidUri { uri }) => {
316+
Err(AgentConfigError::InvalidFileUri { uri })
317+
}
318+
Err(file_uri::FileUriError::FileNotFound { path }) => {
319+
Err(AgentConfigError::FileUriNotFound {
320+
uri: prompt_str.clone(),
321+
path
322+
})
323+
}
324+
Err(file_uri::FileUriError::ReadError { path, source }) => {
325+
Err(AgentConfigError::FileUriReadError {
326+
uri: prompt_str.clone(),
327+
path,
328+
error: source
329+
})
330+
}
331+
}
332+
} else {
333+
// Return the prompt as-is for backward compatibility
334+
Ok(Some(prompt_str.clone()))
335+
}
336+
}
337+
}
338+
}
339+
286340
/// Retrieves an agent by name. It does so via first seeking the given agent under local dir,
287341
/// and falling back to global dir if it does not exist in local.
288342
pub async fn get_agent_by_name(os: &Os, agent_name: &str) -> eyre::Result<(Agent, PathBuf)> {
@@ -937,6 +991,8 @@ fn validate_agent_name(name: &str) -> eyre::Result<()> {
937991
#[cfg(test)]
938992
mod tests {
939993
use serde_json::json;
994+
use std::fs;
995+
use tempfile::TempDir;
940996

941997
use super::*;
942998
use crate::cli::agent::hook::Source;
@@ -1400,4 +1456,124 @@ mod tests {
14001456
}
14011457
}
14021458
}
1459+
1460+
#[test]
1461+
fn test_resolve_prompt_file_uri_relative() {
1462+
let temp_dir = TempDir::new().unwrap();
1463+
1464+
// Create a prompt file
1465+
let prompt_content = "You are a test agent with specific instructions.";
1466+
let prompt_file = temp_dir.path().join("test-prompt.md");
1467+
fs::write(&prompt_file, prompt_content).unwrap();
1468+
1469+
// Create agent config file path
1470+
let config_file = temp_dir.path().join("test-agent.json");
1471+
1472+
// Create agent with file:// URI prompt
1473+
let agent = Agent {
1474+
name: "test-agent".to_string(),
1475+
prompt: Some("file://./test-prompt.md".to_string()),
1476+
path: Some(config_file),
1477+
..Default::default()
1478+
};
1479+
1480+
// Test resolve_prompt
1481+
let resolved = agent.resolve_prompt().unwrap();
1482+
assert_eq!(resolved, Some(prompt_content.to_string()));
1483+
}
1484+
1485+
#[test]
1486+
fn test_resolve_prompt_file_uri_absolute() {
1487+
let temp_dir = TempDir::new().unwrap();
1488+
1489+
// Create a prompt file
1490+
let prompt_content = "Absolute path prompt content.";
1491+
let prompt_file = temp_dir.path().join("absolute-prompt.md");
1492+
fs::write(&prompt_file, prompt_content).unwrap();
1493+
1494+
// Create agent with absolute file:// URI
1495+
let agent = Agent {
1496+
name: "test-agent".to_string(),
1497+
prompt: Some(format!("file://{}", prompt_file.display())),
1498+
path: Some(temp_dir.path().join("test-agent.json")),
1499+
..Default::default()
1500+
};
1501+
1502+
// Test resolve_prompt
1503+
let resolved = agent.resolve_prompt().unwrap();
1504+
assert_eq!(resolved, Some(prompt_content.to_string()));
1505+
}
1506+
1507+
#[test]
1508+
fn test_resolve_prompt_inline_unchanged() {
1509+
let temp_dir = TempDir::new().unwrap();
1510+
1511+
// Create agent with inline prompt
1512+
let inline_prompt = "This is an inline prompt.";
1513+
let agent = Agent {
1514+
name: "test-agent".to_string(),
1515+
prompt: Some(inline_prompt.to_string()),
1516+
path: Some(temp_dir.path().join("test-agent.json")),
1517+
..Default::default()
1518+
};
1519+
1520+
// Test resolve_prompt
1521+
let resolved = agent.resolve_prompt().unwrap();
1522+
assert_eq!(resolved, Some(inline_prompt.to_string()));
1523+
}
1524+
1525+
#[test]
1526+
fn test_resolve_prompt_file_not_found_error() {
1527+
let temp_dir = TempDir::new().unwrap();
1528+
1529+
// Create agent with non-existent file URI
1530+
let agent = Agent {
1531+
name: "test-agent".to_string(),
1532+
prompt: Some("file://./nonexistent.md".to_string()),
1533+
path: Some(temp_dir.path().join("test-agent.json")),
1534+
..Default::default()
1535+
};
1536+
1537+
// Test resolve_prompt should fail
1538+
let result = agent.resolve_prompt();
1539+
assert!(result.is_err());
1540+
1541+
if let Err(AgentConfigError::FileUriNotFound { uri, .. }) = result {
1542+
assert_eq!(uri, "file://./nonexistent.md");
1543+
} else {
1544+
panic!("Expected FileUriNotFound error, got: {:?}", result);
1545+
}
1546+
}
1547+
1548+
#[test]
1549+
fn test_resolve_prompt_no_prompt_field() {
1550+
let temp_dir = TempDir::new().unwrap();
1551+
1552+
// Create agent without prompt field
1553+
let agent = Agent {
1554+
name: "test-agent".to_string(),
1555+
prompt: None,
1556+
path: Some(temp_dir.path().join("test-agent.json")),
1557+
..Default::default()
1558+
};
1559+
1560+
// Test resolve_prompt
1561+
let resolved = agent.resolve_prompt().unwrap();
1562+
assert_eq!(resolved, None);
1563+
}
1564+
1565+
#[test]
1566+
fn test_resolve_prompt_no_path_set() {
1567+
// Create agent without path set (should not happen in practice)
1568+
let agent = Agent {
1569+
name: "test-agent".to_string(),
1570+
prompt: Some("file://./test.md".to_string()),
1571+
path: None,
1572+
..Default::default()
1573+
};
1574+
1575+
// Test resolve_prompt should fail gracefully
1576+
let result = agent.resolve_prompt();
1577+
assert!(result.is_err());
1578+
}
14031579
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
use std::fs;
2+
use std::path::{Path, PathBuf};
3+
4+
use eyre::Result;
5+
use thiserror::Error;
6+
7+
#[derive(Debug, Error)]
8+
pub enum FileUriError {
9+
#[error("Invalid file URI format: {uri}")]
10+
InvalidUri { uri: String },
11+
#[error("File not found: {path}")]
12+
FileNotFound { path: PathBuf },
13+
#[error("Failed to read file {path}: {source}")]
14+
ReadError { path: PathBuf, source: std::io::Error },
15+
}
16+
17+
/// Resolves a file:// URI to its content, supporting both relative and absolute paths.
18+
///
19+
/// # Arguments
20+
/// * `uri` - The file:// URI to resolve
21+
/// * `base_path` - Base path for resolving relative URIs (typically the agent config file directory)
22+
///
23+
/// # Returns
24+
/// The content of the file as a String
25+
pub fn resolve_file_uri(uri: &str, base_path: &Path) -> Result<String, FileUriError> {
26+
// Validate URI format
27+
if !uri.starts_with("file://") {
28+
return Err(FileUriError::InvalidUri { uri: uri.to_string() });
29+
}
30+
31+
// Extract the path part after "file://"
32+
let path_str = uri.trim_start_matches("file://");
33+
34+
// Handle empty path
35+
if path_str.is_empty() {
36+
return Err(FileUriError::InvalidUri { uri: uri.to_string() });
37+
}
38+
39+
// Resolve the path
40+
let resolved_path = if path_str.starts_with('/') {
41+
// Absolute path
42+
PathBuf::from(path_str)
43+
} else {
44+
// Relative path - resolve relative to base_path
45+
base_path.join(path_str)
46+
};
47+
48+
// Check if file exists
49+
if !resolved_path.exists() {
50+
return Err(FileUriError::FileNotFound { path: resolved_path });
51+
}
52+
53+
// Check if it's a file (not a directory)
54+
if !resolved_path.is_file() {
55+
return Err(FileUriError::FileNotFound { path: resolved_path });
56+
}
57+
58+
// Read the file content
59+
fs::read_to_string(&resolved_path)
60+
.map_err(|source| FileUriError::ReadError {
61+
path: resolved_path,
62+
source
63+
})
64+
}
65+
66+
#[cfg(test)]
67+
mod tests {
68+
use super::*;
69+
use std::fs;
70+
use tempfile::TempDir;
71+
72+
#[test]
73+
fn test_invalid_uri_format() {
74+
let base = Path::new("/tmp");
75+
76+
// Not a file:// URI
77+
let result = resolve_file_uri("http://example.com", base);
78+
assert!(matches!(result, Err(FileUriError::InvalidUri { .. })));
79+
80+
// Empty path
81+
let result = resolve_file_uri("file://", base);
82+
assert!(matches!(result, Err(FileUriError::InvalidUri { .. })));
83+
}
84+
85+
#[test]
86+
fn test_file_not_found() {
87+
let base = Path::new("/tmp");
88+
89+
let result = resolve_file_uri("file:///nonexistent/file.txt", base);
90+
assert!(matches!(result, Err(FileUriError::FileNotFound { .. })));
91+
}
92+
93+
#[test]
94+
fn test_absolute_path_resolution() -> Result<(), Box<dyn std::error::Error>> {
95+
let temp_dir = TempDir::new()?;
96+
let file_path = temp_dir.path().join("test.txt");
97+
let content = "Hello, World!";
98+
fs::write(&file_path, content)?;
99+
100+
let uri = format!("file://{}", file_path.display());
101+
let base = Path::new("/some/other/path");
102+
103+
let result = resolve_file_uri(&uri, base)?;
104+
assert_eq!(result, content);
105+
106+
Ok(())
107+
}
108+
109+
#[test]
110+
fn test_relative_path_resolution() -> Result<(), Box<dyn std::error::Error>> {
111+
let temp_dir = TempDir::new()?;
112+
let file_path = temp_dir.path().join("subdir").join("test.txt");
113+
fs::create_dir_all(file_path.parent().unwrap())?;
114+
let content = "Relative content";
115+
fs::write(&file_path, content)?;
116+
117+
let uri = "file://subdir/test.txt";
118+
let base = temp_dir.path();
119+
120+
let result = resolve_file_uri(uri, base)?;
121+
assert_eq!(result, content);
122+
123+
Ok(())
124+
}
125+
126+
#[test]
127+
fn test_directory_instead_of_file() -> Result<(), Box<dyn std::error::Error>> {
128+
let temp_dir = TempDir::new()?;
129+
let dir_path = temp_dir.path().join("testdir");
130+
fs::create_dir(&dir_path)?;
131+
132+
let uri = format!("file://{}", dir_path.display());
133+
let base = Path::new("/tmp");
134+
135+
let result = resolve_file_uri(&uri, base);
136+
assert!(matches!(result, Err(FileUriError::FileNotFound { .. })));
137+
138+
Ok(())
139+
}
140+
}

crates/chat-cli/src/util/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
pub mod consts;
22
pub mod directories;
3+
pub mod file_uri;
34
pub mod editor;
45
pub mod knowledge_store;
56
pub mod open;

0 commit comments

Comments
 (0)