Skip to content

Commit 22c20ae

Browse files
committed
feat: Path based loading for agent
People can now do `q chat --agent team/myagent` in addition to the current `q chat --agent myagent`. This will enable people to better organize their agents in Q CLI directory
1 parent 1776a67 commit 22c20ae

File tree

4 files changed

+206
-85
lines changed

4 files changed

+206
-85
lines changed

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

Lines changed: 193 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,7 @@ use std::collections::{
1010
HashSet,
1111
};
1212
use std::ffi::OsStr;
13-
use std::io::{
14-
self,
15-
Write,
16-
};
13+
use std::io::Write;
1714
use std::path::{
1815
Path,
1916
PathBuf,
@@ -37,7 +34,6 @@ use serde::{
3734
Serialize,
3835
};
3936
use thiserror::Error;
40-
use tokio::fs::ReadDir;
4137
use tracing::{
4238
error,
4339
info,
@@ -212,6 +208,38 @@ impl Default for Agent {
212208
}
213209

214210
impl Agent {
211+
/// Calculate the path-based identifier for this agent
212+
/// Returns the relative path from agent directory to file (without .json extension)
213+
/// Example: "team/assistant" for file at agents/team/assistant.json
214+
pub fn path_identifier(&self, os: &Os) -> Option<String> {
215+
let full_path = self.path.as_ref()?;
216+
217+
// Extract just the filename without extension for fallback
218+
let file_stem = full_path.file_stem()?.to_str()?;
219+
220+
// Try to get the actual directory paths using the proper functions
221+
// Check local workspace directory first
222+
if let Ok(local_dir) = directories::chat_local_agent_dir(os) {
223+
if let Ok(rel_path) = full_path.strip_prefix(&local_dir) {
224+
if let Some(path_str) = rel_path.with_extension("").to_str() {
225+
return Some(path_str.to_string());
226+
}
227+
}
228+
}
229+
230+
// Check global directory
231+
if let Ok(global_dir) = directories::chat_global_agent_path(os) {
232+
if let Ok(rel_path) = full_path.strip_prefix(&global_dir) {
233+
if let Some(path_str) = rel_path.with_extension("").to_str() {
234+
return Some(path_str.to_string());
235+
}
236+
}
237+
}
238+
239+
// Fallback to just filename
240+
Some(file_stem.to_string())
241+
}
242+
215243
/// This function mutates the agent to a state that is writable.
216244
/// Practically this means reverting some fields back to their original values as they were
217245
/// written in the config.
@@ -339,39 +367,23 @@ impl Agent {
339367
}
340368
}
341369

342-
/// Retrieves an agent by name. It does so via first seeking the given agent under local dir,
343-
/// and falling back to global dir if it does not exist in local.
370+
/// Retrieves an agent by name or path identifier. It does so via first seeking the given agent
371+
/// under local dir, and falling back to global dir if it does not exist in local.
372+
/// Supports both JSON name field lookup and path-based lookup (e.g., "team/assistant").
373+
/// Load all agents first and filter by both JSON name and path identifier
344374
pub async fn get_agent_by_name(os: &Os, agent_name: &str) -> eyre::Result<(Agent, PathBuf)> {
345-
let config_path: Result<PathBuf, PathBuf> = 'config: {
346-
// local first, and then fall back to looking at global
347-
let local_config_dir = directories::chat_local_agent_dir(os)?.join(format!("{agent_name}.json"));
348-
if os.fs.exists(&local_config_dir) {
349-
break 'config Ok(local_config_dir);
350-
}
375+
let mut stderr = std::io::stderr();
376+
let (agents, _) = Agents::load(&mut os.clone(), None, true, &mut stderr, true).await;
351377

352-
let global_config_dir = directories::chat_global_agent_path(os)?.join(format!("{agent_name}.json"));
353-
if os.fs.exists(&global_config_dir) {
354-
break 'config Ok(global_config_dir);
378+
for (_, agent) in agents.agents {
379+
if agent.name == agent_name || agent.path_identifier(os).as_deref() == Some(agent_name) {
380+
if let Some(path) = agent.path.clone() {
381+
return Ok((agent, path));
382+
}
355383
}
356-
357-
Err(global_config_dir)
358-
};
359-
360-
match config_path {
361-
Ok(config_path) => {
362-
let content = os.fs.read(&config_path).await?;
363-
let mut agent = serde_json::from_slice::<Agent>(&content)?;
364-
let legacy_mcp_config = if agent.use_legacy_mcp_json {
365-
load_legacy_mcp_config(os).await.unwrap_or(None)
366-
} else {
367-
None
368-
};
369-
let mut stderr = std::io::stderr();
370-
agent.thaw(&config_path, legacy_mcp_config.as_ref(), &mut stderr)?;
371-
Ok((agent, config_path))
372-
},
373-
_ => bail!("Agent {agent_name} does not exist"),
374384
}
385+
386+
bail!("Agent {agent_name} does not exist")
375387
}
376388

377389
pub async fn load(
@@ -476,14 +488,22 @@ impl Agents {
476488
self.agents.get_mut(&self.active_idx)
477489
}
478490

479-
pub fn switch(&mut self, name: &str) -> eyre::Result<&Agent> {
480-
if !self.agents.contains_key(name) {
491+
pub fn switch(&mut self, name: &str, os: &Os) -> eyre::Result<&Agent> {
492+
// Find agent by either JSON name or path identifier
493+
let matching_key = self
494+
.agents
495+
.iter()
496+
.find(|(_, agent)| agent.name.as_str() == name || agent.path_identifier(os).as_deref() == Some(name))
497+
.map(|(key, _)| key.clone());
498+
499+
if let Some(key) = matching_key {
500+
self.active_idx = key;
501+
self.agents
502+
.get(&self.active_idx)
503+
.ok_or(eyre::eyre!("No agent with name {name} found"))
504+
} else {
481505
eyre::bail!("No agent with name {name} found");
482506
}
483-
self.active_idx = name.to_string();
484-
self.agents
485-
.get(name)
486-
.ok_or(eyre::eyre!("No agent with name {name} found"))
487507
}
488508

489509
/// This function does a number of things in the following order:
@@ -558,12 +578,9 @@ impl Agents {
558578
let Ok(path) = directories::chat_local_agent_dir(os) else {
559579
break 'local Vec::<Agent>::new();
560580
};
561-
let Ok(files) = os.fs.read_dir(path).await else {
562-
break 'local Vec::<Agent>::new();
563-
};
564581

565582
let mut agents = Vec::<Agent>::new();
566-
let results = load_agents_from_entries(files, os, &mut global_mcp_config, mcp_enabled, output).await;
583+
let results = load_agents_from_directory(&path, os, &mut global_mcp_config, mcp_enabled, output).await;
567584
for result in results {
568585
match result {
569586
Ok(agent) => agents.push(agent),
@@ -588,20 +605,9 @@ impl Agents {
588605
let Ok(path) = directories::chat_global_agent_path(os) else {
589606
break 'global Vec::<Agent>::new();
590607
};
591-
let files = match os.fs.read_dir(&path).await {
592-
Ok(files) => files,
593-
Err(e) => {
594-
if matches!(e.kind(), io::ErrorKind::NotFound) {
595-
if let Err(e) = os.fs.create_dir_all(&path).await {
596-
error!("Error creating global agent dir: {:?}", e);
597-
}
598-
}
599-
break 'global Vec::<Agent>::new();
600-
},
601-
};
602608

603609
let mut agents = Vec::<Agent>::new();
604-
let results = load_agents_from_entries(files, os, &mut global_mcp_config, mcp_enabled, output).await;
610+
let results = load_agents_from_directory(&path, os, &mut global_mcp_config, mcp_enabled, output).await;
605611
for result in results {
606612
match result {
607613
Ok(agent) => agents.push(agent),
@@ -704,8 +710,14 @@ impl Agents {
704710
// 3. If the above is missing or invalid, assume the in-memory default
705711
let active_idx = 'active_idx: {
706712
if let Some(name) = agent_name {
707-
if all_agents.iter().any(|a| a.name.as_str() == name) {
708-
break 'active_idx name.to_string();
713+
// Dual lookup: try both JSON name field and path identifier
714+
if let Some(matching_agent) = all_agents.iter().find(|agent| {
715+
// Current behavior: match against JSON name field
716+
agent.name.as_str() == name ||
717+
// New behavior: match against file path identifier
718+
agent.path_identifier(os).as_deref() == Some(name)
719+
}) {
720+
break 'active_idx matching_agent.name.clone();
709721
}
710722
let _ = queue!(
711723
output,
@@ -880,24 +892,41 @@ pub struct AgentsLoadMetadata {
880892
pub launched_agent: String,
881893
}
882894

883-
async fn load_agents_from_entries(
884-
mut files: ReadDir,
895+
async fn load_agents_from_directory(
896+
dir_path: &Path,
885897
os: &Os,
886898
global_mcp_config: &mut Option<McpServerConfig>,
887899
mcp_enabled: bool,
888900
output: &mut impl Write,
889901
) -> Vec<Result<Agent, AgentConfigError>> {
890902
let mut res = Vec::<Result<Agent, AgentConfigError>>::new();
891903

892-
while let Ok(Some(file)) = files.next_entry().await {
893-
let file_path = &file.path();
894-
if file_path
895-
.extension()
896-
.and_then(OsStr::to_str)
897-
.is_some_and(|s| s == "json")
898-
{
899-
res.push(Agent::load(os, file_path, global_mcp_config, mcp_enabled, output).await);
900-
}
904+
// Check if directory exists before trying to walk it
905+
if !os.fs.exists(dir_path) {
906+
// Directory doesn't exist - return empty list (this is normal)
907+
return res;
908+
}
909+
910+
// Collect file paths in a blocking task to avoid blocking the async runtime
911+
let dir_path = dir_path.to_path_buf();
912+
let file_paths = tokio::task::spawn_blocking(move || {
913+
walkdir::WalkDir::new(&dir_path)
914+
.follow_links(false)
915+
.into_iter()
916+
.filter_map(|e| e.ok())
917+
.filter(|entry| {
918+
let path = entry.path();
919+
path.is_file() && path.extension().and_then(OsStr::to_str).is_some_and(|s| s == "json")
920+
})
921+
.map(|entry| entry.path().to_path_buf())
922+
.collect::<Vec<_>>()
923+
})
924+
.await
925+
.unwrap_or_default();
926+
927+
// Load agents asynchronously
928+
for file_path in file_paths {
929+
res.push(Agent::load(os, &file_path, global_mcp_config, mcp_enabled, output).await);
901930
}
902931

903932
res
@@ -1070,9 +1099,10 @@ mod tests {
10701099
);
10711100
}
10721101

1073-
#[test]
1074-
fn test_switch() {
1102+
#[tokio::test]
1103+
async fn test_switch() {
10751104
let mut collection = Agents::default();
1105+
let os = crate::os::Os::new().await.unwrap();
10761106

10771107
let default_agent = Agent::default();
10781108
let dev_agent = Agent {
@@ -1086,12 +1116,12 @@ mod tests {
10861116
collection.active_idx = "default".to_string();
10871117

10881118
// Test successful switch
1089-
let result = collection.switch("dev");
1119+
let result = collection.switch("dev", &os);
10901120
assert!(result.is_ok());
10911121
assert_eq!(result.unwrap().name, "dev");
10921122

10931123
// Test switch to non-existent agent
1094-
let result = collection.switch("nonexistent");
1124+
let result = collection.switch("nonexistent", &os);
10951125
assert!(result.is_err());
10961126
assert_eq!(result.unwrap_err().to_string(), "No agent with name nonexistent found");
10971127
}
@@ -1579,4 +1609,94 @@ mod tests {
15791609
let result = agent.resolve_prompt();
15801610
assert!(result.is_err());
15811611
}
1612+
1613+
#[tokio::test]
1614+
async fn test_path_identifier() {
1615+
use std::path::PathBuf;
1616+
1617+
// Create a mock Os for testing
1618+
let os = crate::os::Os::new().await.unwrap();
1619+
1620+
// Get the actual OS paths for testing
1621+
let local_dir = directories::chat_local_agent_dir(&os).unwrap();
1622+
let global_dir = directories::chat_global_agent_path(&os).unwrap();
1623+
1624+
// Test workspace agent path using actual OS paths
1625+
let mut agent = Agent::default();
1626+
agent.path = Some(local_dir.join("team/assistant.json"));
1627+
assert_eq!(agent.path_identifier(&os), Some("team/assistant".to_string()));
1628+
1629+
// Test global agent path using actual OS paths
1630+
agent.path = Some(global_dir.join("org/specialist.json"));
1631+
assert_eq!(agent.path_identifier(&os), Some("org/specialist".to_string()));
1632+
1633+
// Test nested path using actual OS paths
1634+
agent.path = Some(global_dir.join("company/team/expert.json"));
1635+
assert_eq!(agent.path_identifier(&os), Some("company/team/expert".to_string()));
1636+
1637+
// Test simple filename (fallback) - path that doesn't match agent directories
1638+
agent.path = Some(PathBuf::from("/some/other/path/simple.json"));
1639+
assert_eq!(agent.path_identifier(&os), Some("simple".to_string()));
1640+
1641+
// Test no path
1642+
agent.path = None;
1643+
assert_eq!(agent.path_identifier(&os), None);
1644+
1645+
// Test cross-platform path normalization using actual OS paths
1646+
agent.path = Some(global_dir.join("dev").join("helper.json"));
1647+
assert_eq!(agent.path_identifier(&os), Some("dev/helper".to_string()));
1648+
}
1649+
1650+
#[tokio::test]
1651+
async fn test_switch_with_path_identifier() {
1652+
let mut collection = Agents::default();
1653+
let os = crate::os::Os::new().await.unwrap();
1654+
1655+
// Get the actual OS paths for testing
1656+
let global_dir = directories::chat_global_agent_path(&os).unwrap();
1657+
1658+
// Create agents with different paths using actual OS paths
1659+
let mut agent1 = Agent {
1660+
name: "helper".to_string(),
1661+
..Default::default()
1662+
};
1663+
agent1.path = Some(global_dir.join("dev/helper.json"));
1664+
1665+
let mut agent2 = Agent {
1666+
name: "assistant".to_string(),
1667+
..Default::default()
1668+
};
1669+
agent2.path = Some(global_dir.join("team/assistant.json"));
1670+
1671+
collection.agents.insert("helper".to_string(), agent1);
1672+
collection.agents.insert("assistant".to_string(), agent2);
1673+
collection.active_idx = "helper".to_string();
1674+
1675+
// Test switch by JSON name (existing behavior)
1676+
let result = collection.switch("assistant", &os);
1677+
assert!(result.is_ok());
1678+
assert_eq!(result.unwrap().name, "assistant");
1679+
1680+
// Test switch by path identifier (new behavior)
1681+
let result = collection.switch("dev/helper", &os);
1682+
assert!(result.is_ok());
1683+
assert_eq!(result.unwrap().name, "helper");
1684+
1685+
// Test switch by nested path identifier
1686+
let result = collection.switch("team/assistant", &os);
1687+
assert!(result.is_ok());
1688+
assert_eq!(result.unwrap().name, "assistant");
1689+
1690+
// Test switch to non-existent agent (both name and path)
1691+
let result = collection.switch("nonexistent", &os);
1692+
assert!(result.is_err());
1693+
assert_eq!(result.unwrap_err().to_string(), "No agent with name nonexistent found");
1694+
1695+
let result = collection.switch("nonexistent/path", &os);
1696+
assert!(result.is_err());
1697+
assert_eq!(
1698+
result.unwrap_err().to_string(),
1699+
"No agent with name nonexistent/path found"
1700+
);
1701+
}
15821702
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,7 @@ impl AgentArgs {
286286
},
287287
Some(AgentSubcommands::SetDefault { name }) => {
288288
let mut agents = Agents::load(os, None, true, &mut stderr, mcp_enabled).await.0;
289-
match agents.switch(&name) {
289+
match agents.switch(&name, os) {
290290
Ok(agent) => {
291291
os.database
292292
.settings
@@ -352,7 +352,7 @@ pub async fn create_agent(
352352
}
353353

354354
let prepopulated_content = if let Some(from) = from {
355-
let mut agent_to_copy = agents.switch(from.as_str())?.clone();
355+
let mut agent_to_copy = agents.switch(from.as_str(), os)?.clone();
356356
agent_to_copy.name = name.clone();
357357
agent_to_copy
358358
} else {

0 commit comments

Comments
 (0)