1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::process::Command;
4
5use anyhow::{Context, Result, bail};
6use serde::{Deserialize, Serialize};
7
8use crate::config::Config;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ExtensionManifest {
12 pub name: String,
13 #[serde(default)]
14 pub description: String,
15 #[serde(default)]
16 pub version: String,
17 #[serde(default)]
18 pub tools: HashMap<String, ManifestTool>,
19 #[serde(default)]
20 pub commands: HashMap<String, ManifestCommand>,
21 #[serde(default)]
22 pub hooks: HashMap<String, ManifestHook>,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct ManifestTool {
27 pub description: String,
28 pub command: String,
29 #[serde(default = "default_schema")]
30 pub schema: serde_json::Value,
31 #[serde(default = "default_timeout")]
32 pub timeout: u64,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct ManifestCommand {
37 pub description: String,
38 pub command: String,
39 #[serde(default = "default_timeout")]
40 pub timeout: u64,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct ManifestHook {
45 pub command: String,
46 #[serde(default = "default_timeout")]
47 pub timeout: u64,
48}
49
50fn default_schema() -> serde_json::Value {
51 serde_json::json!({
52 "type": "object",
53 "properties": {},
54 "required": []
55 })
56}
57
58fn default_timeout() -> u64 {
59 30
60}
61
62pub struct InstalledExtension {
63 pub manifest: ExtensionManifest,
64 pub path: PathBuf,
65}
66
67fn extensions_dir() -> PathBuf {
68 Config::config_dir().join("extensions")
69}
70
71pub fn discover() -> Vec<InstalledExtension> {
72 let dir = extensions_dir();
73 if !dir.exists() {
74 return Vec::new();
75 }
76 let mut results = Vec::new();
77 let entries = match std::fs::read_dir(&dir) {
78 Ok(e) => e,
79 Err(_) => return Vec::new(),
80 };
81 for entry in entries.flatten() {
82 let path = entry.path();
83 if !path.is_dir() {
84 continue;
85 }
86 let manifest_path = path.join("extension.toml");
87 if !manifest_path.exists() {
88 continue;
89 }
90 match load_manifest(&manifest_path) {
91 Ok(manifest) => {
92 results.push(InstalledExtension {
93 manifest,
94 path: path.clone(),
95 });
96 }
97 Err(e) => {
98 tracing::warn!("Failed to load extension from {}: {}", path.display(), e);
99 }
100 }
101 }
102 results
103}
104
105fn load_manifest(path: &Path) -> Result<ExtensionManifest> {
106 let content =
107 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
108 toml::from_str(&content).with_context(|| format!("parsing {}", path.display()))
109}
110
111fn resolve_command(ext_path: &Path, command: &str) -> String {
113 let script = ext_path.join(command);
114 if script.exists() {
115 script.to_string_lossy().to_string()
116 } else {
117 command.to_string()
118 }
119}
120
121pub fn merge_into_config(config: &mut crate::config::Config) {
123 for ext in discover() {
124 let dir = &ext.path;
125 for (name, tool) in &ext.manifest.tools {
126 let key = format!("{}:{}", ext.manifest.name, name);
127 config
128 .custom_tools
129 .entry(key)
130 .or_insert_with(|| crate::config::CustomToolConfig {
131 description: tool.description.clone(),
132 command: resolve_command(dir, &tool.command),
133 schema: tool.schema.clone(),
134 timeout: tool.timeout,
135 });
136 }
137 for (name, cmd) in &ext.manifest.commands {
138 let key = format!("{}:{}", ext.manifest.name, name);
139 config
140 .commands
141 .entry(key)
142 .or_insert_with(|| crate::config::CommandConfig {
143 description: cmd.description.clone(),
144 command: resolve_command(dir, &cmd.command),
145 timeout: cmd.timeout,
146 });
147 }
148 for (event_name, hook) in &ext.manifest.hooks {
149 config
150 .hooks
151 .entry(event_name.clone())
152 .or_insert_with(|| crate::config::HookConfig {
153 command: resolve_command(dir, &hook.command),
154 timeout: hook.timeout,
155 });
156 }
157 }
158}
159
160pub fn install(source: &str) -> Result<String> {
161 let dir = extensions_dir();
162 std::fs::create_dir_all(&dir)
163 .with_context(|| format!("creating extensions dir {}", dir.display()))?;
164
165 let name = source
166 .rsplit('/')
167 .next()
168 .unwrap_or("extension")
169 .trim_end_matches(".git");
170 let target = dir.join(name);
171
172 if target.exists() {
173 bail!(
174 "Extension '{}' already installed at {}",
175 name,
176 target.display()
177 );
178 }
179
180 let output = Command::new("git")
181 .args(["clone", "--depth", "1", source])
182 .arg(&target)
183 .output()
184 .context("running git clone")?;
185
186 if !output.status.success() {
187 let stderr = String::from_utf8_lossy(&output.stderr);
188 bail!("git clone failed: {}", stderr.trim());
189 }
190
191 let manifest_path = target.join("extension.toml");
192 if !manifest_path.exists() {
193 let _ = std::fs::remove_dir_all(&target);
194 bail!(
195 "No extension.toml found in {}. Not a valid dot extension.",
196 source
197 );
198 }
199
200 let manifest = load_manifest(&manifest_path)?;
201 Ok(format!(
202 "Installed '{}' ({} tools, {} commands, {} hooks)",
203 manifest.name,
204 manifest.tools.len(),
205 manifest.commands.len(),
206 manifest.hooks.len(),
207 ))
208}
209
210pub fn uninstall(name: &str) -> Result<String> {
211 let target = extensions_dir().join(name);
212 if !target.exists() {
213 bail!("Extension '{}' not found", name);
214 }
215 std::fs::remove_dir_all(&target).with_context(|| format!("removing {}", target.display()))?;
216 Ok(format!("Removed extension '{}'", name))
217}
218
219pub fn list() -> Vec<(String, String, PathBuf)> {
220 discover()
221 .into_iter()
222 .map(|e| (e.manifest.name, e.manifest.description, e.path))
223 .collect()
224}