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