1use super::{
2 assets, availability, config_edit, opencode_config, paths, status, InstallResult,
3 IntegrationTarget,
4};
5use std::fs::{self, OpenOptions};
6use std::io::{self, Write};
7use std::path::{Path, PathBuf};
8use std::time::{SystemTime, UNIX_EPOCH};
9
10#[cfg(unix)]
11fn safe_metadata(path: &Path, directory: bool) -> io::Result<()> {
12 use std::os::unix::fs::MetadataExt;
13
14 let metadata = fs::symlink_metadata(path)?;
15 if metadata.file_type().is_symlink()
16 || (directory && !metadata.is_dir())
17 || (!directory && !metadata.is_file())
18 || metadata.uid() != unsafe { libc::geteuid() }
19 || metadata.mode() & 0o022 != 0
20 {
21 return Err(io::Error::new(
22 io::ErrorKind::PermissionDenied,
23 format!("unsafe managed destination: {}", path.display()),
24 ));
25 }
26 Ok(())
27}
28
29#[cfg(not(unix))]
30fn safe_metadata(path: &Path, directory: bool) -> io::Result<()> {
31 let metadata = fs::symlink_metadata(path)?;
32 if metadata.file_type().is_symlink()
33 || (directory && !metadata.is_dir())
34 || (!directory && !metadata.is_file())
35 {
36 return Err(io::Error::new(
37 io::ErrorKind::PermissionDenied,
38 "unsafe managed destination",
39 ));
40 }
41 Ok(())
42}
43
44fn ensure_dir(path: &Path) -> io::Result<()> {
45 match fs::symlink_metadata(path) {
46 Ok(_) => return safe_metadata(path, true),
47 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
48 Err(error) => return Err(error),
49 }
50 let parent = path
51 .parent()
52 .ok_or_else(|| io::Error::other("managed directory has no parent"))?;
53 ensure_dir(parent)?;
54 fs::create_dir(path)?;
55 #[cfg(unix)]
56 {
57 use std::os::unix::fs::PermissionsExt;
58 fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
59 }
60 safe_metadata(path, true)
61}
62
63fn validate_file(path: &Path) -> io::Result<()> {
64 match fs::symlink_metadata(path) {
65 Ok(_) => safe_metadata(path, false),
66 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
67 Err(error) => Err(error),
68 }
69}
70
71fn atomic_write(path: &Path, content: &str, executable: bool) -> io::Result<()> {
72 let parent = path
73 .parent()
74 .ok_or_else(|| io::Error::other("managed file has no parent"))?;
75 ensure_dir(parent)?;
76 validate_file(path)?;
77 if fs::read_to_string(path).ok().as_deref() == Some(content) {
78 return Ok(());
79 }
80
81 let nonce = SystemTime::now()
82 .duration_since(UNIX_EPOCH)
83 .unwrap_or_default()
84 .as_nanos();
85 let temporary = parent.join(format!(".wsx.{}.{}.tmp", std::process::id(), nonce));
86 let result: io::Result<()> = (|| {
87 #[cfg(unix)]
88 use std::os::unix::fs::OpenOptionsExt;
89 let mut options = OpenOptions::new();
90 options.write(true).create_new(true);
91 #[cfg(unix)]
92 options.mode(if executable { 0o700 } else { 0o600 });
93 let mut file = options.open(&temporary)?;
94 file.write_all(content.as_bytes())?;
95 file.sync_all()?;
96 fs::rename(&temporary, path)?;
97 fs::File::open(parent)?.sync_all()?;
98 Ok(())
99 })();
100 if result.is_err() {
101 let _ = fs::remove_file(&temporary);
102 }
103 result
104}
105
106fn read_or_empty(path: &Path) -> io::Result<String> {
107 match fs::read_to_string(path) {
108 Ok(content) => Ok(content),
109 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(String::new()),
110 Err(error) => Err(error),
111 }
112}
113
114fn config_path(target: IntegrationTarget, root: &Path) -> Option<PathBuf> {
115 match target {
116 IntegrationTarget::Claude => Some(root.join("settings.json")),
117 IntegrationTarget::Codex => Some(root.join("hooks.json")),
118 IntegrationTarget::Copilot => Some(root.join("settings.json")),
119 IntegrationTarget::Devin => Some(root.join("config.json")),
120 IntegrationTarget::Droid => Some(root.join("settings.json")),
121 IntegrationTarget::Kimi => Some(root.join("config.toml")),
122 IntegrationTarget::Hermes => Some(root.join("config.yaml")),
123 IntegrationTarget::Qodercli | IntegrationTarget::Qwen => Some(root.join("settings.json")),
124 IntegrationTarget::Cursor
125 | IntegrationTarget::Mastracode
126 | IntegrationTarget::AntigravityCli => Some(root.join("hooks.json")),
127 _ => None,
128 }
129}
130
131fn validate_distinct_omp_directory(omp_asset: &Path, pi_asset: &Path) -> io::Result<()> {
132 if omp_asset.parent() == pi_asset.parent() {
133 return Err(io::Error::new(
134 io::ErrorKind::InvalidInput,
135 "OMP and Pi resolve to the same extension directory",
136 ));
137 }
138 Ok(())
139}
140
141pub fn install(target: IntegrationTarget) -> io::Result<InstallResult> {
142 let available = availability::is_available(target);
143 let (compatible, compatibility_note) = status::compatibility(target, available);
144 if !compatible {
145 return Err(io::Error::new(
146 io::ErrorKind::Unsupported,
147 compatibility_note.unwrap_or("unsupported agent CLI version"),
148 ));
149 }
150 let root = paths::root(target)?;
151 if target == IntegrationTarget::Omp {
152 validate_distinct_omp_directory(
153 &paths::asset_path(target)?,
154 &paths::asset_path(IntegrationTarget::Pi)?,
155 )?;
156 }
157 install_in(target, &root)
158}
159
160fn install_in(target: IntegrationTarget, root: &Path) -> io::Result<InstallResult> {
161 match fs::symlink_metadata(root) {
162 Ok(_) => safe_metadata(root, true)?,
163 Err(error)
164 if error.kind() == io::ErrorKind::NotFound
165 && target == IntegrationTarget::Mastracode =>
166 {
167 ensure_dir(root)?
168 }
169 Err(error) if error.kind() == io::ErrorKind::NotFound => {
170 return Err(io::Error::new(
171 io::ErrorKind::NotFound,
172 format!(
173 "{} config directory not found at {}",
174 target,
175 root.display()
176 ),
177 ));
178 }
179 Err(error) => return Err(error),
180 }
181
182 let asset = paths::asset_path_in(root, target);
183 let executable = matches!(
184 target,
185 IntegrationTarget::Claude
186 | IntegrationTarget::Codex
187 | IntegrationTarget::Copilot
188 | IntegrationTarget::Devin
189 | IntegrationTarget::Droid
190 | IntegrationTarget::Kimi
191 | IntegrationTarget::Qodercli
192 | IntegrationTarget::Qwen
193 | IntegrationTarget::Cursor
194 | IntegrationTarget::Mastracode
195 | IntegrationTarget::AntigravityCli
196 | IntegrationTarget::Grok
197 );
198 let mut prepared = Vec::<(PathBuf, String, bool)>::new();
199
200 if target == IntegrationTarget::Hermes {
201 let directory = asset.parent().expect("Hermes asset has a parent");
202 prepared.push((
203 directory.join("plugin.yaml"),
204 assets::HERMES_MANIFEST.into(),
205 false,
206 ));
207 }
208 if target == IntegrationTarget::Opencode {
209 prepared.push((
210 root.join("wsx-tui-session.js"),
211 assets::OPENCODE_TUI.into(),
212 false,
213 ));
214 let tui_config = root.join("tui.jsonc");
215 let updated = opencode_config::register_tui(&read_or_empty(&tui_config)?)?;
216 prepared.push((tui_config, updated, false));
217 }
218 if let Some(config) = config_path(target, root) {
219 let old = read_or_empty(&config)?;
220 let new = match target {
221 IntegrationTarget::Kimi => config_edit::kimi_toml(&old, &asset),
222 IntegrationTarget::Hermes => config_edit::hermes_yaml(&old),
223 _ => config_edit::json_config(target, &old, &config, &asset)?,
224 };
225 prepared.push((config, new, false));
226 }
227 if target == IntegrationTarget::Codex {
228 let config = root.join("config.toml");
229 let new = config_edit::codex_toml(&read_or_empty(&config)?);
230 prepared.push((config, new, false));
231 }
232 if target == IntegrationTarget::Grok {
233 let config = root.join("hooks/wsx.json");
234 let start = config_edit::command(&asset, "session");
235 let end = config_edit::command(&asset, "detached");
236 let body = serde_json::to_string_pretty(&serde_json::json!({
237 "hooks": {
238 "SessionStart": [{"hooks": [{
239 "type": "command", "command": start, "timeout": 10
240 }]}],
241 "SessionEnd": [{"hooks": [{
242 "type": "command", "command": end, "timeout": 10
243 }]}]
244 }
245 }))
246 .map_err(io::Error::other)?
247 + "\n";
248 prepared.push((config, body, false));
249 }
250
251 prepared.push((asset, assets::primary(target), executable));
254 let mut written = Vec::with_capacity(prepared.len());
255 for (path, content, executable) in prepared {
256 atomic_write(&path, &content, executable)?;
257 written.push(path);
258 }
259 Ok(InstallResult {
260 target,
261 paths: written,
262 })
263}
264
265#[cfg(test)]
266pub(crate) fn atomic_write_for_test(path: &Path, content: &str) -> io::Result<()> {
267 atomic_write(path, content, false)
268}
269
270#[cfg(test)]
271pub(crate) fn install_for_test(
272 target: IntegrationTarget,
273 root: &Path,
274) -> io::Result<InstallResult> {
275 install_in(target, root)
276}
277
278#[cfg(test)]
279pub(crate) fn validate_omp_directories_for_test(
280 omp_asset: &Path,
281 pi_asset: &Path,
282) -> io::Result<()> {
283 validate_distinct_omp_directory(omp_asset, pi_asset)
284}