@@ -90,6 +90,8 @@ pub enum AgentConfigError {
90
90
Io ( #[ from] std:: io:: Error ) ,
91
91
#[ error( "Failed to parse legacy mcp config: {0}" ) ]
92
92
BadLegacyMcpConfig ( #[ from] eyre:: Report ) ,
93
+ #[ error( "Agent configuration error: {0}" ) ]
94
+ Custom ( Box < str > ) ,
93
95
}
94
96
95
97
/// An [Agent] is a declarative way of configuring a given instance of q chat. Currently, it is
@@ -311,6 +313,40 @@ impl Agent {
311
313
agent. thaw ( agent_path. as_ref ( ) , global_mcp_config. as_ref ( ) ) ?;
312
314
Ok ( agent)
313
315
}
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
+ }
314
350
}
315
351
316
352
#[ derive( Debug , PartialEq ) ]
@@ -720,6 +756,21 @@ impl Agents {
720
756
/// Provide default permission labels for the built-in set of tools.
721
757
// This "static" way avoids needing to construct a tool instance.
722
758
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
+
723
774
let label = match tool_name {
724
775
"fs_read" => "trusted" . dark_green ( ) . bold ( ) ,
725
776
"fs_write" => "not trusted" . dark_grey ( ) ,
@@ -736,6 +787,55 @@ impl Agents {
736
787
737
788
format ! ( "{} {label}" , "*" . reset( ) )
738
789
}
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
+ }
739
839
}
740
840
741
841
/// Metadata from the executed [Agents::load] operation.
0 commit comments