devflow_core/agents/claude.rs
1//! Claude Code agent adapter.
2//!
3//! Launches `claude -p "<prompt>"` in non-interactive mode with structured
4//! JSON output. Claude runs headless — no trust dialogs, no user prompts.
5
6use super::AgentAdapter;
7
8pub struct ClaudeAgent;
9
10impl AgentAdapter for ClaudeAgent {
11 fn name(&self) -> &'static str {
12 "Claude Code"
13 }
14
15 fn exec_command(
16 &self,
17 _phase: u32,
18 prompt: &str,
19 _extra_writable_roots: &[std::path::PathBuf],
20 ) -> (&'static str, Vec<String>) {
21 (
22 "claude",
23 vec![
24 "-p".into(),
25 prompt.to_string(),
26 "--output-format".into(),
27 "json".into(),
28 "--dangerously-skip-permissions".into(),
29 ],
30 )
31 }
32
33 fn completion_signal_detected(&self, _output: &str) -> bool {
34 // Claude exits cleanly when done; monitor detects exit via kill -0.
35 false
36 }
37}
38
39impl ClaudeAgent {
40 /// Build the resume relaunch command for a confirmed checkpoint
41 /// auto-decide (D-03/D-04, 28-03). NOT a trait method — `--resume` is a
42 /// Claude-CLI-specific, documented feature with no equivalent on
43 /// `AgentAdapter` (D-05: Claude-only, no Codex/OpenCode accommodation,
44 /// `AgentAdapter` itself is untouched).
45 ///
46 /// Argv order (RESEARCH.md § "Architecture Patterns / Pattern 4",
47 /// confirmed): the print flag, the instruction, the resume flag
48 /// immediately followed by the session id (so the id is parsed as the
49 /// flag's value, not a positional argument), the output-format flag with
50 /// its JSON value, and the permission-bypass flag.
51 ///
52 /// **Pitfall 1 (RESEARCH.md, T-28-02) — load-bearing, do not "clean up":**
53 /// a `claude --resume`d session restores NEITHER the permission mode NOR
54 /// the output format from the original launch. Both are re-passed here
55 /// explicitly even though they look redundant with `exec_command`'s
56 /// launch above. Omitting either reintroduces the exact headless hang
57 /// this phase exists to close: the resumed session halts on a
58 /// permission prompt with no operator present to answer it, and the
59 /// prompt is not guaranteed to even reach the captured stdout.
60 /// `resume_command_includes_permission_bypass` is the named regression
61 /// test guarding this specifically — do not delete it as "obviously
62 /// redundant" with `claude_wraps_prompt_in_noninteractive_flags` above;
63 /// it guards a DIFFERENT command construction path.
64 pub fn exec_resume_command(session_id: &str, instruction: &str) -> (&'static str, Vec<String>) {
65 (
66 "claude",
67 vec![
68 "-p".into(),
69 instruction.to_string(),
70 "--resume".into(),
71 session_id.to_string(),
72 "--output-format".into(),
73 "json".into(),
74 "--dangerously-skip-permissions".into(),
75 ],
76 )
77 }
78}
79
80#[cfg(test)]
81mod tests {
82 use super::*;
83
84 #[test]
85 fn resume_command_names_claude_program() {
86 let (program, _args) = ClaudeAgent::exec_resume_command("sess", "instr");
87 assert_eq!(program, "claude");
88 }
89
90 #[test]
91 fn resume_command_carries_print_flag_and_instruction() {
92 let (_program, args) = ClaudeAgent::exec_resume_command("sess", "do the thing");
93 assert!(args.iter().any(|a| a == "-p"));
94 assert!(args.iter().any(|a| a == "do the thing"));
95 }
96
97 #[test]
98 fn resume_command_resume_flag_immediately_precedes_session_id() {
99 let (_program, args) = ClaudeAgent::exec_resume_command("sess-abc", "instr");
100 let resume_idx = args
101 .iter()
102 .position(|a| a == "--resume")
103 .expect("--resume flag must be present");
104 assert_eq!(
105 args.get(resume_idx + 1).map(String::as_str),
106 Some("sess-abc"),
107 "the session id must immediately follow --resume so it is parsed \
108 as the flag's value, not a positional argument: {args:?}"
109 );
110 }
111
112 /// Pitfall 1 (RESEARCH.md, T-28-02): the single highest-consequence
113 /// regression this phase can ship is a resume relaunch that omits either
114 /// the permission-bypass flag or the JSON output-format flag — a resumed
115 /// Claude session restores neither, so omitting them reintroduces a
116 /// silent headless hang on a permission prompt nobody can answer.
117 #[test]
118 fn resume_command_includes_permission_bypass() {
119 let (program, args) = ClaudeAgent::exec_resume_command("sess-123", "do the thing");
120 assert_eq!(program, "claude");
121 assert!(
122 args.iter().any(|a| a == "--dangerously-skip-permissions"),
123 "a resumed Claude session restores neither the permission mode \
124 nor the output format (RESEARCH Pitfall 1) — omitting this flag \
125 reintroduces a silent headless hang with nobody able to answer \
126 the resulting permission prompt: {args:?}"
127 );
128 assert!(
129 args.windows(2)
130 .any(|w| w[0] == "--output-format" && w[1] == "json"),
131 "the JSON output-format flag must also be re-passed explicitly, \
132 for the same reason as the permission-bypass flag: {args:?}"
133 );
134 }
135}