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
15macro_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 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 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 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 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 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 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 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 let err = cmd.exec();
210 Err(AicoError::Io(err))
211}