返回 CodeWhale
request_plugin_install.rs
根目录 / crates / tui / src / tools / request_plugin_install.rs
1 //! Model-callable plugin review request. Never installs, trusts, or enables.
2
3 use async_trait::async_trait;
4 use serde_json::{Value, json};
5
6 use super::spec::{
7 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, required_str,
8 };
9 use crate::plugins::recommend::{load_marketplace_candidates, lookup_reviewable_plugin};
10
11 pub const REQUEST_PLUGIN_INSTALL_TOOL_NAME: &str = "request_plugin_install";
12
13 pub struct RequestPluginInstallTool;
14
15 #[async_trait]
16 impl ToolSpec for RequestPluginInstallTool {
17 fn name(&self) -> &'static str {
18 REQUEST_PLUGIN_INSTALL_TOOL_NAME
19 }
20
21 fn description(&self) -> &'static str {
22 "Ask the human to review installing or trusting a plugin that is \
23 already installed-but-idle or listed in a marketplace catalog they \
24 added. Does not install, trust, or enable anything. Fails if the \
25 plugin name is unknown. Pass `name` and a short `reason`."
26 }
27
28 fn input_schema(&self) -> Value {
29 json!({
30 "type": "object",
31 "properties": {
32 "name": {
33 "type": "string",
34 "description": "Plugin name as shown in <recommended_plugins> or /plugin suggest."
35 },
36 "reason": {
37 "type": "string",
38 "description": "Short reason this plugin fits the current task."
39 }
40 },
41 "required": ["name", "reason"]
42 })
43 }
44
45 fn capabilities(&self) -> Vec<ToolCapability> {
46 vec![ToolCapability::ReadOnly]
47 }
48
49 fn approval_requirement(&self) -> ApprovalRequirement {
50 ApprovalRequirement::Auto
51 }
52
53 async fn execute(&self, input: Value, ctx: &ToolContext) -> Result<ToolResult, ToolError> {
54 let name = required_str(&input, "name")?.trim();
55 let reason = required_str(&input, "reason")?.trim();
56 if name.is_empty() {
57 return Err(ToolError::invalid_input(
58 "request_plugin_install: name must not be empty",
59 ));
60 }
61 if reason.is_empty() {
62 return Err(ToolError::invalid_input(
63 "request_plugin_install: reason must not be empty",
64 ));
65 }
66 let Some(registry) = ctx.plugin_registry.as_ref() else {
67 return Err(ToolError::not_available(
68 "request_plugin_install: plugin registry is not available",
69 ));
70 };
71 let marketplace = load_marketplace_candidates(registry.state_path());
72 let Some(matched) = lookup_reviewable_plugin(name, registry, &marketplace) else {
73 return Err(ToolError::invalid_input(format!(
74 "request_plugin_install: unknown plugin `{name}`"
75 )));
76 };
77 let command = matched.command();
78 let payload = json!({
79 "completed": false,
80 "installed": false,
81 "plugin": matched.name,
82 "plugin_id": matched.id,
83 "command": command,
84 "reason": reason,
85 });
86 let mut result = ToolResult::success(format!(
87 "Review requested for {}. Run `{command}` — nothing was installed, trusted, or enabled. Reason: {reason}",
88 matched.name
89 ));
90 result.metadata = Some(payload);
91 Ok(result)
92 }
93 }
94
95 #[cfg(test)]
96 mod tests {
97 use super::*;
98 use crate::test_support::{EnvVarGuard, lock_test_env};
99 use std::fs;
100 use std::sync::Arc;
101 use tempfile::TempDir;
102
103 fn write_keyword_bundle(root: &std::path::Path, name: &str) {
104 let bundle = root.join(".codewhale/plugins").join(name);
105 fs::create_dir_all(&bundle).unwrap();
106 fs::write(
107 bundle.join("plugin.toml"),
108 format!(
109 "schema_version = 1\n[plugin]\nname = \"{name}\"\nversion = \"1.0.0\"\ndescription = \"{name}\"\nkeywords = [\"{name}\"]\n"
110 ),
111 )
112 .unwrap();
113 }
114
115 #[tokio::test]
116 async fn request_plugin_install_does_not_mutate_disk() {
117 let _lock = lock_test_env();
118 let root = TempDir::new().unwrap();
119 let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
120 write_keyword_bundle(root.path(), "supabase");
121 let registry = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv()
122 .registry_for_workspace(root.path());
123 let bundle = root.path().join(".codewhale/plugins/supabase/plugin.toml");
124 let before = fs::read(&bundle).unwrap();
125 let ctx = ToolContext::new(root.path()).with_plugin_registry(Arc::clone(&registry));
126
127 let result = RequestPluginInstallTool
128 .execute(
129 json!({"name": "supabase", "reason": "needs hosted auth"}),
130 &ctx,
131 )
132 .await
133 .expect("known idle plugin");
134 assert!(result.success);
135 assert!(result.content.contains("/plugin trust supabase"));
136 let meta = result.metadata.expect("metadata");
137 assert_eq!(meta["installed"], json!(false));
138 assert_eq!(meta["command"], json!("/plugin trust supabase"));
139 assert_eq!(fs::read(&bundle).unwrap(), before);
140
141 let err = RequestPluginInstallTool
142 .execute(
143 json!({"name": "not-a-real-plugin", "reason": "guess"}),
144 &ctx,
145 )
146 .await
147 .unwrap_err();
148 assert!(err.to_string().to_lowercase().contains("unknown"), "{err}");
149 assert_eq!(fs::read(&bundle).unwrap(), before);
150 }
151 }
152
152 lines RUST