Skip to main content

agent_config/agents/
iflow.rs

1//! iFlow CLI integration.
2//!
3//! iFlow combines hooks and MCP servers in a single `settings.json`:
4//!
5//! ```json
6//! {
7//!   "hooks": {
8//!     "PreToolUse": [
9//!       {
10//!         "matcher": "Bash",
11//!         "hooks": [{ "type": "command", "command": "..." }],
12//!         "_agent_config_tag": "myapp"
13//!       }
14//!     ]
15//!   },
16//!   "mcpServers": {
17//!     "github": { "command": "npx", "args": ["..."] }
18//!   }
19//! }
20//! ```
21//!
22//! Surfaces:
23//!
24//! 1. **Hooks**: Claude-shape JSON envelope.
25//! 2. **MCP servers**: `mcpServers` JSON map in the same `settings.json`.
26//!
27//! Skills and a dedicated prompt-rules file are not part of iFlow's
28//! documented surface.
29
30use 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/// iFlow CLI installer.
46#[derive(Debug, Clone, Copy, Default)]
47pub struct IFlowAgent {
48    _private: (),
49}
50
51impl IFlowAgent {
52    /// Construct an instance. Stateless.
53    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    }
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334    use serde_json::Value;
335    use tempfile::tempdir;
336
337    fn local_spec(tag: &str) -> HookSpec {
338        HookSpec::builder(tag)
339            .command_program("myapp", ["hook"])
340            .matcher(Matcher::Bash)
341            .event(Event::PreToolUse)
342            .build()
343    }
344
345    fn mcp_spec(name: &str, owner: &str) -> McpSpec {
346        McpSpec::builder(name)
347            .owner(owner)
348            .stdio("npx", ["-y", "@example/server"])
349            .build()
350    }
351
352    fn read_json(p: &Path) -> Value {
353        serde_json::from_slice(&std::fs::read(p).unwrap()).unwrap()
354    }
355
356    #[test]
357    fn install_writes_settings_with_claude_shape() {
358        let dir = tempdir().unwrap();
359        let agent = IFlowAgent::new();
360        let scope = Scope::Local(dir.path().to_path_buf());
361        agent.install(&scope, &local_spec("alpha")).unwrap();
362
363        let v = read_json(&dir.path().join(".iflow/settings.json"));
364        assert_eq!(v["hooks"]["PreToolUse"][0]["matcher"], json!("Bash"));
365    }
366
367    #[test]
368    fn install_idempotent() {
369        let dir = tempdir().unwrap();
370        let agent = IFlowAgent::new();
371        let scope = Scope::Local(dir.path().to_path_buf());
372        let spec = local_spec("alpha");
373        agent.install(&scope, &spec).unwrap();
374        let r2 = agent.install(&scope, &spec).unwrap();
375        assert!(r2.already_installed);
376    }
377
378    #[test]
379    fn hook_and_mcp_share_settings_json() {
380        let dir = tempdir().unwrap();
381        let agent = IFlowAgent::new();
382        let scope = Scope::Local(dir.path().to_path_buf());
383        agent.install(&scope, &local_spec("alpha")).unwrap();
384        agent
385            .install_mcp(&scope, &mcp_spec("github", "myapp"))
386            .unwrap();
387        let v = read_json(&dir.path().join(".iflow/settings.json"));
388        assert!(v["hooks"]["PreToolUse"].is_array());
389        assert_eq!(v["mcpServers"]["github"]["command"], json!("npx"));
390    }
391
392    #[test]
393    fn install_uninstall_round_trip() {
394        let dir = tempdir().unwrap();
395        let agent = IFlowAgent::new();
396        let scope = Scope::Local(dir.path().to_path_buf());
397        agent.install(&scope, &local_spec("alpha")).unwrap();
398        agent.uninstall(&scope, "alpha").unwrap();
399        assert!(!dir.path().join(".iflow/settings.json").exists());
400    }
401
402    #[test]
403    fn uninstall_mcp_other_owner_refused() {
404        let dir = tempdir().unwrap();
405        let agent = IFlowAgent::new();
406        let scope = Scope::Local(dir.path().to_path_buf());
407        agent
408            .install_mcp(&scope, &mcp_spec("github", "appA"))
409            .unwrap();
410        let err = agent.uninstall_mcp(&scope, "github", "appB").unwrap_err();
411        assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
412    }
413}