Skip to main content

wsx_core/integration/
install.rs

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    // The versioned primary asset is the status marker. Write it last so a
252    // failed config edit remains detectable and retryable on the next scan.
253    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}