Skip to content

Commit 826db79

Browse files
author
Olivier Mansour
committed
Add interactive 'a' option for execute tools with 4-option menu to create
allowed command patterns. Patterns are immediately saved to disk if you use an agent. in /tools, display the allowed commands
1 parent 9470d66 commit 826db79

File tree

3 files changed

+649
-38
lines changed

3 files changed

+649
-38
lines changed

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

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ pub enum AgentConfigError {
9090
Io(#[from] std::io::Error),
9191
#[error("Failed to parse legacy mcp config: {0}")]
9292
BadLegacyMcpConfig(#[from] eyre::Report),
93+
#[error("Agent configuration error: {0}")]
94+
Custom(Box<str>),
9395
}
9496

9597
/// An [Agent] is a declarative way of configuring a given instance of q chat. Currently, it is
@@ -311,6 +313,40 @@ impl Agent {
311313
agent.thaw(agent_path.as_ref(), global_mcp_config.as_ref())?;
312314
Ok(agent)
313315
}
316+
317+
/// Save the agent configuration to disk
318+
pub async fn save(&mut self, os: &Os) -> Result<(), AgentConfigError> {
319+
let path = self
320+
.path
321+
.as_ref()
322+
.ok_or_else(|| AgentConfigError::Custom("Agent has no associated file path".into()))?;
323+
324+
// Create a copy for serialization (freeze it to remove runtime-only fields)
325+
let mut agent_to_save = self.clone();
326+
agent_to_save.freeze();
327+
328+
// Serialize to JSON with pretty formatting
329+
let json_content = serde_json::to_string_pretty(&agent_to_save)
330+
.map_err(|e| AgentConfigError::Custom(format!("Failed to serialize agent: {}", e).into()))?;
331+
332+
// Write to a temporary file first for atomic operation
333+
let temp_path = path.with_extension("json.tmp");
334+
335+
// Write to temporary file
336+
os.fs
337+
.write(&temp_path, json_content.as_bytes())
338+
.await
339+
.map_err(|e| AgentConfigError::Custom(format!("Failed to write temporary file: {}", e).into()))?;
340+
341+
// Atomically rename temporary file to final file
342+
os.fs.rename(&temp_path, path).await.map_err(|e| {
343+
// Clean up temporary file on failure
344+
let _ = std::fs::remove_file(&temp_path);
345+
AgentConfigError::Custom(format!("Failed to save agent file: {}", e).into())
346+
})?;
347+
348+
Ok(())
349+
}
314350
}
315351

316352
#[derive(Debug, PartialEq)]
@@ -720,6 +756,21 @@ impl Agents {
720756
/// Provide default permission labels for the built-in set of tools.
721757
// This "static" way avoids needing to construct a tool instance.
722758
fn default_permission_label(&self, tool_name: &str) -> String {
759+
// Handle execute tools with custom labels first (preserving early return)
760+
#[cfg(not(windows))]
761+
if tool_name == "execute_bash" {
762+
if let Some(custom_label) = self.get_execute_tool_label("execute_bash") {
763+
return format!("{} {}", "*".reset(), custom_label);
764+
}
765+
}
766+
767+
#[cfg(windows)]
768+
if tool_name == "execute_cmd" {
769+
if let Some(custom_label) = self.get_execute_tool_label("execute_cmd") {
770+
return format!("{} {}", "*".reset(), custom_label);
771+
}
772+
}
773+
723774
let label = match tool_name {
724775
"fs_read" => "trusted".dark_green().bold(),
725776
"fs_write" => "not trusted".dark_grey(),
@@ -736,6 +787,55 @@ impl Agents {
736787

737788
format!("{} {label}", "*".reset())
738789
}
790+
791+
/// Get the display label for execute tools (execute_bash/execute_cmd) with allowedCommands
792+
fn get_execute_tool_label(&self, tool_name: &str) -> Option<String> {
793+
let agent = self.get_active()?;
794+
let settings = agent.tools_settings.get(tool_name)?;
795+
let parsed_settings = serde_json::from_value::<serde_json::Value>(settings.clone()).ok()?;
796+
797+
// Check for allowedCommands
798+
let allowed_commands = parsed_settings
799+
.get("allowedCommands")
800+
.and_then(|v| v.as_array())
801+
.filter(|arr| !arr.is_empty())
802+
.and_then(|commands_array| {
803+
let commands: Vec<String> = commands_array
804+
.iter()
805+
.filter_map(|v| v.as_str().map(|s| s.to_string()))
806+
.collect();
807+
808+
if commands.is_empty() {
809+
return None;
810+
}
811+
812+
let commands_display = if commands.len() <= 3 {
813+
commands.join(", ")
814+
} else {
815+
format!("{}, ... (+{} more)", commands[..2].join(", "), commands.len() - 2)
816+
};
817+
818+
Some(format!("allowed: [{}]", commands_display))
819+
});
820+
821+
// Check for allowReadOnly (defaults to true if not specified)
822+
let allow_read_only = parsed_settings
823+
.get("allowReadOnly")
824+
.and_then(|v| v.as_bool())
825+
.unwrap_or(true);
826+
827+
// Combine the information
828+
match (allowed_commands, allow_read_only) {
829+
(Some(commands), true) => Some(
830+
format!("{}, trust read-only commands", commands)
831+
.dark_grey()
832+
.to_string(),
833+
),
834+
(Some(commands), false) => Some(commands.dark_grey().to_string()),
835+
(None, true) => None, // Fall back to default "trust read-only commands"
836+
(None, false) => Some("no commands allowed".dark_grey().to_string()),
837+
}
838+
}
739839
}
740840

741841
/// Metadata from the executed [Agents::load] operation.

0 commit comments

Comments
 (0)