Skip to main content

aagt_core/skills/
mod.rs

1pub mod tool;
2pub mod capabilities;
3pub mod runtime;
4pub mod compiler;
5
6use std::path::{Path, PathBuf};
7use std::collections::HashMap;
8use std::sync::Arc;
9use dashmap::DashMap;
10
11use async_trait::async_trait;
12use serde::{Deserialize, Serialize};
13use serde_json::{json, Value};
14use tracing::{info, warn};
15
16use crate::error::{Error, Result};
17use crate::skills::tool::{Tool, ToolDefinition};
18use crate::agent::context::ContextInjector;
19use crate::agent::message::Message;
20#[cfg(feature = "trading")]
21use crate::trading::risk::RiskManager;
22#[cfg(feature = "trading")]
23use crate::trading::strategy::{Action, ActionExecutor};
24
25/// Metadata extracted from a `SKILL.md` frontmatter
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct SkillMetadata {
28    /// Name of the skill
29    pub name: String,
30    /// Short description
31    pub description: String,
32    /// Optional homepage URL
33    pub homepage: Option<String>,
34    /// Arguments schema (JSON Schema) - DEPRECATED: use parameters_ts
35    pub parameters: Option<Value>,
36    /// Arguments as TypeScript interface (Preferred)
37    pub interface: Option<String>,
38    /// Script to execute
39    pub script: Option<String>,
40    /// Language or runtime for the script
41    pub runtime: Option<String>,
42    /// Standard ClawHub metadata object
43    #[serde(default)]
44    pub metadata: Value,
45    /// Kind of skill (e.g., 'tool', 'knowledge', 'agent')
46    #[serde(default = "default_skill_kind")]
47    pub kind: String,
48    /// Optional usage guidelines for LLM reasoning
49    pub usage_guidelines: Option<String>,
50}
51
52fn default_skill_kind() -> String {
53    "tool".to_string()
54}
55
56/// Configuration for skill execution
57#[derive(Debug, Clone)]
58pub struct SkillExecutionConfig {
59    /// Maximum execution time in seconds
60    pub timeout_secs: u64,
61    /// Maximum output size in bytes (to prevent memory exhaustion)
62    pub max_output_bytes: usize,
63    /// Whether to allow network access (future: implement via sandbox)
64    pub allow_network: bool,
65    /// Custom environment variables
66    pub env_vars: HashMap<String, String>,
67}
68
69impl Default for SkillExecutionConfig {
70    fn default() -> Self {
71        Self {
72            timeout_secs: 30,
73            max_output_bytes: 1024 * 1024, // 1MB
74            allow_network: false,
75            env_vars: HashMap::new(),
76        }
77    }
78}
79
80/// A skill that executes an external script
81pub struct DynamicSkill {
82    metadata: SkillMetadata,
83    instructions: String,
84    base_dir: PathBuf,
85    #[cfg(feature = "trading")]
86    risk_manager: Option<Arc<RiskManager>>,
87    #[cfg(feature = "trading")]
88    executor: Option<Arc<dyn ActionExecutor>>,
89    execution_config: SkillExecutionConfig,
90    wasm_runtime: Arc<crate::skills::runtime::WasmRuntime>,
91}
92
93impl DynamicSkill {
94    /// Create a new dynamic skill
95    pub fn new(metadata: SkillMetadata, instructions: String, base_dir: PathBuf) -> Self {
96        Self {
97            metadata,
98            instructions,
99            base_dir,
100            #[cfg(feature = "trading")]
101            risk_manager: None,
102            #[cfg(feature = "trading")]
103            executor: None,
104            execution_config: SkillExecutionConfig::default(),
105            wasm_runtime: Arc::new(crate::skills::runtime::WasmRuntime::new().expect("Failed to init WasmRuntime")),
106        }
107    }
108
109    /// Set a risk manager for validating proposals
110    #[cfg(feature = "trading")]
111    pub fn with_risk_manager(mut self, risk_manager: Arc<RiskManager>) -> Self {
112        self.risk_manager = Some(risk_manager);
113        self
114    }
115
116    /// Set an action executor for executing approved proposals
117    #[cfg(feature = "trading")]
118    pub fn with_executor(mut self, executor: Arc<dyn ActionExecutor>) -> Self {
119        self.executor = Some(executor);
120        self
121    }
122
123    /// Set custom execution configuration
124    pub fn with_execution_config(mut self, config: SkillExecutionConfig) -> Self {
125        self.execution_config = config;
126        self
127    }
128
129    /// Access metadata
130    pub fn metadata(&self) -> &SkillMetadata {
131        &self.metadata
132    }
133}
134
135#[cfg(feature = "trading")]
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct Proposal {
138    pub from_token: String,
139    pub to_token: String,
140    pub amount_usd: rust_decimal::Decimal,
141    /// Amount string for the action (e.g. "100", "50%", "max")
142    pub amount: String,
143    pub expected_slippage: Option<rust_decimal::Decimal>,
144}
145
146#[async_trait]
147impl Tool for DynamicSkill {
148    fn name(&self) -> String {
149        self.metadata.name.clone()
150    }
151
152    async fn definition(&self) -> ToolDefinition {
153        ToolDefinition {
154            name: self.metadata.name.clone(),
155            description: self.metadata.description.clone(),
156            parameters: self.metadata.parameters.clone().unwrap_or(json!({})),
157            parameters_ts: self.metadata.interface.clone(),
158            is_binary: self.metadata.runtime.as_deref() == Some("wasm"),
159            is_verified: false, // Default to unverified
160            usage_guidelines: self.metadata.usage_guidelines.clone(),
161        }
162    }
163
164
165
166    async fn call(&self, arguments: &str) -> anyhow::Result<String> {
167        let runtime_type = self.metadata.runtime.as_deref().unwrap_or("python3");
168
169        let interpreter = match runtime_type {
170            "python" | "python3" => "python3",
171            "bash" | "sh" => "bash",
172            "node" | "js" => "node",
173            "wasm" => "wasm",
174            lang => lang
175        };
176
177        if interpreter == "wasm" {
178            let wasm_file = self.metadata.script.as_ref().ok_or_else(|| {
179                Error::tool_execution(self.name(), "No wasm file defined for this skill".to_string())
180            })?;
181            let wasm_path = self.base_dir.join("scripts").join(wasm_file);
182            info!(tool = %self.name(), "Executing Wasm skill");
183            return self.wasm_runtime.call(&wasm_path, arguments).map_err(|e| e.into());
184        }
185
186        let script_file = self.metadata.script.as_ref().ok_or_else(|| {
187             Error::tool_execution(self.name(), "No script defined for this skill".to_string())
188        })?;
189
190        let script_rel_path = Path::new("scripts").join(script_file);
191        let script_full_path = self.base_dir.join(&script_rel_path);
192
193        if !script_full_path.exists() {
194             return Err(Error::tool_execution(
195                 self.name(),
196                 format!("Script not found at {:?}", script_full_path),
197             ).into());
198        }
199        
200        info!(tool = %self.name(), "Executing dynamic skill (Runtime: {})", runtime_type);
201
202        let has_bwrap = which::which("bwrap").is_ok();
203        let unsafe_override = std::env::var("AAGT_UNSAFE_SKILL_EXEC").map(|v| v == "true").unwrap_or(false);
204        let timeout = std::time::Duration::from_secs(self.execution_config.timeout_secs);
205
206        if !has_bwrap && !unsafe_override {
207             return Err(Error::tool_execution(
208                  self.name(), 
209                 "Security Error: 'bwrap' (Bubblewrap) sandbox is not installed on the system. Cannot execute skill securely. \
210                 To bypass this for testing, set AAGT_UNSAFE_SKILL_EXEC=true."
211             ).into());
212        }
213
214        let output = if !has_bwrap && unsafe_override {
215            warn!(tool = %self.name(), "UNSAFE EXECUTION: Running skill without Bubblewrap sandbox override.");
216            let mut cmd = tokio::process::Command::new(interpreter);
217            cmd.arg(&script_full_path);
218            cmd.arg(arguments);
219            cmd.stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::piped());
220            for (key, value) in &self.execution_config.env_vars { cmd.env(key, value); }
221            
222            let child = cmd.spawn()
223                .map_err(|e| Error::ToolExecution { 
224                    tool_name: self.name(), 
225                    message: format!("Failed to spawn process: {}", e) 
226                })?;
227
228            tokio::time::timeout(timeout, child.wait_with_output())
229                .await
230                .map_err(|_| Error::ToolExecution { 
231                    tool_name: self.name(), 
232                    message: "Execution timed out".to_string() 
233                })?
234                .map_err(|e| Error::ToolExecution { 
235                    tool_name: self.name(), 
236                    message: format!("Process failed: {}", e) 
237                })?
238        } else {
239            let mut cmd = tokio::process::Command::new("bwrap");
240            cmd.arg("--ro-bind").arg("/").arg("/");
241            cmd.arg("--dev").arg("/dev");
242            cmd.arg("--proc").arg("/proc");
243            cmd.arg("--tmpfs").arg("/tmp");
244            if let Ok(cwd) = std::env::current_dir() {
245                cmd.arg("--bind").arg(&cwd).arg(&cwd);
246            }
247            if !self.execution_config.allow_network {
248                cmd.arg("--unshare-net");
249            }
250            cmd.arg(interpreter).arg(&script_full_path).arg(arguments);
251            cmd.stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::piped());
252            for (key, value) in &self.execution_config.env_vars { cmd.env(key, value); }
253
254            let child = cmd.spawn()
255                .map_err(|e| Error::ToolExecution { 
256                    tool_name: self.name(), 
257                    message: format!("Failed to spawn process: {}", e) 
258                })?;
259
260            tokio::time::timeout(timeout, child.wait_with_output())
261                .await
262                .map_err(|_| Error::ToolExecution { 
263                    tool_name: self.name(), 
264                    message: "Execution timed out".to_string() 
265                })?
266                .map_err(|e| Error::ToolExecution { 
267                    tool_name: self.name(), 
268                    message: format!("Process failed: {}", e) 
269                })?
270        };
271
272        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
273        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
274
275        if !output.status.success() {
276            return Err(Error::ToolExecution {
277                tool_name: self.name(),
278                message: format!("Script error (exit code {}): {}\nStderr: {}", 
279                    output.status.code().unwrap_or(-1), stdout, stderr)
280            }.into());
281        }
282
283        // 🛡️ Safety Check: Parse for Proposals (Trading logic)
284        #[cfg(feature = "trading")]
285        if let Ok(value) = serde_json::from_str::<serde_json::Value>(&stdout) {
286            if value.get("type").and_then(|t| t.as_str()) == Some("proposal") {
287                if let Some(proposal_data) = value.get("data") {
288                    let proposal: Proposal = serde_json::from_value(proposal_data.clone())
289                        .map_err(|e| Error::tool_execution(self.name(), format!("Malformed proposal: {}", e)))?;
290
291                    info!("Skill {} generated a transaction proposal: {:?}", self.name(), proposal);
292
293                    if let Some(ref rm) = self.risk_manager {
294                        let context = crate::trading::risk::TradeContext {
295                            user_id: "default_user".to_string(), 
296                            from_token: proposal.from_token.clone(),
297                            to_token: proposal.to_token.clone(),
298                            amount_usd: proposal.amount_usd,
299                            expected_slippage: proposal.expected_slippage.unwrap_or(rust_decimal_macros::dec!(1.0)),
300                            liquidity_usd: None,
301                            is_flagged: false,
302                        };
303
304                        rm.check_and_reserve(&context).await
305                            .map_err(|e| Error::tool_execution(self.name(), format!("Risk Check Denied: {}", e)))?;
306
307                        if let Some(ref executor) = self.executor {
308                             let action = Action::Swap {
309                                 from_token: proposal.from_token,
310                                 to_token: proposal.to_token,
311                                 amount: proposal.amount,
312                             };
313                             let pipeline_ctx = crate::trading::pipeline::Context::new(format!("Skill execution: {}", self.name()));
314                             let result = match executor.execute(&action, &pipeline_ctx).await {
315                                Ok(res) => res,
316                                Err(e) => {
317                                    warn!("Skill execution failed, rolling back risk reservation: {}", e);
318                                    rm.rollback_trade(&context.user_id, context.amount_usd).await;
319                                    return Err(Error::tool_execution(self.name(), format!("Execution Failed (Rolled Back): {}", e)).into());
320                                }
321                             };
322                             rm.commit_trade(&context.user_id, context.amount_usd).await?;
323                             return Ok(format!("SUCCESS: Trade executed: {}", result));
324                        } else {
325                            rm.commit_trade(&context.user_id, context.amount_usd).await?;
326                            return Ok(format!("SIMULATION SUCCESS: Trade approved but NO EXECUTOR configured. Proposal: {:?}", proposal));
327                        }
328                    } else {
329                        return Err(Error::tool_execution(self.name(), "RiskManager not configured, cannot execute risky proposal".to_string()).into());
330                    }
331                }
332            }
333        }
334
335        Ok(stdout)
336    }
337}
338
339/// Registry and loader for dynamic skills
340pub struct SkillLoader {
341    pub skills: DashMap<String, Arc<DynamicSkill>>,
342    pub base_path: PathBuf,
343    #[cfg(feature = "trading")]
344    risk_manager: Option<Arc<RiskManager>>,
345    #[cfg(feature = "trading")]
346    executor: Option<Arc<dyn ActionExecutor>>,
347}
348
349impl SkillLoader {
350    /// Create a new registry
351    pub fn new(base_path: impl Into<PathBuf>) -> Self {
352        Self {
353            skills: DashMap::new(),
354            base_path: base_path.into(),
355            #[cfg(feature = "trading")]
356            risk_manager: None,
357            #[cfg(feature = "trading")]
358            executor: None,
359        }
360    }
361
362    /// Set a risk manager for all loaded skills
363    #[cfg(feature = "trading")]
364    pub fn with_risk_manager(mut self, risk_manager: Arc<RiskManager>) -> Self {
365        self.risk_manager = Some(risk_manager);
366        self
367    }
368
369    /// Set an executor for all loaded skills
370    #[cfg(feature = "trading")]
371    pub fn with_executor(mut self, executor: Arc<dyn ActionExecutor>) -> Self {
372        self.executor = Some(executor);
373        self
374    }
375
376    /// Load all skills from the base directory
377    pub async fn load_all(&self) -> Result<()> {
378        if !self.base_path.exists() {
379            return Ok(());
380        }
381
382        let mut entries = tokio::fs::read_dir(&self.base_path).await?;
383        while let Some(entry) = entries.next_entry().await? {
384            let path = entry.path();
385            if path.is_dir() {
386                if let Ok(skill) = self.load_skill(&path).await {
387                    #[cfg(feature = "trading")]
388                    let mut skill = skill;
389                    #[cfg(feature = "trading")]
390                    {
391                        if let Some(ref rm) = self.risk_manager {
392                            skill = skill.with_risk_manager(Arc::clone(rm));
393                        }
394                        if let Some(ref exec) = self.executor {
395                            skill = skill.with_executor(Arc::clone(exec));
396                        }
397                    }
398                    info!("Loaded dynamic skill: {}", skill.name());
399                    self.skills.insert(skill.name(), Arc::new(skill));
400                }
401            }
402        }
403        Ok(())
404    }
405
406    pub async fn load_skill(&self, path: &Path) -> Result<DynamicSkill> {
407        let manifest_path = path.join("SKILL.md");
408        if !manifest_path.exists() {
409            return Err(Error::Internal("No SKILL.md found".to_string()));
410        }
411
412        let content = tokio::fs::read_to_string(&manifest_path).await?;
413        
414        // Find frontmatter delimiters
415        let start_delimiter = "---\n";
416        let end_delimiter = "\n---";
417        
418        // Normalize line endings for delimiter search? 
419        // Or just search based on robust logic.
420        // Let's assume \n or \r\n. 
421        // We will look for "\n---" which indicates the end delimiter on its own line.
422        
423        let yaml_str;
424        let instructions;
425
426        // Ensure file starts with YAML frontmatter
427        if content.starts_with(start_delimiter) || content.starts_with("---\r\n") {
428             // Find end of frontmatter
429             if let Some(end_idx) = content[4..].find(end_delimiter) {
430                 let actual_end_idx = end_idx + 4; // Add back the initial offset
431                 yaml_str = &content[4..actual_end_idx]; // Exclude delimiters
432                 
433                 // Instructions start after the end delimiter + delimiter length (4 chars for \n---)
434                 // content[actual_end_idx] starts with \n. 
435                 // \n--- is 4 chars.
436                 let rest_start = actual_end_idx + 4;
437                 if rest_start < content.len() {
438                     instructions = content[rest_start..].trim().to_string();
439                 } else {
440                     instructions = String::new();
441                 }
442             } else {
443                 return Err(Error::Internal("SKILL.md frontmatter unclosed (missing closing ---)".to_string()));
444             }
445        } else {
446             return Err(Error::Internal("SKILL.md must start with ---".to_string()));
447        }
448
449        let mut metadata: SkillMetadata = serde_yaml_ng::from_str(yaml_str)
450            .map_err(|e| Error::Internal(format!("Failed to parse Skill YAML: {}", e)))?;
451        
452        // --- 兼容性提升: 自动推断缺失的元数据 ---
453        // 如果 frontmatter 缺失 script 或 runtime,尝试从 Markdown 正文中解析
454        if metadata.script.is_none() || metadata.runtime.is_none() {
455            // 查找常见的脚本执行模式,例如: ```bash \n python3 .../scripts/xxx.py \n ```
456            // 或者直接查找 scripts/ 目录下的第一个文件
457            if metadata.script.is_none() {
458                let scripts_dir = path.join("scripts");
459                if scripts_dir.exists() {
460                    if let Ok(mut entries) = std::fs::read_dir(scripts_dir) {
461                        if let Some(Ok(first_entry)) = entries.next() {
462                            let filename = first_entry.file_name().to_string_lossy().to_string();
463                            metadata.script = Some(filename.clone());
464                            
465                            // 根据后缀推断 runtime
466                            if metadata.runtime.is_none() {
467                                if filename.ends_with(".py") {
468                                    metadata.runtime = Some("python3".into());
469                                } else if filename.ends_with(".js") {
470                                    metadata.runtime = Some("node".into());
471                                } else if filename.ends_with(".sh") {
472                                    metadata.runtime = Some("bash".into());
473                                }
474                            }
475                        }
476                    }
477                }
478            }
479
480            // 如果还是没找到,尝试从代码块中解析执行指令
481            if metadata.runtime.is_none() {
482                if instructions.contains("python3") {
483                    metadata.runtime = Some("python3".into());
484                } else if instructions.contains("node") {
485                    metadata.runtime = Some("node".into());
486                } else if instructions.contains("bash") || instructions.contains("sh ") {
487                    metadata.runtime = Some("bash".into());
488                }
489            }
490        }
491        
492        Ok(DynamicSkill::new(metadata, instructions, path.to_path_buf()))
493    }
494}
495
496#[async_trait::async_trait]
497impl ContextInjector for SkillLoader {
498    async fn inject(&self) -> Result<Vec<Message>> {
499        if self.skills.is_empty() {
500            return Ok(Vec::new());
501        }
502
503        let mut content = String::from("## Available Skills\n\n");
504        content.push_str("You have the following skills available via `read_skill_manual`:\n\n");
505
506        for skill_ref in self.skills.iter() {
507            let skill = skill_ref.value();
508            content.push_str(&format!("- **{}**: {}\n", skill.name(), skill.metadata.description));
509        }
510
511        content.push_str("\nUse `read_skill_manual(skill_name)` to see full instructions for any skill.\n");
512
513        Ok(vec![Message::system(content)])
514    }
515}
516
517/// Tool to read the full SKILL.md guide for a specific skill
518pub struct ReadSkillDoc {
519    loader: Arc<SkillLoader>,
520}
521
522impl ReadSkillDoc {
523    pub fn new(loader: Arc<SkillLoader>) -> Self {
524        Self { loader }
525    }
526}
527
528#[async_trait]
529impl Tool for ReadSkillDoc {
530    fn name(&self) -> String {
531        "read_skill_manual".to_string()
532    }
533
534    async fn definition(&self) -> ToolDefinition {
535        ToolDefinition {
536            name: self.name(),
537            description: "Read the full SKILL.md manual for a specific skill to understand its parameters and usage examples.".to_string(),
538            parameters: json!({
539                "type": "object",
540                "properties": {
541                    "skill_name": {
542                        "type": "string",
543                        "description": "The name of the skill to read documentation for"
544                    }
545                },
546                "required": ["skill_name"]
547            }),
548            parameters_ts: Some("interface ReadSkillArgs {\n  skill_name: string; // The name of the skill to read manual for\n}".to_string()),
549            is_binary: false,
550            is_verified: true,
551            usage_guidelines: None,
552        }
553    }
554
555    async fn call(&self, arguments: &str) -> anyhow::Result<String> {
556        #[derive(Deserialize)]
557        struct Args {
558            skill_name: String,
559        }
560        let args: Args = serde_json::from_str(arguments)?;
561        
562        if let Some(skill) = self.loader.skills.get(&args.skill_name) {
563            Ok(format!("# Skill: {}\n\n{}", skill.name(), skill.instructions))
564        } else {
565            Err(anyhow::anyhow!("Skill '{}' not found in registry", args.skill_name))
566        }
567    }
568}
569/// Tool to search and install skills from ClawHub using CLI (npm/pnpm/bun)
570pub struct ClawHubTool {
571    loader: Arc<SkillLoader>,
572}
573
574impl ClawHubTool {
575    pub fn new(loader: Arc<SkillLoader>) -> Self {
576        Self { loader }
577    }
578}
579
580#[async_trait]
581impl Tool for ClawHubTool {
582    fn name(&self) -> String {
583        "clawhub_manager".to_string()
584    }
585
586    async fn definition(&self) -> ToolDefinition {
587        ToolDefinition {
588            name: self.name(),
589            description: "Search and install new skills from the ClawHub.ai registry. Supports 'search' to find skills and 'install' to add them to your environment.".to_string(),
590            parameters: json!({
591                "type": "object",
592                "properties": {
593                    "action": {
594                        "type": "string",
595                        "enum": ["search", "install"],
596                        "description": "The action to perform"
597                    },
598                    "query": {
599                        "type": "string",
600                        "description": "Search query or skill slug to install"
601                    },
602                    "manager": {
603                        "type": "string",
604                        "enum": ["npm", "pnpm", "bun"],
605                        "description": "The package manager to use (default: npm)"
606                    }
607                },
608                "required": ["action", "query"]
609            }),
610            parameters_ts: Some("interface ClawHubArgs {\n  action: 'search' | 'install';\n  query: string; // Search query or skill slug\n  manager?: 'npm' | 'pnpm' | 'bun'; // Package manager (default: npm)\n}".to_string()),
611            is_binary: false,
612            is_verified: true,
613            usage_guidelines: None,
614        }
615    }
616
617    async fn call(&self, arguments: &str) -> anyhow::Result<String> {
618        #[derive(Deserialize)]
619        struct Args {
620            action: String,
621            query: String,
622            manager: Option<String>,
623        }
624        let args: Args = serde_json::from_str(arguments)?;
625
626        let manager = args.manager.as_deref().unwrap_or("npm");
627        let (cmd, base_args) = match manager {
628            "pnpm" => ("pnpm", vec!["dlx", "clawhub@latest"]),
629            "bun" => ("bunx", vec!["clawhub@latest"]),
630            _ => ("npx", vec!["clawhub@latest"]),
631        };
632
633        match args.action.as_str() {
634            "search" => {
635                info!("Searching ClawHub registry for: {} (via {})", args.query, manager);
636                let output = tokio::process::Command::new(cmd)
637                    .args(&base_args)
638                    .arg("search")
639                    .arg(&args.query)
640                    .output()
641                    .await?;
642                
643                Ok(String::from_utf8_lossy(&output.stdout).to_string())
644            }
645            "install" => {
646                info!("Installing skill from ClawHub: {} (via {})", args.query, manager);
647                let output = tokio::process::Command::new(cmd)
648                    .args(&base_args)
649                    .arg("install")
650                    .arg(&args.query)
651                    .output()
652                    .await?;
653
654                if output.status.success() {
655                    // Refresh the loader to pick up the new skill
656                    info!("Skill {} installed successfully, refreshing registry...", args.query);
657                    self.loader.load_all().await?;
658                    Ok(format!("Successfully installed '{}'. It is now available for use.", args.query))
659                } else {
660                    let err = String::from_utf8_lossy(&output.stderr);
661                    Err(anyhow::anyhow!("Failed to install skill: {}", err))
662                }
663            }
664            _ => Err(anyhow::anyhow!("Unknown action: {}", args.action)),
665        }
666    }
667}