1use std::path::{Path, PathBuf};
31
32use serde_json::json;
33
34use crate::error::AgentConfigError;
35use crate::integration::{InstallReport, Integration, McpSurface, UninstallReport};
36use crate::paths;
37use crate::plan::{has_refusal, InstallPlan, PlanTarget, UninstallPlan};
38use crate::scope::{Scope, ScopeKind};
39use crate::spec::{Event, HookSpec, Matcher, McpSpec};
40use crate::status::StatusReport;
41use crate::util::{file_lock, json_patch, mcp_json_object, ownership, planning, safe_fs};
42
43use crate::agents::planning as agent_planning;
44
45#[derive(Debug, Clone, Copy, Default)]
47pub struct IFlowAgent {
48 _private: (),
49}
50
51impl IFlowAgent {
52 pub const fn new() -> Self {
54 Self { _private: () }
55 }
56
57 fn iflow_home_from_home(home: &Path) -> PathBuf {
58 home.join(".iflow")
59 }
60
61 fn settings_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
62 Ok(match scope {
63 Scope::Global => Self::iflow_home_from_home(&paths::home_dir()?).join("settings.json"),
64 Scope::Local(p) => p.join(".iflow").join("settings.json"),
65 })
66 }
67
68 fn mcp_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
69 Self::settings_path(scope)
70 }
71}
72
73impl Integration for IFlowAgent {
74 fn id(&self) -> &'static str {
75 "iflow"
76 }
77
78 fn display_name(&self) -> &'static str {
79 "iFlow CLI"
80 }
81
82 fn supported_scopes(&self) -> &'static [ScopeKind] {
83 &[ScopeKind::Global, ScopeKind::Local]
84 }
85
86 fn status(&self, scope: &Scope, tag: &str) -> Result<StatusReport, AgentConfigError> {
87 HookSpec::validate_tag(tag)?;
88 let p = Self::settings_path(scope)?;
89 let presence = json_patch::tagged_hook_presence(&p, &["hooks"], tag)?;
90 Ok(StatusReport::for_tagged_hook(tag, p, presence))
91 }
92
93 fn plan_install(
94 &self,
95 scope: &Scope,
96 spec: &HookSpec,
97 ) -> Result<InstallPlan, AgentConfigError> {
98 HookSpec::validate_tag(&spec.tag)?;
99 let target = PlanTarget::Hook {
100 integration_id: Integration::id(self),
101 scope: scope.clone(),
102 tag: spec.tag.clone(),
103 };
104 let p = Self::settings_path(scope)?;
105 let mut changes = Vec::new();
106
107 let event_key = event_to_string(&spec.event);
108 let matcher_str = matcher_to_iflow(&spec.matcher);
109 let entry = json!({
110 "matcher": matcher_str,
111 "hooks": [{ "type": "command", "command": spec.command.render_shell() }],
112 });
113 planning::plan_tagged_json_upsert(
114 &mut changes,
115 &p,
116 &["hooks", event_key.as_str()],
117 &spec.tag,
118 entry,
119 |_| {},
120 )?;
121 if has_refusal(&changes) {
122 return Ok(InstallPlan::from_changes(target, changes));
123 }
124
125 Ok(InstallPlan::from_changes(target, changes))
126 }
127
128 fn plan_uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallPlan, AgentConfigError> {
129 HookSpec::validate_tag(tag)?;
130 let target = PlanTarget::Hook {
131 integration_id: Integration::id(self),
132 scope: scope.clone(),
133 tag: tag.to_string(),
134 };
135 let mut changes = Vec::new();
136 let p = Self::settings_path(scope)?;
137 planning::plan_tagged_json_remove_under(
138 &mut changes,
139 &p,
140 &["hooks"],
141 tag,
142 planning::json_object_empty,
143 true,
144 )?;
145 Ok(UninstallPlan::from_changes(target, changes))
146 }
147
148 fn install(&self, scope: &Scope, spec: &HookSpec) -> Result<InstallReport, AgentConfigError> {
149 HookSpec::validate_tag(&spec.tag)?;
150 let mut report = InstallReport::default();
151
152 let p = Self::settings_path(scope)?;
153 scope.ensure_contained(&p)?;
154 file_lock::with_lock(&p, || {
155 let mut root = json_patch::read_or_empty(&p)?;
156
157 let event_key = event_to_string(&spec.event);
158 let matcher_str = matcher_to_iflow(&spec.matcher);
159
160 let entry = json!({
161 "matcher": matcher_str,
162 "hooks": [{ "type": "command", "command": spec.command.render_shell() }],
163 });
164
165 let changed = json_patch::upsert_tagged_array_entry(
166 &mut root,
167 &["hooks", &event_key],
168 &spec.tag,
169 entry,
170 )?;
171
172 if changed {
173 let bytes = json_patch::to_pretty(&root);
174 let outcome = safe_fs::write(scope, &p, &bytes, true)?;
175 if outcome.existed {
176 report.patched.push(outcome.path.clone());
177 } else {
178 report.created.push(outcome.path.clone());
179 }
180 if let Some(b) = outcome.backup {
181 report.backed_up.push(b);
182 }
183 } else {
184 report.already_installed = true;
185 }
186 Ok::<(), AgentConfigError>(())
187 })?;
188
189 Ok(report)
190 }
191
192 fn uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallReport, AgentConfigError> {
193 HookSpec::validate_tag(tag)?;
194 let mut report = UninstallReport::default();
195
196 let p = Self::settings_path(scope)?;
197 scope.ensure_contained(&p)?;
198 if p.exists() {
199 file_lock::with_lock(&p, || {
200 let mut root = json_patch::read_or_empty(&p)?;
201 let changed =
202 json_patch::remove_tagged_array_entries_under(&mut root, &["hooks"], tag)?;
203 if changed {
204 let is_now_empty = root.as_object().map(|o| o.is_empty()).unwrap_or(true);
205 let bytes = json_patch::to_pretty(&root);
206 if is_now_empty && safe_fs::restore_backup_if_matches(scope, &p, &bytes)? {
207 report.restored.push(p.clone());
208 } else if is_now_empty {
209 safe_fs::remove_file(scope, &p)?;
210 report.removed.push(p.clone());
211 } else {
212 safe_fs::write(scope, &p, &bytes, false)?;
213 report.patched.push(p.clone());
214 }
215 }
216 Ok::<(), AgentConfigError>(())
217 })?;
218 }
219
220 if report.removed.is_empty() && report.patched.is_empty() && report.restored.is_empty() {
221 report.not_installed = true;
222 }
223 Ok(report)
224 }
225}
226
227impl McpSurface for IFlowAgent {
228 fn id(&self) -> &'static str {
229 "iflow"
230 }
231
232 fn supported_mcp_scopes(&self) -> &'static [ScopeKind] {
233 &[ScopeKind::Global, ScopeKind::Local]
234 }
235
236 fn mcp_status(
237 &self,
238 scope: &Scope,
239 name: &str,
240 expected_owner: &str,
241 ) -> Result<StatusReport, AgentConfigError> {
242 McpSpec::validate_name(name)?;
243 let cfg = Self::mcp_path(scope)?;
244 let ledger = ownership::mcp_ledger_for(&cfg);
245 let presence = mcp_json_object::config_presence(&cfg, name)?;
246 let recorded = ownership::owner_of(&ledger, name)?;
247 Ok(StatusReport::for_mcp(
248 name,
249 cfg,
250 ledger,
251 presence,
252 expected_owner,
253 recorded,
254 ))
255 }
256
257 fn plan_install_mcp(
258 &self,
259 scope: &Scope,
260 spec: &McpSpec,
261 ) -> Result<InstallPlan, AgentConfigError> {
262 agent_planning::mcp_json_object_install(
263 McpSurface::id(self),
264 scope,
265 spec,
266 Self::mcp_path(scope),
267 )
268 }
269
270 fn plan_uninstall_mcp(
271 &self,
272 scope: &Scope,
273 name: &str,
274 owner_tag: &str,
275 ) -> Result<UninstallPlan, AgentConfigError> {
276 agent_planning::mcp_json_object_uninstall(
277 McpSurface::id(self),
278 scope,
279 name,
280 owner_tag,
281 Self::mcp_path(scope),
282 )
283 }
284
285 fn install_mcp(
286 &self,
287 scope: &Scope,
288 spec: &McpSpec,
289 ) -> Result<InstallReport, AgentConfigError> {
290 spec.validate()?;
291 let cfg = Self::mcp_path(scope)?;
292 spec.validate_local_secret_policy(scope)?;
293 scope.ensure_contained(&cfg)?;
294 let ledger = ownership::mcp_ledger_for(&cfg);
295 mcp_json_object::install(&cfg, &ledger, spec)
296 }
297
298 fn uninstall_mcp(
299 &self,
300 scope: &Scope,
301 name: &str,
302 owner_tag: &str,
303 ) -> Result<UninstallReport, AgentConfigError> {
304 McpSpec::validate_name(name)?;
305 HookSpec::validate_tag(owner_tag)?;
306 let cfg = Self::mcp_path(scope)?;
307 scope.ensure_contained(&cfg)?;
308 let ledger = ownership::mcp_ledger_for(&cfg);
309 mcp_json_object::uninstall(&cfg, &ledger, name, owner_tag, "mcp server")
310 }
311}
312
313fn matcher_to_iflow(m: &Matcher) -> String {
314 match m {
315 Matcher::All => "*".to_string(),
316 Matcher::Bash => "Bash".to_string(),
317 Matcher::Exact(s) => s.clone(),
318 Matcher::AnyOf(names) => names.join("|"),
319 Matcher::Regex(s) => s.clone(),
320 }
321}
322
323fn event_to_string(e: &Event) -> String {
324 match e {
325 Event::PreToolUse => "PreToolUse".into(),
326 Event::PostToolUse => "PostToolUse".into(),
327 Event::Custom(s) => s.clone(),
328 other => other.as_str().into(),
329 }
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335 use serde_json::Value;
336 use tempfile::tempdir;
337
338 fn local_spec(tag: &str) -> HookSpec {
339 HookSpec::builder(tag)
340 .command_program("myapp", ["hook"])
341 .matcher(Matcher::Bash)
342 .event(Event::PreToolUse)
343 .build()
344 }
345
346 fn mcp_spec(name: &str, owner: &str) -> McpSpec {
347 McpSpec::builder(name)
348 .owner(owner)
349 .stdio("npx", ["-y", "@example/server"])
350 .build()
351 }
352
353 fn read_json(p: &Path) -> Value {
354 serde_json::from_slice(&std::fs::read(p).unwrap()).unwrap()
355 }
356
357 #[test]
358 fn install_writes_settings_with_claude_shape() {
359 let dir = tempdir().unwrap();
360 let agent = IFlowAgent::new();
361 let scope = Scope::Local(dir.path().to_path_buf());
362 agent.install(&scope, &local_spec("alpha")).unwrap();
363
364 let v = read_json(&dir.path().join(".iflow/settings.json"));
365 assert_eq!(v["hooks"]["PreToolUse"][0]["matcher"], json!("Bash"));
366 }
367
368 #[test]
369 fn install_idempotent() {
370 let dir = tempdir().unwrap();
371 let agent = IFlowAgent::new();
372 let scope = Scope::Local(dir.path().to_path_buf());
373 let spec = local_spec("alpha");
374 agent.install(&scope, &spec).unwrap();
375 let r2 = agent.install(&scope, &spec).unwrap();
376 assert!(r2.already_installed);
377 }
378
379 #[test]
380 fn hook_and_mcp_share_settings_json() {
381 let dir = tempdir().unwrap();
382 let agent = IFlowAgent::new();
383 let scope = Scope::Local(dir.path().to_path_buf());
384 agent.install(&scope, &local_spec("alpha")).unwrap();
385 agent
386 .install_mcp(&scope, &mcp_spec("github", "myapp"))
387 .unwrap();
388 let v = read_json(&dir.path().join(".iflow/settings.json"));
389 assert!(v["hooks"]["PreToolUse"].is_array());
390 assert_eq!(v["mcpServers"]["github"]["command"], json!("npx"));
391 }
392
393 #[test]
394 fn install_uninstall_round_trip() {
395 let dir = tempdir().unwrap();
396 let agent = IFlowAgent::new();
397 let scope = Scope::Local(dir.path().to_path_buf());
398 agent.install(&scope, &local_spec("alpha")).unwrap();
399 agent.uninstall(&scope, "alpha").unwrap();
400 assert!(!dir.path().join(".iflow/settings.json").exists());
401 }
402
403 #[test]
404 fn uninstall_mcp_other_owner_refused() {
405 let dir = tempdir().unwrap();
406 let agent = IFlowAgent::new();
407 let scope = Scope::Local(dir.path().to_path_buf());
408 agent
409 .install_mcp(&scope, &mcp_spec("github", "appA"))
410 .unwrap();
411 let err = agent.uninstall_mcp(&scope, "github", "appB").unwrap_err();
412 assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
413 }
414}