pocket_cli/cards/
blend.rs

1use crate::cards::{Card, CardConfig, CardCommand};
2use crate::utils;
3use anyhow::{Result, Context, anyhow};
4use colored::Colorize;
5use std::path::PathBuf;
6use std::fs;
7use std::io::{Read, Write};
8use std::process::Command;
9use dirs;
10
11/// Card for shell integration via the blend command
12pub struct BlendCard {
13    /// Name of the card
14    name: String,
15    
16    /// Version of the card
17    version: String,
18    
19    /// Description of the card
20    description: String,
21    
22    /// Configuration for the card
23    config: BlendCardConfig,
24    
25    /// Path to the Pocket data directory
26    data_dir: PathBuf,
27}
28
29/// Configuration for the blend card
30#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
31pub struct BlendCardConfig {
32    /// Path to the hook directory
33    pub hook_dir: String,
34    
35    /// Path to the bin directory
36    pub bin_dir: String,
37}
38
39impl Default for BlendCardConfig {
40    fn default() -> Self {
41        Self {
42            hook_dir: "~/.pocket/hooks".to_string(),
43            bin_dir: "~/.pocket/bin".to_string(),
44        }
45    }
46}
47
48impl BlendCard {
49    /// Creates a new blend card
50    pub fn new(data_dir: impl AsRef<std::path::Path>) -> Self {
51        Self {
52            name: "blend".to_string(),
53            version: env!("CARGO_PKG_VERSION").to_string(),
54            description: "Shell integration and executable hooks".to_string(),
55            config: BlendCardConfig::default(),
56            data_dir: data_dir.as_ref().to_path_buf(),
57        }
58    }
59    
60    /// Add a shell script as a hook
61    pub fn add_hook(&self, script_path: &str, executable: bool) -> Result<()> {
62        // Expand the hook directory path
63        let hook_dir = utils::expand_path(&self.config.hook_dir)?;
64        
65        // Create hook directory if it doesn't exist
66        if !hook_dir.exists() {
67            fs::create_dir_all(&hook_dir)
68                .with_context(|| format!("Failed to create hook directory at {}", hook_dir.display()))?;
69        }
70        
71        // Read the script content
72        let script_content = fs::read_to_string(script_path)
73            .with_context(|| format!("Failed to read script at {}", script_path))?;
74        
75        // Determine the hook name (filename without extension)
76        let script_path = std::path::Path::new(script_path);
77        let hook_name = script_path.file_stem()
78            .and_then(|stem| stem.to_str())
79            .ok_or_else(|| anyhow!("Invalid script filename"))?;
80        
81        // Path to the copied hook script
82        let hook_script_path = hook_dir.join(format!("{}.sh", hook_name));
83        
84        // Write the script to the hook directory
85        fs::write(&hook_script_path, script_content)
86            .with_context(|| format!("Failed to write hook script to {}", hook_script_path.display()))?;
87        
88        if executable {
89            // Make the script executable
90            #[cfg(unix)]
91            {
92                use std::os::unix::fs::PermissionsExt;
93                let mut perms = fs::metadata(&hook_script_path)?.permissions();
94                perms.set_mode(0o755);
95                fs::set_permissions(&hook_script_path, perms)?;
96            }
97            
98            // Create the bin directory if it doesn't exist
99            let bin_dir = utils::expand_path(&self.config.bin_dir)?;
100            if !bin_dir.exists() {
101                fs::create_dir_all(&bin_dir)
102                    .with_context(|| format!("Failed to create bin directory at {}", bin_dir.display()))?;
103                
104                // Add the bin directory to PATH
105                self.add_bin_to_path(&bin_dir)?;
106            }
107            
108            // Create a wrapper script that calls the hook
109            let wrapper_path = bin_dir.join(format!("@{}", hook_name));
110            let wrapper_content = format!(
111                "#!/bin/bash\n\
112                # Wrapper for Pocket hook: {}\n\
113                exec \"{}\" \"$@\"\n",
114                hook_name,
115                hook_script_path.display()
116            );
117            
118            fs::write(&wrapper_path, wrapper_content)
119                .with_context(|| format!("Failed to write wrapper script to {}", wrapper_path.display()))?;
120            
121            // Make the wrapper executable
122            #[cfg(unix)]
123            {
124                use std::os::unix::fs::PermissionsExt;
125                let mut perms = fs::metadata(&wrapper_path)?.permissions();
126                perms.set_mode(0o755);
127                fs::set_permissions(&wrapper_path, perms)?;
128            }
129            
130            println!("Successfully added executable hook '{}' from {}", hook_name, script_path.display());
131            println!("You can run it with '@{}' or 'pocket blend run {}'", hook_name, hook_name);
132        } else {
133            // Add the hook to shell config
134            self.add_hook_to_shell_config(hook_name, &hook_script_path)?;
135            println!("Successfully added hook '{}' from {}", hook_name, script_path.display());
136            println!("Restart your shell or run 'source {}' to apply changes", self.get_shell_config_path()?.display());
137        }
138        
139        Ok(())
140    }
141    
142    /// List all installed hooks
143    pub fn list_hooks(&self) -> Result<()> {
144        // Expand the hook directory path
145        let hook_dir = utils::expand_path(&self.config.hook_dir)?;
146        
147        if !hook_dir.exists() {
148            println!("No hooks installed yet");
149            return Ok(());
150        }
151        
152        let mut hooks = Vec::new();
153        
154        // Read the hook directory
155        for entry in fs::read_dir(hook_dir)? {
156            let entry = entry?;
157            let path = entry.path();
158            
159            if path.is_file() && path.extension().and_then(|e| e.to_str()) == Some("sh") {
160                let name = path.file_stem()
161                    .and_then(|stem| stem.to_str())
162                    .unwrap_or("unknown")
163                    .to_string();
164                
165                // Check if it's an executable hook
166                let bin_dir = utils::expand_path(&self.config.bin_dir)?;
167                let wrapper_path = bin_dir.join(format!("@{}", name));
168                let is_executable = wrapper_path.exists();
169                
170                hooks.push((name, path, is_executable));
171            }
172        }
173        
174        if hooks.is_empty() {
175            println!("No hooks installed yet");
176            return Ok(());
177        }
178        
179        println!("Installed hooks:");
180        for (name, path, is_executable) in hooks {
181            let hook_type = if is_executable {
182                "[executable]"
183            } else {
184                "[shell extension]"
185            };
186            
187            println!("  @{} ({}) {}", name, path.display(), hook_type);
188        }
189        
190        Ok(())
191    }
192    
193    /// Edit a hook
194    pub fn edit_hook(&self, hook_name: &str) -> Result<()> {
195        // Remove @ prefix if present
196        let hook_name = hook_name.trim_start_matches('@');
197        
198        // Expand the hook directory path
199        let hook_dir = utils::expand_path(&self.config.hook_dir)?;
200        let hook_path = hook_dir.join(format!("{}.sh", hook_name));
201        
202        if !hook_path.exists() {
203            return Err(anyhow!("Hook '{}' not found", hook_name));
204        }
205        
206        // Get the editor from environment
207        let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string());
208        
209        // Open the hook script in the editor
210        let status = Command::new(&editor)
211            .arg(&hook_path)
212            .status()
213            .with_context(|| format!("Failed to open editor {}", editor))?;
214        
215        if !status.success() {
216            return Err(anyhow!("Editor exited with non-zero status"));
217        }
218        
219        println!("Hook '{}' edited successfully", hook_name);
220        Ok(())
221    }
222    
223    /// Run a hook
224    pub fn run_hook(&self, hook_name: &str, args: &[String]) -> Result<()> {
225        // Remove @ prefix if present
226        let hook_name = hook_name.trim_start_matches('@');
227        
228        // Expand the hook directory path
229        let hook_dir = utils::expand_path(&self.config.hook_dir)?;
230        let hook_path = hook_dir.join(format!("{}.sh", hook_name));
231        
232        if !hook_path.exists() {
233            return Err(anyhow!("Hook '{}' not found", hook_name));
234        }
235        
236        println!("Running hook '{}'...", hook_name);
237        
238        // Make sure the script is executable
239        #[cfg(unix)]
240        {
241            use std::os::unix::fs::PermissionsExt;
242            let mut perms = fs::metadata(&hook_path)?.permissions();
243            perms.set_mode(0o755);
244            fs::set_permissions(&hook_path, perms)?;
245        }
246        
247        // Run the hook script with arguments
248        let mut command = Command::new(&hook_path);
249        if !args.is_empty() {
250            command.args(args);
251        }
252        
253        let status = command
254            .status()
255            .with_context(|| format!("Failed to execute hook '{}'", hook_name))?;
256        
257        if !status.success() {
258            return Err(anyhow!("Hook '{}' exited with non-zero status", hook_name));
259        }
260        
261        Ok(())
262    }
263    
264    /// Get the user's shell config file path
265    fn get_shell_config_path(&self) -> Result<PathBuf> {
266        // Detect the shell
267        let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string());
268        let home = utils::expand_path("~")?;
269        
270        // Choose the config file based on the shell
271        let config_path = if shell.contains("zsh") {
272            home.join(".zshrc")
273        } else if shell.contains("bash") {
274            // Check if .bash_profile exists, otherwise use .bashrc
275            let bash_profile = home.join(".bash_profile");
276            if bash_profile.exists() {
277                bash_profile
278            } else {
279                home.join(".bashrc")
280            }
281        } else {
282            // Default to .profile
283            home.join(".profile")
284        };
285        
286        Ok(config_path)
287    }
288    
289    /// Add hook to shell config
290    fn add_hook_to_shell_config(&self, hook_name: &str, hook_path: &PathBuf) -> Result<()> {
291        let config_path = self.get_shell_config_path()?;
292        
293        // Read the current shell config
294        let mut config_content = String::new();
295        if config_path.exists() {
296            let mut file = fs::File::open(&config_path)?;
297            file.read_to_string(&mut config_content)?;
298        }
299        
300        // Check if the hook is already in the config
301        let source_line = format!("source \"{}\"", hook_path.display());
302        if config_content.contains(&source_line) {
303            println!("Hook '{}' is already sourced in {}", hook_name, config_path.display());
304            return Ok(());
305        }
306        
307        // Add the hook to the shell config
308        let mut file = fs::OpenOptions::new()
309            .write(true)
310            .append(true)
311            .create(true)
312            .open(&config_path)?;
313        
314        writeln!(file, "\n# Pocket CLI hook: {}", hook_name)?;
315        writeln!(file, "{}", source_line)?;
316        
317        println!("Added hook '{}' to {}", hook_name, config_path.display());
318        Ok(())
319    }
320    
321    /// Add bin directory to PATH
322    fn add_bin_to_path(&self, bin_dir: &PathBuf) -> Result<()> {
323        let config_path = self.get_shell_config_path()?;
324        
325        // Read the current shell config
326        let mut config_content = String::new();
327        if config_path.exists() {
328            let mut file = fs::File::open(&config_path)?;
329            file.read_to_string(&mut config_content)?;
330        }
331        
332        // Check if the PATH addition is already in the config
333        let path_line = format!("export PATH=\"{}:$PATH\"", bin_dir.display());
334        if config_content.contains(&path_line) {
335            return Ok(());
336        }
337        
338        // Add the bin directory to PATH
339        let mut file = fs::OpenOptions::new()
340            .write(true)
341            .append(true)
342            .create(true)
343            .open(&config_path)?;
344        
345        writeln!(file, "\n# Pocket hook bin directory")?;
346        writeln!(file, "{}", path_line)?;
347        
348        println!("Added Pocket hook bin directory to your PATH");
349        Ok(())
350    }
351}
352
353impl Card for BlendCard {
354    fn name(&self) -> &str {
355        &self.name
356    }
357    
358    fn version(&self) -> &str {
359        &self.version
360    }
361    
362    fn description(&self) -> &str {
363        &self.description
364    }
365    
366    fn initialize(&mut self, config: &CardConfig) -> Result<()> {
367        // If there are options in the card config, try to parse them
368        if let Some(options_value) = config.options.get("blend") {
369            if let Ok(options) = serde_json::from_value::<BlendCardConfig>(options_value.clone()) {
370                self.config = options;
371            }
372        }
373        
374        Ok(())
375    }
376    
377    fn execute(&self, command: &str, args: &[String]) -> Result<()> {
378        match command {
379            "add" => {
380                if args.is_empty() {
381                    return Err(anyhow!("Missing script path"));
382                }
383                
384                let script_path = &args[0];
385                
386                let mut executable = false;
387                
388                // Parse optional arguments
389                let mut i = 1;
390                while i < args.len() {
391                    match args[i].as_str() {
392                        "--executable" | "-e" => {
393                            executable = true;
394                        }
395                        _ => { /* Ignore unknown args */ }
396                    }
397                    i += 1;
398                }
399                
400                self.add_hook(script_path, executable)?;
401            }
402            "list" => {
403                self.list_hooks()?;
404            }
405            "edit" => {
406                if args.is_empty() {
407                    return Err(anyhow!("Missing hook name"));
408                }
409                
410                let hook_name = &args[0];
411                self.edit_hook(hook_name)?;
412            }
413            "run" => {
414                if args.is_empty() {
415                    return Err(anyhow!("Missing hook name"));
416                }
417                
418                let hook_name = &args[0];
419                let hook_args = if args.len() > 1 {
420                    &args[1..]
421                } else {
422                    &[]
423                };
424                
425                self.run_hook(hook_name, hook_args)?;
426            }
427            _ => {
428                return Err(anyhow!("Unknown command: {}", command));
429            }
430        }
431        
432        Ok(())
433    }
434    
435    fn commands(&self) -> Vec<CardCommand> {
436        vec![
437            CardCommand {
438                name: "add".to_string(),
439                description: "Add a shell script as a hook".to_string(),
440                usage: "add <script_path> [--executable]".to_string(),
441            },
442            CardCommand {
443                name: "list".to_string(),
444                description: "List all installed hooks".to_string(),
445                usage: "list".to_string(),
446            },
447            CardCommand {
448                name: "edit".to_string(),
449                description: "Edit an existing hook".to_string(),
450                usage: "edit <hook_name>".to_string(),
451            },
452            CardCommand {
453                name: "run".to_string(),
454                description: "Run a hook command directly".to_string(),
455                usage: "run <hook_name> [args...]".to_string(),
456            },
457        ]
458    }
459    
460    fn cleanup(&mut self) -> Result<()> {
461        Ok(())
462    }
463}