Skip to main content

agent_shell_parser/
lib.rs

1use 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}