Skip to main content

agent_config/agents/
qodercli.rs

1//! Qoder CLI integration.
2//!
3//! Surfaces:
4//!
5//! 1. **Prompt rules**: `AGENTS.md` (`~/.qoder/AGENTS.md` or `<root>/AGENTS.md`).
6//! 2. **MCP servers**: `mcpServers` JSON map. The user-scope file is
7//!    `~/.qoder.json` (a flat file in `$HOME`, not under `~/.qoder/`); the
8//!    project-scope file is `<root>/.mcp.json`.
9//!
10//! Skills and hooks are not part of Qoder's documented file-config surface.
11
12use std::path::{Path, PathBuf};
13
14use crate::agents::planning as agent_planning;
15use crate::error::AgentConfigError;
16use crate::integration::{
17    InstallReport, InstructionSurface, Integration, McpSurface, UninstallReport,
18};
19use crate::paths;
20use crate::plan::{InstallPlan, UninstallPlan};
21use crate::scope::{Scope, ScopeKind};
22use crate::spec::{HookSpec, InstructionSpec, McpSpec};
23use crate::status::StatusReport;
24use crate::util::{
25    file_lock, fs_atomic, instructions_dir, mcp_json_object, md_block, ownership, safe_fs,
26};
27
28/// Qoder CLI installer.
29#[derive(Debug, Clone, Copy, Default)]
30pub struct QoderCliAgent {
31    _private: (),
32}
33
34impl QoderCliAgent {
35    /// Construct an instance. Stateless.
36    pub const fn new() -> Self {
37        Self { _private: () }
38    }
39
40    fn qoder_home_from_home(home: &Path) -> PathBuf {
41        home.join(".qoder")
42    }
43
44    fn rules_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
45        Ok(match scope {
46            Scope::Global => Self::qoder_home_from_home(&paths::home_dir()?).join("AGENTS.md"),
47            Scope::Local(p) => p.join("AGENTS.md"),
48        })
49    }
50
51    fn mcp_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
52        Ok(match scope {
53            Scope::Global => paths::home_dir()?.join(".qoder.json"),
54            Scope::Local(p) => p.join(".mcp.json"),
55        })
56    }
57
58    /// Directory holding the instruction ownership ledger.
59    /// Global: `~/.qoder/`. Local: `<root>/.qoder/`.
60    fn instruction_config_dir(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
61        Ok(match scope {
62            Scope::Global => Self::qoder_home_from_home(&paths::home_dir()?),
63            Scope::Local(p) => p.join(".qoder"),
64        })
65    }
66}
67
68impl Integration for QoderCliAgent {
69    fn id(&self) -> &'static str {
70        "qodercli"
71    }
72
73    fn display_name(&self) -> &'static str {
74        "Qoder CLI"
75    }
76
77    fn supported_scopes(&self) -> &'static [ScopeKind] {
78        &[ScopeKind::Global, ScopeKind::Local]
79    }
80
81    fn status(&self, scope: &Scope, tag: &str) -> Result<StatusReport, AgentConfigError> {
82        HookSpec::validate_tag(tag)?;
83        let path = Self::rules_path(scope)?;
84        StatusReport::for_markdown_block_hook(tag, path)
85    }
86
87    fn plan_install(
88        &self,
89        scope: &Scope,
90        spec: &HookSpec,
91    ) -> Result<InstallPlan, AgentConfigError> {
92        agent_planning::markdown_install(
93            Integration::id(self),
94            scope,
95            spec,
96            Self::rules_path(scope),
97            true,
98        )
99    }
100
101    fn plan_uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallPlan, AgentConfigError> {
102        agent_planning::markdown_uninstall(
103            Integration::id(self),
104            scope,
105            tag,
106            Self::rules_path(scope),
107        )
108    }
109
110    fn install(&self, scope: &Scope, spec: &HookSpec) -> Result<InstallReport, AgentConfigError> {
111        HookSpec::validate_tag(&spec.tag)?;
112        agent_planning::validate_prompt_only_event(Integration::id(self), &spec.event)?;
113        let rules = spec
114            .rules
115            .as_ref()
116            .ok_or(AgentConfigError::MissingSpecField {
117                id: "qodercli",
118                field: "rules",
119            })?;
120        let path = Self::rules_path(scope)?;
121        let mut report = InstallReport::default();
122        scope.ensure_contained(&path)?;
123        file_lock::with_lock(&path, || {
124            let host = fs_atomic::read_to_string_or_empty(&path)?;
125            let new_host = md_block::upsert(&host, &spec.tag, &rules.content);
126            let outcome = safe_fs::write(scope, &path, new_host.as_bytes(), true)?;
127            if outcome.no_change {
128                report.already_installed = true;
129            } else if outcome.existed {
130                report.patched.push(outcome.path.clone());
131            } else {
132                report.created.push(outcome.path.clone());
133            }
134            if let Some(b) = outcome.backup {
135                report.backed_up.push(b);
136            }
137            Ok::<(), AgentConfigError>(())
138        })?;
139        Ok(report)
140    }
141
142    fn uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallReport, AgentConfigError> {
143        HookSpec::validate_tag(tag)?;
144        let path = Self::rules_path(scope)?;
145        let mut report = UninstallReport::default();
146        scope.ensure_contained(&path)?;
147        file_lock::with_lock(&path, || {
148            let host = fs_atomic::read_to_string_or_empty(&path)?;
149            let (stripped, removed) = md_block::remove(&host, tag);
150
151            if !removed {
152                report.not_installed = true;
153                return Ok(());
154            }
155
156            if stripped.trim().is_empty() {
157                if safe_fs::restore_backup_if_matches(scope, &path, stripped.as_bytes())? {
158                    report.restored.push(path.clone());
159                } else {
160                    safe_fs::remove_file(scope, &path)?;
161                    report.removed.push(path.clone());
162                }
163            } else {
164                safe_fs::write(scope, &path, stripped.as_bytes(), false)?;
165                report.patched.push(path.clone());
166            }
167            Ok::<(), AgentConfigError>(())
168        })?;
169        Ok(report)
170    }
171}
172
173impl McpSurface for QoderCliAgent {
174    fn id(&self) -> &'static str {
175        "qodercli"
176    }
177
178    fn supported_mcp_scopes(&self) -> &'static [ScopeKind] {
179        &[ScopeKind::Global, ScopeKind::Local]
180    }
181
182    fn mcp_status(
183        &self,
184        scope: &Scope,
185        name: &str,
186        expected_owner: &str,
187    ) -> Result<StatusReport, AgentConfigError> {
188        McpSpec::validate_name(name)?;
189        let cfg = Self::mcp_path(scope)?;
190        let ledger = ownership::mcp_ledger_for(&cfg);
191        let presence = mcp_json_object::config_presence(&cfg, name)?;
192        let recorded = ownership::owner_of(&ledger, name)?;
193        Ok(StatusReport::for_mcp(
194            name,
195            cfg,
196            ledger,
197            presence,
198            expected_owner,
199            recorded,
200        ))
201    }
202
203    fn plan_install_mcp(
204        &self,
205        scope: &Scope,
206        spec: &McpSpec,
207    ) -> Result<InstallPlan, AgentConfigError> {
208        agent_planning::mcp_json_object_install(
209            McpSurface::id(self),
210            scope,
211            spec,
212            Self::mcp_path(scope),
213        )
214    }
215
216    fn plan_uninstall_mcp(
217        &self,
218        scope: &Scope,
219        name: &str,
220        owner_tag: &str,
221    ) -> Result<UninstallPlan, AgentConfigError> {
222        agent_planning::mcp_json_object_uninstall(
223            McpSurface::id(self),
224            scope,
225            name,
226            owner_tag,
227            Self::mcp_path(scope),
228        )
229    }
230
231    fn install_mcp(
232        &self,
233        scope: &Scope,
234        spec: &McpSpec,
235    ) -> Result<InstallReport, AgentConfigError> {
236        spec.validate()?;
237        let cfg = Self::mcp_path(scope)?;
238        spec.validate_local_secret_policy(scope)?;
239        scope.ensure_contained(&cfg)?;
240        let ledger = ownership::mcp_ledger_for(&cfg);
241        mcp_json_object::install(&cfg, &ledger, spec)
242    }
243
244    fn uninstall_mcp(
245        &self,
246        scope: &Scope,
247        name: &str,
248        owner_tag: &str,
249    ) -> Result<UninstallReport, AgentConfigError> {
250        McpSpec::validate_name(name)?;
251        HookSpec::validate_tag(owner_tag)?;
252        let cfg = Self::mcp_path(scope)?;
253        scope.ensure_contained(&cfg)?;
254        let ledger = ownership::mcp_ledger_for(&cfg);
255        mcp_json_object::uninstall(&cfg, &ledger, name, owner_tag, "mcp server")
256    }
257}
258
259impl QoderCliAgent {
260    fn inline_layout(
261        &self,
262        scope: &Scope,
263    ) -> Result<instructions_dir::InlineLayout, AgentConfigError> {
264        Ok(instructions_dir::InlineLayout {
265            config_dir: Self::instruction_config_dir(scope)?,
266            host_file: Self::rules_path(scope)?,
267        })
268    }
269}
270
271impl InstructionSurface for QoderCliAgent {
272    fn id(&self) -> &'static str {
273        "qodercli"
274    }
275
276    fn supported_instruction_scopes(&self) -> &'static [ScopeKind] {
277        &[ScopeKind::Global, ScopeKind::Local]
278    }
279
280    fn instruction_status(
281        &self,
282        scope: &Scope,
283        name: &str,
284        expected_owner: &str,
285    ) -> Result<StatusReport, AgentConfigError> {
286        instructions_dir::inline_status(self.inline_layout(scope)?, name, expected_owner)
287    }
288
289    fn plan_install_instruction(
290        &self,
291        scope: &Scope,
292        spec: &InstructionSpec,
293    ) -> Result<InstallPlan, AgentConfigError> {
294        instructions_dir::inline_plan_install(
295            InstructionSurface::id(self),
296            scope,
297            self.inline_layout(scope),
298            spec,
299        )
300    }
301
302    fn plan_uninstall_instruction(
303        &self,
304        scope: &Scope,
305        name: &str,
306        owner_tag: &str,
307    ) -> Result<UninstallPlan, AgentConfigError> {
308        instructions_dir::inline_plan_uninstall(
309            InstructionSurface::id(self),
310            scope,
311            self.inline_layout(scope),
312            name,
313            owner_tag,
314        )
315    }
316
317    fn install_instruction(
318        &self,
319        scope: &Scope,
320        spec: &InstructionSpec,
321    ) -> Result<InstallReport, AgentConfigError> {
322        instructions_dir::inline_install(scope, self.inline_layout(scope)?, spec)
323    }
324
325    fn uninstall_instruction(
326        &self,
327        scope: &Scope,
328        name: &str,
329        owner_tag: &str,
330    ) -> Result<UninstallReport, AgentConfigError> {
331        instructions_dir::inline_uninstall(scope, self.inline_layout(scope)?, name, owner_tag)
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338    use serde_json::{json, Value};
339    use tempfile::tempdir;
340
341    fn rules_spec(tag: &str, body: &str) -> HookSpec {
342        HookSpec::builder(tag)
343            .command_program("noop", [] as [&str; 0])
344            .rules(body)
345            .build()
346    }
347
348    fn mcp_spec(name: &str, owner: &str) -> McpSpec {
349        McpSpec::builder(name)
350            .owner(owner)
351            .stdio("npx", ["-y", "@example/server"])
352            .build()
353    }
354
355    fn read_json(p: &Path) -> Value {
356        serde_json::from_slice(&std::fs::read(p).unwrap()).unwrap()
357    }
358
359    #[test]
360    fn install_writes_agents_md_block() {
361        let dir = tempdir().unwrap();
362        let agent = QoderCliAgent::new();
363        let scope = Scope::Local(dir.path().to_path_buf());
364        agent
365            .install(&scope, &rules_spec("alpha", "Use Qoder."))
366            .unwrap();
367        let body = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap();
368        assert!(body.contains("Use Qoder."));
369    }
370
371    #[test]
372    fn install_uninstall_round_trip() {
373        let dir = tempdir().unwrap();
374        let agent = QoderCliAgent::new();
375        let scope = Scope::Local(dir.path().to_path_buf());
376        agent.install(&scope, &rules_spec("alpha", "x")).unwrap();
377        agent.uninstall(&scope, "alpha").unwrap();
378        assert!(!dir.path().join("AGENTS.md").exists());
379    }
380
381    #[test]
382    fn install_mcp_writes_local_dot_mcp_json() {
383        let dir = tempdir().unwrap();
384        let agent = QoderCliAgent::new();
385        let scope = Scope::Local(dir.path().to_path_buf());
386        agent
387            .install_mcp(&scope, &mcp_spec("github", "myapp"))
388            .unwrap();
389        let v = read_json(&dir.path().join(".mcp.json"));
390        assert_eq!(v["mcpServers"]["github"]["command"], json!("npx"));
391    }
392
393    #[test]
394    fn install_mcp_idempotent() {
395        let dir = tempdir().unwrap();
396        let agent = QoderCliAgent::new();
397        let scope = Scope::Local(dir.path().to_path_buf());
398        let s = mcp_spec("github", "myapp");
399        agent.install_mcp(&scope, &s).unwrap();
400        let r = agent.install_mcp(&scope, &s).unwrap();
401        assert!(r.already_installed);
402    }
403
404    #[test]
405    fn uninstall_mcp_other_owner_refused() {
406        let dir = tempdir().unwrap();
407        let agent = QoderCliAgent::new();
408        let scope = Scope::Local(dir.path().to_path_buf());
409        agent
410            .install_mcp(&scope, &mcp_spec("github", "appA"))
411            .unwrap();
412        let err = agent.uninstall_mcp(&scope, "github", "appB").unwrap_err();
413        assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
414    }
415
416    #[test]
417    fn plan_install_does_not_write() {
418        let dir = tempdir().unwrap();
419        let agent = QoderCliAgent::new();
420        let scope = Scope::Local(dir.path().to_path_buf());
421        let _plan = agent
422            .plan_install(&scope, &rules_spec("alpha", "rules"))
423            .unwrap();
424        assert!(!dir.path().join("AGENTS.md").exists());
425    }
426}