diff --git a/agent-support/sublime-text/git_ai.py b/agent-support/sublime-text/git_ai.py new file mode 100644 index 000000000..75ae1940c --- /dev/null +++ b/agent-support/sublime-text/git_ai.py @@ -0,0 +1,121 @@ +""" +git-ai Sublime Text plugin — known_human checkpoint + +Fires `git-ai checkpoint known_human --hook-input stdin` after each file save, +debounced 500ms per git repository root. This lets git-ai attribute saved lines +to the human author. + +Installation (automatic via `git-ai install-hooks`): + The plugin is written to: + macOS: ~/Library/Application Support/Sublime Text/Packages/git-ai/git_ai.py + Linux: ~/.config/sublime-text/Packages/git-ai/git_ai.py + Windows: %APPDATA%\\Sublime Text\\Packages\\git-ai\\git_ai.py + Sublime Text hot-reloads Python packages — no restart needed. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import threading + +import sublime +import sublime_plugin + +# git-ai binary path (substituted at install time by `git-ai install-hooks`) +GIT_AI_BIN = "__GIT_AI_BINARY_PATH__" + +_lock = threading.Lock() +_timers: dict = {} # repo_root -> threading.Timer +_pending: dict = {} # repo_root -> dict[path, content] + + +def _find_repo_root(file_path: str) -> str | None: + """Walk up from file_path to find the nearest .git directory.""" + d = os.path.dirname(os.path.abspath(file_path)) + while True: + if os.path.isdir(os.path.join(d, ".git")): + return d + parent = os.path.dirname(d) + if parent == d: + return None + d = parent + + +def _fire_checkpoint(repo_root: str) -> None: + """Called after the debounce window; sends accumulated files to git-ai.""" + with _lock: + _timers.pop(repo_root, None) + files: dict = _pending.pop(repo_root, {}) + + if not files: + return + + payload = json.dumps({ + "editor": "sublime-text", + "editor_version": sublime.version(), + "extension_version": "1.0.0", + "cwd": repo_root, + "edited_filepaths": list(files.keys()), + "dirty_files": files, + }) + + try: + proc = subprocess.Popen( + [GIT_AI_BIN, "checkpoint", "known_human", "--hook-input", "stdin"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + cwd=repo_root, + ) + stdout, stderr = proc.communicate(input=payload.encode("utf-8"), timeout=15) + if proc.returncode != 0: + print(f"[git-ai] checkpoint exited with code {proc.returncode}: {stderr.decode('utf-8', errors='replace')[:200]}") + except Exception as exc: + print(f"[git-ai] checkpoint known_human error: {exc}") + + +class GitAiKnownHumanListener(sublime_plugin.EventListener): + """Listens for post-save events and fires the known_human checkpoint.""" + + def on_post_save_async(self, view: sublime.View) -> None: + file_path = view.file_name() + if not file_path: + return + + # Skip IDE-internal paths + norm = file_path.replace("\\", "/") + if "/.git/" in norm: + return + if file_path.endswith(".sublime-workspace") or file_path.endswith(".sublime-project"): + return + + repo_root = _find_repo_root(file_path) + if not repo_root: + return + + # Read current content from the view buffer (already saved) + content = view.substr(sublime.Region(0, view.size())) + + with _lock: + if repo_root not in _pending: + _pending[repo_root] = {} + _pending[repo_root][file_path] = content + + # Cancel existing debounce timer for this repo root and start a new one + existing = _timers.get(repo_root) + if existing is not None: + existing.cancel() + + timer = threading.Timer(0.5, _fire_checkpoint, args=[repo_root]) + _timers[repo_root] = timer + timer.start() + + +def plugin_unloaded(): + with _lock: + for t in _timers.values(): + t.cancel() + _timers.clear() + _pending.clear() diff --git a/src/mdm/agents/mod.rs b/src/mdm/agents/mod.rs index 97c3f4e80..96e3fa384 100644 --- a/src/mdm/agents/mod.rs +++ b/src/mdm/agents/mod.rs @@ -8,6 +8,7 @@ mod gemini; mod github_copilot; mod jetbrains; mod opencode; +mod sublime_text; mod vscode; mod windsurf; @@ -21,6 +22,7 @@ pub use gemini::GeminiInstaller; pub use github_copilot::GitHubCopilotInstaller; pub use jetbrains::JetBrainsInstaller; pub use opencode::OpenCodeInstaller; +pub use sublime_text::SublimeTextInstaller; pub use vscode::VSCodeInstaller; pub use windsurf::WindsurfInstaller; @@ -40,6 +42,7 @@ pub fn get_all_installers() -> Vec> { Box::new(DroidInstaller), Box::new(FirebenderInstaller), Box::new(JetBrainsInstaller), + Box::new(SublimeTextInstaller), Box::new(WindsurfInstaller), ] } diff --git a/src/mdm/agents/sublime_text.rs b/src/mdm/agents/sublime_text.rs new file mode 100644 index 000000000..8f0b0084d --- /dev/null +++ b/src/mdm/agents/sublime_text.rs @@ -0,0 +1,229 @@ +use crate::error::GitAiError; +use crate::mdm::hook_installer::{ + HookCheckResult, HookInstaller, HookInstallerParams, InstallResult, UninstallResult, +}; +use crate::mdm::utils::binary_exists; +#[cfg(not(windows))] +use crate::mdm::utils::home_dir; +use std::fs; +use std::path::PathBuf; + +// Plugin source, embedded at compile time with binary path placeholder +const PLUGIN_TEMPLATE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/agent-support/sublime-text/git_ai.py" +)); + +pub struct SublimeTextInstaller; + +impl SublimeTextInstaller { + fn packages_dir() -> Option { + #[cfg(target_os = "macos")] + { + Some( + home_dir() + .join("Library") + .join("Application Support") + .join("Sublime Text") + .join("Packages"), + ) + } + #[cfg(all(unix, not(target_os = "macos")))] + { + // Try both Sublime Text 3 and 4 paths + let paths = [ + home_dir() + .join(".config") + .join("sublime-text") + .join("Packages"), + home_dir() + .join(".config") + .join("sublime-text-3") + .join("Packages"), + ]; + paths.into_iter().find(|p| p.exists()).or_else(|| { + Some( + home_dir() + .join(".config") + .join("sublime-text") + .join("Packages"), + ) + }) + } + #[cfg(windows)] + { + std::env::var("APPDATA") + .ok() + .map(|appdata| PathBuf::from(appdata).join("Sublime Text").join("Packages")) + } + } + + fn plugin_path() -> Option { + Self::packages_dir().map(|p| p.join("git-ai").join("git_ai.py")) + } + + fn is_plugin_installed(binary_path: &std::path::Path) -> bool { + let Some(path) = Self::plugin_path() else { + return false; + }; + if !path.exists() { + return false; + } + let Ok(content) = fs::read_to_string(&path) else { + return false; + }; + content.contains(&binary_path.display().to_string()) + } +} + +impl HookInstaller for SublimeTextInstaller { + fn name(&self) -> &str { + "Sublime Text" + } + + fn id(&self) -> &str { + "sublime-text" + } + + fn uses_config_hooks(&self) -> bool { + false + } + + fn check_hooks(&self, params: &HookInstallerParams) -> Result { + let has_subl = binary_exists("subl") || binary_exists("sublime_text"); + let has_packages = Self::packages_dir().map(|p| p.exists()).unwrap_or(false); + let tool_installed = has_subl || has_packages; + + let hooks_installed = Self::is_plugin_installed(¶ms.binary_path); + Ok(HookCheckResult { + tool_installed, + hooks_installed, + hooks_up_to_date: hooks_installed, + }) + } + + fn install_hooks( + &self, + _params: &HookInstallerParams, + _dry_run: bool, + ) -> Result, GitAiError> { + Ok(None) + } + + fn uninstall_hooks( + &self, + _params: &HookInstallerParams, + _dry_run: bool, + ) -> Result, GitAiError> { + Ok(None) + } + + fn install_extras( + &self, + params: &HookInstallerParams, + dry_run: bool, + ) -> Result, GitAiError> { + let Some(plugin_path) = Self::plugin_path() else { + return Ok(vec![InstallResult { + changed: false, + diff: None, + message: "Sublime Text: Could not determine Packages directory".to_string(), + }]); + }; + + if Self::is_plugin_installed(¶ms.binary_path) { + return Ok(vec![InstallResult { + changed: false, + diff: None, + message: "Sublime Text: Plugin already installed".to_string(), + }]); + } + + if dry_run { + return Ok(vec![InstallResult { + changed: true, + diff: None, + message: format!( + "Sublime Text: Pending plugin install to {}", + plugin_path.display() + ), + }]); + } + + // Substitute the binary path placeholder + let path_str = params + .binary_path + .display() + .to_string() + .replace('\\', "\\\\") + .replace('"', "\\\""); + let content = PLUGIN_TEMPLATE.replace("__GIT_AI_BINARY_PATH__", &path_str); + + if let Some(dir) = plugin_path.parent() { + fs::create_dir_all(dir)?; + } + fs::write(&plugin_path, content)?; + + Ok(vec![InstallResult { + changed: true, + diff: None, + message: format!( + "Sublime Text: Plugin installed to {} (hot-reloaded, no restart needed)", + plugin_path.display() + ), + }]) + } + + fn uninstall_extras( + &self, + _params: &HookInstallerParams, + _dry_run: bool, + ) -> Result, GitAiError> { + let Some(plugin_path) = Self::plugin_path() else { + return Ok(vec![UninstallResult { + changed: false, + diff: None, + message: "Sublime Text: Could not determine Packages directory".to_string(), + }]); + }; + if let Some(dir) = plugin_path.parent() + && dir.exists() + { + fs::remove_dir_all(dir)?; + return Ok(vec![UninstallResult { + changed: true, + diff: None, + message: "Sublime Text: Plugin removed".to_string(), + }]); + } + Ok(vec![UninstallResult { + changed: false, + diff: None, + message: "Sublime Text: Plugin was not installed".to_string(), + }]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sublime_text_installer_name() { + assert_eq!(SublimeTextInstaller.name(), "Sublime Text"); + } + + #[test] + fn test_sublime_text_installer_id() { + assert_eq!(SublimeTextInstaller.id(), "sublime-text"); + } + + #[test] + fn test_sublime_text_install_hooks_returns_none() { + let installer = SublimeTextInstaller; + let params = HookInstallerParams { + binary_path: std::path::PathBuf::from("/usr/local/bin/git-ai"), + }; + assert!(installer.install_hooks(¶ms, false).unwrap().is_none()); + } +}