use anyhow::{Context, Result};
use serde::Deserialize;
use tokio::process::Command;
#[derive(Debug, Deserialize)]
struct ClaudeJsonResult {
#[serde(rename = "type")]
msg_type: Option<String>,
result: Option<String>,
session_id: Option<String>,
subtype: Option<String>,
}
pub async fn run() -> Result<()> {
let message = std::env::var("ILINK_MESSAGE").unwrap_or_default();
let session_id = std::env::var("ILINK_SESSION_ID").unwrap_or_default();
let (response_text, new_session_id) = if !session_id.is_empty() {
match call_claude(&message, &session_id).await {
Ok(result) => result,
Err(e) => {
eprintln!("[claude-code] --resume failed ({e:#}), retrying as new session");
call_claude(&message, "").await?
}
}
} else {
call_claude(&message, "").await?
};
if let Some(sid) = &new_session_id {
if !sid.is_empty() {
println!("ILINK_SESSION:{sid}");
}
}
print!("{response_text}");
Ok(())
}
async fn call_claude(message: &str, session_id: &str) -> Result<(String, Option<String>)> {
let mut args: Vec<String> = vec![
"--output-format".into(),
"json".into(),
"--dangerously-skip-permissions".into(),
];
if let Ok(model) = std::env::var("ILINK_CLAUDE_MODEL") {
if !model.trim().is_empty() {
args.push("--model".into());
args.push(model.trim().to_string());
}
}
if !session_id.is_empty() {
args.push("--resume".into());
args.push(session_id.to_string());
}
args.push("-p".into());
args.push(message.to_string());
let mut cmd = Command::new("claude");
cmd.args(&args);
cmd.stdin(std::process::Stdio::null());
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
cmd.kill_on_drop(true);
let child = cmd
.spawn()
.context("failed to spawn `claude`; ensure it is installed and in PATH")?;
let output = child.wait_with_output().await.context("wait for claude")?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if let Ok((text, sid)) = parse_claude_json_output(&stdout) {
if !text.trim().is_empty() {
if !output.status.success() {
eprintln!(
"[claude-code] claude exited {:?} but returned result text",
output.status.code()
);
}
return Ok((text, sid));
}
}
if !output.status.success() {
let mut detail = stderr.to_string();
if detail.trim().is_empty() && !stdout.trim().is_empty() {
detail = stdout.to_string();
}
anyhow::bail!(
"claude exited with status {:?}\nstderr: {detail}",
output.status.code()
);
}
parse_claude_json_output(&stdout)
}
fn parse_claude_json_output(stdout: &str) -> Result<(String, Option<String>)> {
let items = collect_json_items(stdout);
for obj in items.into_iter().rev() {
let is_result =
obj.msg_type.as_deref() == Some("result") || obj.subtype.as_deref() == Some("success");
if is_result || obj.result.is_some() {
let text = obj.result.unwrap_or_default();
return Ok((text, obj.session_id));
}
}
Ok((stdout.to_string(), None))
}
fn collect_json_items(stdout: &str) -> Vec<ClaudeJsonResult> {
let trimmed = stdout.trim();
if trimmed.starts_with('[') {
if let Ok(items) = serde_json::from_str::<Vec<ClaudeJsonResult>>(trimmed) {
return items;
}
}
stdout
.lines()
.filter_map(|line| {
let line = line.trim();
if line.is_empty() {
return None;
}
serde_json::from_str::<ClaudeJsonResult>(line).ok()
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_result_object() {
let json =
r#"{"type":"result","subtype":"success","result":"Hello!","session_id":"sess-abc"}"#;
let (text, sid) = parse_claude_json_output(json).unwrap();
assert_eq!(text, "Hello!");
assert_eq!(sid.as_deref(), Some("sess-abc"));
}
#[test]
fn parse_multiline_stream_last_result() {
let stdout = r#"{"type":"assistant","content":"thinking..."}
{"type":"result","subtype":"success","result":"Done.","session_id":"sess-xyz"}
"#;
let (text, sid) = parse_claude_json_output(stdout).unwrap();
assert_eq!(text, "Done.");
assert_eq!(sid.as_deref(), Some("sess-xyz"));
}
#[test]
fn fallback_to_raw_stdout_when_no_json() {
let stdout = "plain text response\n";
let (text, sid) = parse_claude_json_output(stdout).unwrap();
assert_eq!(text, "plain text response\n");
assert!(sid.is_none());
}
#[test]
fn parse_error_result_from_json_array() {
let stdout = r#"[{"type":"result","subtype":"success","is_error":true,"result":"model not found","session_id":"sess-1"}]"#;
let (text, sid) = parse_claude_json_output(stdout).unwrap();
assert_eq!(text, "model not found");
assert_eq!(sid.as_deref(), Some("sess-1"));
}
}