Skip to main content

harness/
config.rs

1use std::collections::HashMap;
2use std::path::PathBuf;
3
4use serde::{Deserialize, Serialize};
5
6/// Which coding agent backend to use.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum AgentKind {
10    Claude,
11    OpenCode,
12    Codex,
13    Cursor,
14}
15
16impl AgentKind {
17    /// Default binary name for this agent (first in the candidates list).
18    pub fn default_binary(&self) -> &'static str {
19        self.binary_candidates()[0]
20    }
21
22    /// All known binary names for this agent, in priority order.
23    /// Different install methods / platforms may use different names.
24    pub fn binary_candidates(&self) -> &'static [&'static str] {
25        match self {
26            AgentKind::Claude => &["claude"],
27            AgentKind::OpenCode => &["opencode"],
28            AgentKind::Codex => &["codex"],
29            // Cursor ships as "agent" on some installs, "cursor-agent" on others
30            AgentKind::Cursor => &["cursor-agent", "agent"],
31        }
32    }
33
34    /// Environment variable names for API keys relevant to this agent.
35    pub fn api_key_env_vars(&self) -> &'static [&'static str] {
36        match self {
37            AgentKind::Claude => &["ANTHROPIC_API_KEY"],
38            AgentKind::Codex => &["OPENAI_API_KEY"],
39            AgentKind::OpenCode => &["ANTHROPIC_API_KEY", "OPENAI_API_KEY"],
40            AgentKind::Cursor => &["CURSOR_API_KEY"],
41        }
42    }
43
44    pub fn display_name(&self) -> &'static str {
45        match self {
46            AgentKind::Claude => "Claude Code",
47            AgentKind::OpenCode => "OpenCode",
48            AgentKind::Codex => "Codex",
49            AgentKind::Cursor => "Cursor",
50        }
51    }
52}
53
54impl std::fmt::Display for AgentKind {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        f.write_str(self.display_name())
57    }
58}
59
60impl std::str::FromStr for AgentKind {
61    type Err = String;
62
63    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
64        match s.to_lowercase().as_str() {
65            "claude" | "claude-code" | "claude_code" => Ok(AgentKind::Claude),
66            "opencode" | "open-code" | "open_code" => Ok(AgentKind::OpenCode),
67            "codex" | "openai-codex" | "openai_codex" => Ok(AgentKind::Codex),
68            "cursor" | "cursor-agent" | "cursor_agent" => Ok(AgentKind::Cursor),
69            _ => Err(format!(
70                "unknown agent: `{s}` (expected: claude, opencode, codex, cursor)"
71            )),
72        }
73    }
74}
75
76/// How the agent should handle tool permission prompts.
77///
78/// Only two modes: full access (default — "yolo") or read-only.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
80#[serde(rename_all = "snake_case")]
81pub enum PermissionMode {
82    /// Full access — auto-approve everything (yolo mode). This is the default.
83    #[default]
84    FullAccess,
85    /// Read-only / plan mode — the agent cannot make changes.
86    ReadOnly,
87}
88
89/// Desired output format for the final result.
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
91#[serde(rename_all = "snake_case")]
92pub enum OutputFormat {
93    /// Plain text — only the final assistant message.
94    Text,
95    /// JSON — structured result object.
96    Json,
97    /// NDJSON stream — one event per line as the run progresses.
98    #[default]
99    StreamJson,
100    /// Markdown — human-readable transcript with headings and code blocks.
101    Markdown,
102}
103
104/// Unified task configuration — everything needed to run a task on any agent.
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct TaskConfig {
107    /// The prompt / instruction to send to the agent.
108    pub prompt: String,
109
110    /// Which agent backend to use.
111    pub agent: AgentKind,
112
113    /// Working directory for the agent.
114    #[serde(default)]
115    pub cwd: Option<PathBuf>,
116
117    /// Model override (e.g. "sonnet", "gpt-5-codex", "claude-opus-4-6").
118    #[serde(default)]
119    pub model: Option<String>,
120
121    /// How to handle tool approvals.
122    #[serde(default)]
123    pub permission_mode: PermissionMode,
124
125    /// Output format.
126    #[serde(default)]
127    pub output_format: OutputFormat,
128
129    /// Maximum number of agentic turns before stopping.
130    #[serde(default)]
131    pub max_turns: Option<u32>,
132
133    /// Maximum spend in USD before stopping.
134    #[serde(default)]
135    pub max_budget_usd: Option<f64>,
136
137    /// Timeout in seconds for the entire run.
138    #[serde(default)]
139    pub timeout_secs: Option<u64>,
140
141    /// Custom system prompt (replaces default).
142    #[serde(default)]
143    pub system_prompt: Option<String>,
144
145    /// Custom system prompt to append to the default.
146    #[serde(default)]
147    pub append_system_prompt: Option<String>,
148
149    /// Override the agent binary path.
150    #[serde(default)]
151    pub binary_path: Option<PathBuf>,
152
153    /// Additional environment variables to set for the agent process.
154    #[serde(default)]
155    pub env: HashMap<String, String>,
156
157    /// Extra agent-specific flags passed through verbatim.
158    #[serde(default)]
159    pub extra_args: Vec<String>,
160}
161
162impl TaskConfig {
163    pub fn new(prompt: impl Into<String>, agent: AgentKind) -> Self {
164        Self {
165            prompt: prompt.into(),
166            agent,
167            cwd: None,
168            model: None,
169            permission_mode: PermissionMode::FullAccess,
170            output_format: OutputFormat::StreamJson,
171            max_turns: None,
172            max_budget_usd: None,
173            timeout_secs: None,
174            system_prompt: None,
175            append_system_prompt: None,
176            binary_path: None,
177            env: HashMap::new(),
178            extra_args: Vec::new(),
179        }
180    }
181
182    /// Create a builder for `TaskConfig`.
183    pub fn builder(prompt: impl Into<String>, agent: AgentKind) -> TaskConfigBuilder {
184        TaskConfigBuilder::new(prompt, agent)
185    }
186}
187
188/// Fluent builder for `TaskConfig`.
189///
190/// ```rust,no_run
191/// use harness::config::{AgentKind, TaskConfig};
192/// let config = TaskConfig::builder("fix the bug", AgentKind::Claude)
193///     .model("opus")
194///     .timeout_secs(60)
195///     .build();
196/// ```
197pub struct TaskConfigBuilder {
198    config: TaskConfig,
199}
200
201impl TaskConfigBuilder {
202    pub fn new(prompt: impl Into<String>, agent: AgentKind) -> Self {
203        Self {
204            config: TaskConfig::new(prompt, agent),
205        }
206    }
207
208    pub fn cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
209        self.config.cwd = Some(cwd.into());
210        self
211    }
212
213    pub fn model(mut self, model: impl Into<String>) -> Self {
214        self.config.model = Some(model.into());
215        self
216    }
217
218    pub fn permission_mode(mut self, mode: PermissionMode) -> Self {
219        self.config.permission_mode = mode;
220        self
221    }
222
223    pub fn read_only(mut self) -> Self {
224        self.config.permission_mode = PermissionMode::ReadOnly;
225        self
226    }
227
228    pub fn output_format(mut self, format: OutputFormat) -> Self {
229        self.config.output_format = format;
230        self
231    }
232
233    pub fn max_turns(mut self, turns: u32) -> Self {
234        self.config.max_turns = Some(turns);
235        self
236    }
237
238    pub fn max_budget_usd(mut self, budget: f64) -> Self {
239        self.config.max_budget_usd = Some(budget);
240        self
241    }
242
243    pub fn timeout_secs(mut self, secs: u64) -> Self {
244        self.config.timeout_secs = Some(secs);
245        self
246    }
247
248    pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
249        self.config.system_prompt = Some(prompt.into());
250        self
251    }
252
253    pub fn append_system_prompt(mut self, prompt: impl Into<String>) -> Self {
254        self.config.append_system_prompt = Some(prompt.into());
255        self
256    }
257
258    pub fn binary_path(mut self, path: impl Into<PathBuf>) -> Self {
259        self.config.binary_path = Some(path.into());
260        self
261    }
262
263    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
264        self.config.env.insert(key.into(), value.into());
265        self
266    }
267
268    pub fn extra_arg(mut self, arg: impl Into<String>) -> Self {
269        self.config.extra_args.push(arg.into());
270        self
271    }
272
273    pub fn extra_args(mut self, args: Vec<String>) -> Self {
274        self.config.extra_args.extend(args);
275        self
276    }
277
278    pub fn build(self) -> TaskConfig {
279        self.config
280    }
281}