agent_shell_parser/
lib.rs1use serde::Deserialize;
2use std::path::Path;
3use std::process::Command;
4
5pub mod guard;
6
7#[derive(Debug, thiserror::Error)]
8pub enum Error {
9 #[error("failed to read stdin: {0}")]
10 Stdin(#[from] std::io::Error),
11 #[error("failed to parse JSON input: {0}")]
12 Json(#[from] serde_json::Error),
13 #[error("jj command failed: {0}")]
14 Jj(String),
15}
16
17#[derive(Debug, Deserialize)]
18pub struct WorktreeCreateInput {
19 pub name: String,
20 pub cwd: String,
21 #[serde(default)]
22 pub session_id: Option<String>,
23}
24
25#[derive(Debug, Deserialize)]
26pub struct WorktreeRemoveInput {
27 pub worktree_path: String,
28 #[serde(default)]
29 pub session_id: Option<String>,
30}
31
32#[derive(Debug, Deserialize)]
33pub struct PreToolUseInput {
34 pub tool_name: String,
35 #[serde(default)]
36 pub tool_input: serde_json::Value,
37 #[serde(default)]
38 pub cwd: Option<String>,
39}
40
41pub fn parse_input<T: serde::de::DeserializeOwned>() -> Result<T, Error> {
42 let input = std::io::read_to_string(std::io::stdin())?;
43 Ok(serde_json::from_str(&input)?)
44}
45
46pub fn is_jj_colocated(cwd: &Path) -> bool {
47 Command::new("jj")
48 .arg("root")
49 .current_dir(cwd)
50 .stdout(std::process::Stdio::null())
51 .stderr(std::process::Stdio::null())
52 .status()
53 .map(|s| s.success())
54 .unwrap_or(false)
55}
56
57pub fn jj_version() -> Option<(u32, u32, u32)> {
58 let output = Command::new("jj").arg("--version").output().ok()?;
59 let text = String::from_utf8_lossy(&output.stdout);
60 let version_str = text.strip_prefix("jj ")?;
61 let mut parts = version_str.trim().split('.');
62 let major = parts.next()?.parse().ok()?;
63 let minor = parts.next()?.parse().ok()?;
64 let patch = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
65 Some((major, minor, patch))
66}
67
68pub fn require_jj_version(min_major: u32, min_minor: u32) -> Result<(), String> {
69 match jj_version() {
70 None => Err("jj-cli not found. Install with: cargo install --locked jj-cli".into()),
71 Some((major, minor, _))
72 if major < min_major || (major == min_major && minor < min_minor) =>
73 {
74 Err(format!(
75 "jj-cli {major}.{minor} found, but >= {min_major}.{min_minor} required. \
76 Upgrade with: cargo install --locked jj-cli"
77 ))
78 }
79 Some(_) => Ok(()),
80 }
81}
82
83#[cfg(test)]
84mod tests {
85 use super::*;
86
87 #[test]
88 fn parse_worktree_create_input() {
89 let json = r#"{"name": "test-feature", "cwd": "/tmp/repo", "session_id": "abc123"}"#;
90 let input: WorktreeCreateInput = serde_json::from_str(json).unwrap();
91 assert_eq!(input.name, "test-feature");
92 assert_eq!(input.cwd, "/tmp/repo");
93 assert_eq!(input.session_id.as_deref(), Some("abc123"));
94 }
95
96 #[test]
97 fn parse_worktree_remove_input() {
98 let json = r#"{"worktree_path": "/tmp/repo/.claude/worktrees/test-feature"}"#;
99 let input: WorktreeRemoveInput = serde_json::from_str(json).unwrap();
100 assert_eq!(
101 input.worktree_path,
102 "/tmp/repo/.claude/worktrees/test-feature"
103 );
104 assert!(input.session_id.is_none());
105 }
106
107 #[test]
108 fn parse_pre_tool_use_input() {
109 let json = r#"{"tool_name": "Bash", "tool_input": {"command": "git commit -m test"}, "cwd": "/tmp/repo"}"#;
110 let input: PreToolUseInput = serde_json::from_str(json).unwrap();
111 assert_eq!(input.tool_name, "Bash");
112 assert_eq!(input.cwd.as_deref(), Some("/tmp/repo"));
113 }
114}