use std::collections::HashMap;
use std::path::PathBuf;
use super::types::{AgentKind, SessionFile, TimeFilter, ToolInput, ToolInvocation, ToolResult};
use super::SessionProvider;
const MAX_SESSION_SIZE: u64 = 100 * 1024 * 1024;
pub(crate) struct ClaudeCodeProvider {
projects_dir: PathBuf,
}
impl ClaudeCodeProvider {
pub(crate) fn detect() -> Option<Self> {
let projects_dir = if let Ok(override_dir) = std::env::var("SKIM_PROJECTS_DIR") {
PathBuf::from(override_dir)
} else {
AgentKind::ClaudeCode
.config_dir(&dirs::home_dir()?)
.join("projects")
};
if projects_dir.is_dir() {
Some(Self { projects_dir })
} else {
None
}
}
}
impl SessionProvider for ClaudeCodeProvider {
fn agent_kind(&self) -> AgentKind {
AgentKind::ClaudeCode
}
fn find_sessions(&self, filter: &TimeFilter) -> anyhow::Result<Vec<SessionFile>> {
let mut sessions = Vec::new();
let canonical_root = self
.projects_dir
.canonicalize()
.unwrap_or_else(|_| self.projects_dir.clone());
let entries = std::fs::read_dir(&self.projects_dir)?;
for entry in entries.flatten() {
if !entry.path().is_dir() {
continue;
}
if let Ok(files) = std::fs::read_dir(entry.path()) {
for file in files.flatten() {
let path = file.path();
if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
continue;
}
if let Ok(canonical_path) = path.canonicalize() {
if !canonical_path.starts_with(&canonical_root) {
eprintln!(
"warning: skipping file outside projects dir: {}",
path.display()
);
continue;
}
}
let modified = match std::fs::metadata(&path).and_then(|m| m.modified()) {
Ok(t) => t,
Err(e) => {
eprintln!(
"warning: could not read metadata for {}: {}",
path.display(),
e
);
continue;
}
};
if let Some(since) = filter.since {
if modified < since {
continue;
}
}
let session_id = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
sessions.push(SessionFile {
path,
modified,
agent: AgentKind::ClaudeCode,
session_id,
});
}
}
}
sessions.sort_by(|a, b| b.modified.cmp(&a.modified));
if filter.latest_only {
sessions.truncate(1);
}
Ok(sessions)
}
fn parse_session(&self, file: &SessionFile) -> anyhow::Result<Vec<ToolInvocation>> {
let file_size = std::fs::metadata(&file.path)?.len();
if file_size > MAX_SESSION_SIZE {
anyhow::bail!(
"session file too large ({:.1} MB, limit {:.0} MB): {}",
file_size as f64 / (1024.0 * 1024.0),
MAX_SESSION_SIZE as f64 / (1024.0 * 1024.0),
file.path.display()
);
}
let content = std::fs::read_to_string(&file.path)?;
parse_claude_jsonl(&content, &file.session_id)
}
}
fn parse_claude_jsonl(content: &str, session_id: &str) -> anyhow::Result<Vec<ToolInvocation>> {
let mut invocations = Vec::new();
let mut pending: HashMap<String, usize> = HashMap::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let json: serde_json::Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(_) => continue, };
let msg_type = json.get("type").and_then(|t| t.as_str()).unwrap_or("");
let timestamp = json
.get("timestamp")
.and_then(|t| t.as_str())
.unwrap_or("")
.to_string();
match msg_type {
"assistant" => {
if let Some(contents) = json
.get("message")
.and_then(|m| m.get("content"))
.and_then(|c| c.as_array())
{
for item in contents {
if item.get("type").and_then(|t| t.as_str()) != Some("tool_use") {
continue;
}
let tool_id = item
.get("id")
.and_then(|id| id.as_str())
.unwrap_or("")
.to_string();
let tool_name = item
.get("name")
.and_then(|n| n.as_str())
.unwrap_or("")
.to_string();
let input_json = item
.get("input")
.cloned()
.unwrap_or(serde_json::Value::Null);
let input = parse_tool_input(&tool_name, &input_json);
let idx = invocations.len();
invocations.push(ToolInvocation {
tool_name: tool_name.clone(),
input,
timestamp: timestamp.clone(),
session_id: session_id.to_string(),
agent: AgentKind::ClaudeCode,
result: None,
});
if !tool_id.is_empty() {
pending.insert(tool_id, idx);
}
}
}
}
"user" => {
if let Some(contents) = json
.get("message")
.and_then(|m| m.get("content"))
.and_then(|c| c.as_array())
{
for item in contents {
if item.get("type").and_then(|t| t.as_str()) != Some("tool_result") {
continue;
}
let tool_use_id = item
.get("tool_use_id")
.and_then(|id| id.as_str())
.unwrap_or("");
if let Some(&idx) = pending.get(tool_use_id) {
let result_content = match item.get("content") {
Some(serde_json::Value::String(s)) => s.clone(),
Some(serde_json::Value::Array(arr)) => {
arr.iter()
.filter_map(|b| b.get("text").and_then(|t| t.as_str()))
.collect::<Vec<_>>()
.join("\n")
}
_ => String::new(),
};
let is_error = item
.get("is_error")
.and_then(|e| e.as_bool())
.unwrap_or(false);
invocations[idx].result = Some(ToolResult {
content: result_content,
is_error,
});
pending.remove(tool_use_id);
}
}
}
}
_ => {} }
}
Ok(invocations)
}
fn parse_tool_input(tool_name: &str, input: &serde_json::Value) -> ToolInput {
match tool_name {
"Read" => {
let file_path = input
.get("file_path")
.and_then(|p| p.as_str())
.unwrap_or("")
.to_string();
ToolInput::Read { file_path }
}
"Write" => {
let file_path = input
.get("file_path")
.and_then(|p| p.as_str())
.unwrap_or("")
.to_string();
ToolInput::Write { file_path }
}
"Edit" => {
let file_path = input
.get("file_path")
.and_then(|p| p.as_str())
.unwrap_or("")
.to_string();
ToolInput::Edit { file_path }
}
"Bash" => {
let command = input
.get("command")
.and_then(|c| c.as_str())
.unwrap_or("")
.to_string();
ToolInput::Bash { command }
}
"Glob" => {
let pattern = input
.get("pattern")
.and_then(|p| p.as_str())
.unwrap_or("")
.to_string();
ToolInput::Glob { pattern }
}
"Grep" => {
let pattern = input
.get("pattern")
.and_then(|p| p.as_str())
.unwrap_or("")
.to_string();
ToolInput::Grep { pattern }
}
_ => ToolInput::Other {
tool_name: tool_name.to_string(),
raw: input.clone(),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_tool_use_read() {
let jsonl = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_01","name":"Read","input":{"file_path":"/tmp/test.rs"}}]},"timestamp":"2024-01-01T00:00:00Z","sessionId":"sess1"}"#;
let invocations = parse_claude_jsonl(jsonl, "sess1").unwrap();
assert_eq!(invocations.len(), 1);
assert_eq!(invocations[0].tool_name, "Read");
assert!(matches!(
&invocations[0].input,
ToolInput::Read { file_path } if file_path == "/tmp/test.rs"
));
}
#[test]
fn test_parse_tool_use_bash() {
let jsonl = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_02","name":"Bash","input":{"command":"cargo test","description":"Run tests"}}]},"timestamp":"2024-01-01T00:00:00Z","sessionId":"sess1"}"#;
let invocations = parse_claude_jsonl(jsonl, "sess1").unwrap();
assert_eq!(invocations.len(), 1);
assert_eq!(invocations[0].tool_name, "Bash");
assert!(matches!(
&invocations[0].input,
ToolInput::Bash { command } if command == "cargo test"
));
}
#[test]
fn test_correlate_tool_result() {
let jsonl = concat!(
r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_01","name":"Read","input":{"file_path":"/tmp/test.rs"}}]},"timestamp":"2024-01-01T00:00:00Z","sessionId":"sess1"}"#,
"\n",
r#"{"type":"user","message":{"content":[{"tool_use_id":"toolu_01","type":"tool_result","content":"fn main() {}"}]}}"#
);
let invocations = parse_claude_jsonl(jsonl, "sess1").unwrap();
assert_eq!(invocations.len(), 1);
assert!(invocations[0].result.is_some());
assert_eq!(
invocations[0].result.as_ref().unwrap().content,
"fn main() {}"
);
}
#[test]
fn test_skip_malformed_lines() {
let jsonl = "not json\n{}\n";
let invocations = parse_claude_jsonl(jsonl, "sess1").unwrap();
assert_eq!(invocations.len(), 0);
}
#[test]
fn test_empty_input() {
let invocations = parse_claude_jsonl("", "sess1").unwrap();
assert_eq!(invocations.len(), 0);
}
#[test]
fn test_multiple_tools_in_one_message() {
let jsonl = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_01","name":"Read","input":{"file_path":"/a.rs"}},{"type":"tool_use","id":"toolu_02","name":"Read","input":{"file_path":"/b.rs"}}]},"timestamp":"2024-01-01T00:00:00Z","sessionId":"sess1"}"#;
let invocations = parse_claude_jsonl(jsonl, "sess1").unwrap();
assert_eq!(invocations.len(), 2);
}
#[test]
fn test_tool_result_with_array_content() {
let jsonl = concat!(
r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_01","name":"Read","input":{"file_path":"/tmp/test.rs"}}]},"timestamp":"2024-01-01T00:00:00Z","sessionId":"sess1"}"#,
"\n",
r#"{"type":"user","message":{"content":[{"tool_use_id":"toolu_01","type":"tool_result","content":[{"type":"text","text":"line 1"},{"type":"text","text":"line 2"}]}]}}"#
);
let invocations = parse_claude_jsonl(jsonl, "sess1").unwrap();
assert_eq!(invocations.len(), 1);
assert!(invocations[0].result.is_some());
assert_eq!(
invocations[0].result.as_ref().unwrap().content,
"line 1\nline 2"
);
}
#[test]
fn test_tool_result_is_error() {
let jsonl = concat!(
r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_01","name":"Read","input":{"file_path":"/tmp/test.rs"}}]},"timestamp":"2024-01-01T00:00:00Z","sessionId":"sess1"}"#,
"\n",
r#"{"type":"user","message":{"content":[{"tool_use_id":"toolu_01","type":"tool_result","content":"file not found","is_error":true}]}}"#
);
let invocations = parse_claude_jsonl(jsonl, "sess1").unwrap();
assert_eq!(invocations.len(), 1);
assert!(invocations[0].result.as_ref().unwrap().is_error);
}
#[test]
fn test_parse_tool_input_variants() {
let write_input = serde_json::json!({"file_path": "/tmp/out.rs"});
let result = parse_tool_input("Write", &write_input);
assert!(matches!(result, ToolInput::Write { file_path } if file_path == "/tmp/out.rs"));
let edit_input = serde_json::json!({"file_path": "/tmp/edit.rs"});
let result = parse_tool_input("Edit", &edit_input);
assert!(matches!(result, ToolInput::Edit { file_path } if file_path == "/tmp/edit.rs"));
let glob_input = serde_json::json!({"pattern": "**/*.rs"});
let result = parse_tool_input("Glob", &glob_input);
assert!(matches!(result, ToolInput::Glob { pattern } if pattern == "**/*.rs"));
let grep_input = serde_json::json!({"pattern": "fn main"});
let result = parse_tool_input("Grep", &grep_input);
assert!(matches!(result, ToolInput::Grep { pattern } if pattern == "fn main"));
let other_input = serde_json::json!({"foo": "bar"});
let result = parse_tool_input("UnknownTool", &other_input);
assert!(matches!(result, ToolInput::Other { tool_name, .. } if tool_name == "UnknownTool"));
}
#[test]
fn test_tool_input_file_path() {
let read = ToolInput::Read {
file_path: "/tmp/a.rs".to_string(),
};
assert_eq!(read.file_path(), Some("/tmp/a.rs"));
let write = ToolInput::Write {
file_path: "/tmp/b.rs".to_string(),
};
assert_eq!(write.file_path(), Some("/tmp/b.rs"));
let edit = ToolInput::Edit {
file_path: "/tmp/c.rs".to_string(),
};
assert_eq!(edit.file_path(), Some("/tmp/c.rs"));
let bash = ToolInput::Bash {
command: "ls".to_string(),
};
assert_eq!(bash.file_path(), None);
let glob = ToolInput::Glob {
pattern: "*.rs".to_string(),
};
assert_eq!(glob.file_path(), None);
}
}