Skip to content

Commit e6f5e99

Browse files
authored
feat(agent): root and slash subcommand for agent list, create, and rename (#2307)
* adds agent root command for create, rename, and list * adds tests * adds slash command for create and rename * rust fmt * adds post write validation * enriches error message with more detail * adds agent config dir description * addresses comments
1 parent f647ce4 commit e6f5e99

File tree

7 files changed

+429
-44
lines changed

7 files changed

+429
-44
lines changed

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,11 @@ use crate::util::{
5959

6060
mod context_migrate;
6161
mod mcp_config;
62+
mod root_command_args;
6263
mod wrapper_types;
6364

65+
pub use root_command_args::*;
66+
6467
/// An [Agent] is a declarative way of configuring a given instance of q chat. Currently, it is
6568
/// impacting q chat in via influenicng [ContextManager] and [ToolManager].
6669
/// Changes made to [ContextManager] and [ToolManager] do not persist across sessions.
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
use std::io::Write;
2+
use std::path::PathBuf;
3+
use std::process::ExitCode;
4+
5+
use clap::{
6+
Args,
7+
Subcommand,
8+
};
9+
use eyre::{
10+
Result,
11+
bail,
12+
};
13+
14+
use super::{
15+
Agent,
16+
Agents,
17+
};
18+
use crate::database::settings::Setting;
19+
use crate::os::Os;
20+
use crate::util::directories::{
21+
self,
22+
agent_config_dir,
23+
};
24+
25+
#[derive(Clone, Debug, Subcommand, PartialEq, Eq)]
26+
pub enum AgentSubcommands {
27+
/// List the available agents. Note that local agents are only discovered if the command is
28+
/// invoked at a directory that contains them
29+
List,
30+
/// Renames a given agent to a new name
31+
Rename {
32+
/// Original name of the agent
33+
#[arg(long, short)]
34+
agent: String,
35+
/// New name the agent shall be changed to
36+
#[arg(long, short)]
37+
new_name: String,
38+
},
39+
/// Create an agent config. If path is not provided, Q CLI shall create this config in the
40+
/// global agent directory
41+
Create {
42+
/// Name of the agent to be created
43+
#[arg(long, short)]
44+
name: String,
45+
/// The directory where the agent will be saved. If not provided, the agent will be saved in
46+
/// the global agent directory
47+
#[arg(long, short)]
48+
directory: Option<String>,
49+
/// The name of an agent that shall be used as the starting point for the agent creation
50+
#[arg(long, short)]
51+
from: Option<String>,
52+
},
53+
}
54+
55+
#[derive(Debug, Clone, PartialEq, Eq, Default, Args)]
56+
pub struct AgentArgs {
57+
#[command(subcommand)]
58+
cmd: Option<AgentSubcommands>,
59+
}
60+
61+
impl AgentArgs {
62+
pub async fn execute(self, os: &mut Os) -> Result<ExitCode> {
63+
let mut stderr = std::io::stderr();
64+
let mut agents = Agents::load(os, None, true, &mut stderr).await;
65+
match self.cmd {
66+
Some(AgentSubcommands::List) | None => {
67+
let agent_with_path =
68+
agents
69+
.agents
70+
.into_iter()
71+
.fold(Vec::<(String, String)>::new(), |mut acc, (name, agent)| {
72+
acc.push((
73+
name,
74+
agent
75+
.path
76+
.and_then(|p| p.parent().map(|p| p.to_string_lossy().to_string()))
77+
.unwrap_or("**No path found**".to_string()),
78+
));
79+
acc
80+
});
81+
let max_name_length = agent_with_path.iter().map(|(name, _)| name.len()).max().unwrap_or(0);
82+
let output_str = agent_with_path
83+
.into_iter()
84+
.map(|(name, path)| format!("{name:<width$} {path}", width = max_name_length))
85+
.collect::<Vec<_>>()
86+
.join("\n");
87+
88+
writeln!(stderr, "{}", output_str)?;
89+
},
90+
Some(AgentSubcommands::Create { name, directory, from }) => {
91+
let path_with_file_name = create_agent(os, &mut agents, name.clone(), directory, from).await?;
92+
let editor_cmd = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string());
93+
let mut cmd = std::process::Command::new(editor_cmd);
94+
95+
let status = cmd.arg(&path_with_file_name).status()?;
96+
if !status.success() {
97+
bail!("Editor process did not exit with success");
98+
}
99+
100+
let Ok(content) = os.fs.read(&path_with_file_name).await else {
101+
bail!(
102+
"Post write validation failed. Error opening {}. Aborting",
103+
path_with_file_name.display()
104+
);
105+
};
106+
if let Err(e) = serde_json::from_slice::<Agent>(&content) {
107+
bail!(
108+
"Post write validation failed for agent '{name}' at path: {}. Malformed config detected: {e}",
109+
path_with_file_name.display()
110+
);
111+
}
112+
113+
writeln!(
114+
stderr,
115+
"\n📁 Created agent {} '{}'\n",
116+
name,
117+
path_with_file_name.display()
118+
)?;
119+
},
120+
Some(AgentSubcommands::Rename { agent, new_name }) => {
121+
rename_agent(os, &mut agents, agent.clone(), new_name.clone()).await?;
122+
writeln!(stderr, "\n✓ Renamed agent '{}' to '{}'\n", agent, new_name)?;
123+
},
124+
}
125+
Ok(ExitCode::SUCCESS)
126+
}
127+
}
128+
129+
pub async fn create_agent(
130+
os: &mut Os,
131+
agents: &mut Agents,
132+
name: String,
133+
path: Option<String>,
134+
from: Option<String>,
135+
) -> Result<PathBuf> {
136+
let path = if let Some(path) = path {
137+
let path = PathBuf::from(path);
138+
139+
// If path points to a file, strip the filename to get the directory
140+
if !path.is_dir() {
141+
bail!("Path must be a directory");
142+
}
143+
144+
let last_three_segments = agent_config_dir();
145+
if path.ends_with(&last_three_segments) {
146+
path
147+
} else {
148+
path.join(&last_three_segments)
149+
}
150+
} else {
151+
directories::chat_global_agent_path(os)?
152+
};
153+
154+
if let Some((name, _)) = agents.agents.iter().find(|(agent_name, agent)| {
155+
&name == *agent_name
156+
&& agent
157+
.path
158+
.as_ref()
159+
.is_some_and(|agent_path| agent_path.parent().is_some_and(|parent| parent == path))
160+
}) {
161+
bail!("Agent with name {name} already exists. Aborting");
162+
}
163+
164+
let prepopulated_content = if let Some(from) = from {
165+
let agent_to_copy = agents.switch(from.as_str())?;
166+
serde_json::to_string_pretty(agent_to_copy)?
167+
} else {
168+
Default::default()
169+
};
170+
let path_with_file_name = path.join(format!("{name}.json"));
171+
172+
if !path.exists() {
173+
os.fs.create_dir_all(&path).await?;
174+
}
175+
os.fs.create_new(&path_with_file_name).await?;
176+
os.fs.write(&path_with_file_name, prepopulated_content).await?;
177+
178+
Ok(path_with_file_name)
179+
}
180+
181+
pub async fn rename_agent(os: &mut Os, agents: &mut Agents, agent: String, new_name: String) -> Result<()> {
182+
if agents.agents.iter().any(|(name, _)| name == &new_name) {
183+
bail!("New name {new_name} already exists in the current scope. Aborting");
184+
}
185+
186+
match agents.switch(agent.as_str()) {
187+
Ok(target_agent) => {
188+
if let Some(path) = target_agent.path.as_ref() {
189+
let new_path = path
190+
.parent()
191+
.map(|p| p.join(format!("{new_name}.json")))
192+
.ok_or(eyre::eyre!("Failed to retrieve parent directory of target config"))?;
193+
os.fs.rename(path, new_path).await?;
194+
195+
if let Some(default_agent) = os.database.settings.get_string(Setting::ChatDefaultAgent) {
196+
let global_agent_path = directories::chat_global_agent_path(os)?;
197+
if default_agent == agent
198+
&& target_agent
199+
.path
200+
.as_ref()
201+
.is_some_and(|p| p.parent().is_some_and(|p| p == global_agent_path))
202+
{
203+
os.database.settings.set(Setting::ChatDefaultAgent, new_name).await?;
204+
}
205+
}
206+
} else {
207+
bail!("Target agent has no path associated. Aborting");
208+
}
209+
},
210+
Err(e) => {
211+
bail!(e);
212+
},
213+
}
214+
215+
Ok(())
216+
}
217+
218+
#[cfg(test)]
219+
mod tests {
220+
use super::*;
221+
use crate::cli::RootSubcommand;
222+
use crate::util::test::assert_parse;
223+
224+
#[test]
225+
fn test_agent_subcommand_list() {
226+
assert_parse!(
227+
["agent", "list"],
228+
RootSubcommand::Agent(AgentArgs {
229+
cmd: Some(AgentSubcommands::List)
230+
})
231+
);
232+
}
233+
234+
#[test]
235+
fn test_agent_subcommand_create() {
236+
assert_parse!(
237+
["agent", "create", "--name", "some_agent", "--from", "some_old_agent"],
238+
RootSubcommand::Agent(AgentArgs {
239+
cmd: Some(AgentSubcommands::Create {
240+
name: "some_agent".to_string(),
241+
directory: None,
242+
from: Some("some_old_agent".to_string())
243+
})
244+
})
245+
);
246+
}
247+
248+
#[test]
249+
fn test_agent_subcommand_rename() {
250+
assert_parse!(
251+
["agent", "rename", "--agent", "old_name", "--new-name", "new_name"],
252+
RootSubcommand::Agent(AgentArgs {
253+
cmd: Some(AgentSubcommands::Rename {
254+
agent: "old_name".to_string(),
255+
new_name: "new_name".to_string(),
256+
})
257+
})
258+
);
259+
}
260+
}

0 commit comments

Comments
 (0)