Skip to main content

aico/
addons.rs

1use crate::exceptions::AicoError;
2use crate::fs::atomic_write_text;
3use crate::models::{AddonInfo, AddonSource};
4use crate::session::find_session_file;
5use crate::trust::is_project_trusted;
6use std::collections::HashMap;
7use std::env;
8use std::fs;
9use std::os::unix::fs::PermissionsExt;
10use std::path::{Path, PathBuf};
11use std::process::{Command, Stdio};
12
13const PROJECT_ADDONS_DIR: &str = ".aico/addons";
14
15// Macro to embed bundled addons
16macro_rules! bundle_addon {
17    ($name:expr, $path:expr) => {
18        ($name, include_bytes!($path) as &'static [u8])
19    };
20}
21
22fn get_cache_dir() -> PathBuf {
23    let dir = crate::utils::get_app_cache_dir().join("bundled_addons");
24    let _ = fs::create_dir_all(&dir);
25    dir
26}
27
28fn extract_bundled_addon(name: &str, content: &[u8]) -> Result<PathBuf, AicoError> {
29    let cache_dir = get_cache_dir();
30    let target = cache_dir.join(name);
31
32    // Check if content matches to avoid unnecessary writes/chmod
33    if target.exists()
34        && let Ok(existing) = fs::read(&target)
35        && existing == content
36    {
37        return Ok(target);
38    }
39
40    atomic_write_text(&target, &String::from_utf8_lossy(content))?;
41
42    let mut perms = fs::metadata(&target)?.permissions();
43    perms.set_mode(0o755);
44    fs::set_permissions(&target, perms)?;
45
46    Ok(target)
47}
48
49fn run_usage(path: &Path) -> String {
50    let output = Command::new(path)
51        .arg("--usage")
52        .stdout(Stdio::piped())
53        .stderr(Stdio::null())
54        .output();
55
56    match output {
57        Ok(out) if out.status.success() => {
58            let s = String::from_utf8_lossy(&out.stdout);
59            s.lines().next().unwrap_or("").trim().to_string()
60        }
61        _ => String::new(),
62    }
63}
64
65pub fn discover_addons() -> Vec<AddonInfo> {
66    let mut candidates = Vec::new();
67
68    // 1. Project Addons
69    if let Some(session_path) = find_session_file() {
70        let root = session_path.parent().unwrap_or(Path::new("."));
71        let addon_dir = root.join(PROJECT_ADDONS_DIR);
72        if addon_dir.is_dir() {
73            if is_project_trusted(root) {
74                if let Ok(entries) = fs::read_dir(addon_dir) {
75                    for entry in entries.flatten() {
76                        let path = entry.path();
77                        if is_executable_file(&path) {
78                            candidates.push((path, AddonSource::Project));
79                        }
80                    }
81                }
82            } else {
83                eprintln!("[WARN] Project addons found but ignored. Run 'aico trust' to enable.");
84            }
85        }
86    }
87
88    // 2. User Addons
89    let user_addon_dir = crate::utils::get_app_config_dir().join("addons");
90    if let Ok(entries) = fs::read_dir(user_addon_dir) {
91        for entry in entries.flatten() {
92            let path = entry.path();
93            if is_executable_file(&path) {
94                candidates.push((path, AddonSource::User));
95            }
96        }
97    }
98
99    // 3. Bundled Addons
100    let bundled = vec![
101        bundle_addon!("commit", "../.aico/addons/commit"),
102        bundle_addon!("manage-context", "../.aico/addons/manage-context"),
103        bundle_addon!("refine", "../.aico/addons/refine"),
104        bundle_addon!("summarize", "../.aico/addons/summarize"),
105    ];
106
107    for (name, content) in bundled {
108        if let Ok(path) = extract_bundled_addon(name, content) {
109            candidates.push((path, AddonSource::Bundled));
110        }
111    }
112
113    // 4. Process sequentially
114    let mut results = HashMap::<String, AddonInfo>::new();
115
116    for (path, source) in candidates {
117        let name = path
118            .file_name()
119            .and_then(|n| n.to_str())
120            .unwrap_or("")
121            .to_string();
122        if name.is_empty() {
123            continue;
124        }
125
126        let help_text = run_usage(&path);
127        let info = AddonInfo {
128            name: name.clone(),
129            path,
130            help_text,
131            source,
132        };
133
134        // Higher precedence for Project > User > Bundled
135        if let Some(existing) = results.get(&name) {
136            if info.source < existing.source {
137                results.insert(name, info);
138            }
139        } else {
140            results.insert(name, info);
141        }
142    }
143
144    let mut list: Vec<AddonInfo> = results.into_values().collect();
145    list.sort_by(|a, b| a.name.cmp(&b.name));
146    list
147}
148
149fn is_executable_file(path: &Path) -> bool {
150    if let Ok(meta) = fs::metadata(path) {
151        if !meta.is_file() {
152            return false;
153        }
154
155        #[cfg(unix)]
156        {
157            use std::os::unix::fs::PermissionsExt;
158            return meta.permissions().mode() & 0o111 != 0;
159        }
160    }
161    false
162}
163
164pub fn execute_addon(addon: &AddonInfo, args: Vec<String>) -> Result<(), AicoError> {
165    use std::os::unix::process::CommandExt;
166
167    let mut cmd = Command::new(&addon.path);
168    cmd.args(args);
169
170    if let Some(session_file) = find_session_file()
171        && let Ok(abs) = fs::canonicalize(session_file)
172    {
173        cmd.env("AICO_SESSION_FILE", abs);
174    }
175
176    // Prepend current aico binary location to PATH so scripts can call it
177    if let Ok(current_exe) = env::current_exe()
178        && let Some(bin_dir) = current_exe.parent()
179    {
180        let existing_path = env::var_os("PATH");
181
182        let mut paths = match existing_path {
183            Some(p) => env::split_paths(&p).collect::<Vec<_>>(),
184            None => Vec::new(),
185        };
186
187        paths.insert(0, bin_dir.to_path_buf());
188
189        if let Ok(new_path) = env::join_paths(paths) {
190            cmd.env("PATH", new_path);
191        }
192    }
193
194    // Replace current process with addon
195    let err = cmd.exec();
196    Err(AicoError::Io(err))
197}