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        let rules = spec
113            .rules
114            .as_ref()
115            .ok_or(AgentConfigError::MissingSpecField {
116                id: "qodercli",
117                field: "rules",
118            })?;
119        let path = Self::rules_path(scope)?;
120        let mut report = InstallReport::default();
121        scope.ensure_contained(&path)?;
122        file_lock::with_lock(&path, || {
123            let host = fs_atomic::read_to_string_or_empty(&path)?;
124            let new_host = md_block::upsert(&host, &spec.tag, &rules.content);
125            let outcome = safe_fs::write(scope, &path, new_host.as_bytes(), true)?;
126            if outcome.no_change {
127                report.already_installed = true;
128            } else if outcome.existed {
129                report.patched.push(outcome.path.clone());
130            } else {
131                report.created.push(outcome.path.clone());
132            }
133            if let Some(b) = outcome.backup {
134                report.backed_up.push(b);
135            }
136            Ok::<(), AgentConfigError>(())
137        })?;
138        Ok(report)
139    }
140
141    fn uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallReport, AgentConfigError> {
142        HookSpec::validate_tag(tag)?;
143        let path = Self::rules_path(scope)?;
144        let mut report = UninstallReport::default();
145        scope.ensure_contained(&path)?;
146        file_lock::with_lock(&path, || {
147            let host = fs_atomic::read_to_string_or_empty(&path)?;
148            let (stripped, removed) = md_block::remove(&host, tag);
149
150            if !removed {
151                report.not_installed = true;
152                return Ok(());
153            }
154
155            if stripped.trim().is_empty() {
156                if safe_fs::restore_backup_if_matches(scope, &path, stripped.as_bytes())? {
157                    report.restored.push(path.clone());
158                } else {
159                    safe_fs::remove_file(scope, &path)?;
160                    report.removed.push(path.clone());
161                }
162            } else {
163                safe_fs::write(scope, &path, stripped.as_bytes(), false)?;
164                report.patched.push(path.clone());
165            }
166            Ok::<(), AgentConfigError>(())
167        })?;
168        Ok(report)
169    }
170}
171
172impl McpSurface for QoderCliAgent {
173    fn id(&self) -> &'static str {
174        "qodercli"
175    }
176
177    fn supported_mcp_scopes(&self) -> &'static [ScopeKind] {
178        &[ScopeKind::Global, ScopeKind::Local]
179    }
180
181    fn mcp_status(
182        &self,
183        scope: &Scope,
184        name: &str,
185        expected_owner: &str,
186    ) -> Result<StatusReport, AgentConfigError> {
187        McpSpec::validate_name(name)?;
188        let cfg = Self::mcp_path(scope)?;
189        let ledger = ownership::mcp_ledger_for(&cfg);
190        let presence = mcp_json_object::config_presence(&cfg, name)?;
191        let recorded = ownership::owner_of(&ledger, name)?;
192        Ok(StatusReport::for_mcp(
193            name,
194            cfg,
195            ledger,
196            presence,
197            expected_owner,
198            recorded,
199        ))
200    }
201
202    fn plan_install_mcp(
203        &self,
204        scope: &Scope,
205        spec: &McpSpec,
206    ) -> Result<InstallPlan, AgentConfigError> {
207        agent_planning::mcp_json_object_install(
208            McpSurface::id(self),
209            scope,
210            spec,
211            Self::mcp_path(scope),
212        )
213    }
214
215    fn plan_uninstall_mcp(
216        &self,
217        scope: &Scope,
218        name: &str,
219        owner_tag: &str,
220    ) -> Result<UninstallPlan, AgentConfigError> {
221        agent_planning::mcp_json_object_uninstall(
222            McpSurface::id(self),
223            scope,
224            name,
225            owner_tag,
226            Self::mcp_path(scope),
227        )
228    }
229
230    fn install_mcp(
231        &self,
232        scope: &Scope,
233        spec: &McpSpec,
234    ) -> Result<InstallReport, AgentConfigError> {
235        spec.validate()?;
236        let cfg = Self::mcp_path(scope)?;
237        spec.validate_local_secret_policy(scope)?;
238        scope.ensure_contained(&cfg)?;
239        let ledger = ownership::mcp_ledger_for(&cfg);
240        mcp_json_object::install(&cfg, &ledger, spec)
241    }
242
243    fn uninstall_mcp(
244        &self,
245        scope: &Scope,
246        name: &str,
247        owner_tag: &str,
248    ) -> Result<UninstallReport, AgentConfigError> {
249        McpSpec::validate_name(name)?;
250        HookSpec::validate_tag(owner_tag)?;
251        let cfg = Self::mcp_path(scope)?;
252        scope.ensure_contained(&cfg)?;
253        let ledger = ownership::mcp_ledger_for(&cfg);
254        mcp_json_object::uninstall(&cfg, &ledger, name, owner_tag, "mcp server")
255    }
256}
257
258impl QoderCliAgent {
259    fn inline_layout(
260        &self,
261        scope: &Scope,
262    ) -> Result<instructions_dir::InlineLayout, AgentConfigError> {
263        Ok(instructions_dir::InlineLayout {
264            config_dir: Self::instruction_config_dir(scope)?,
265            host_file: Self::rules_path(scope)?,
266        })
267    }
268}
269
270impl InstructionSurface for QoderCliAgent {
271    fn id(&self) -> &'static str {
272        "qodercli"
273    }
274
275    fn supported_instruction_scopes(&self) -> &'static [ScopeKind] {
276        &[ScopeKind::Global, ScopeKind::Local]
277    }
278
279    fn instruction_status(
280        &self,
281        scope: &Scope,
282        name: &str,
283        expected_owner: &str,
284    ) -> Result<StatusReport, AgentConfigError> {
285        instructions_dir::inline_status(self.inline_layout(scope)?, name, expected_owner)
286    }
287
288    fn plan_install_instruction(
289        &self,
290        scope: &Scope,
291        spec: &InstructionSpec,
292    ) -> Result<InstallPlan, AgentConfigError> {
293        instructions_dir::inline_plan_install(
294            InstructionSurface::id(self),
295            scope,
296            self.inline_layout(scope),
297            spec,
298        )
299    }
300
301    fn plan_uninstall_instruction(
302        &self,
303        scope: &Scope,
304        name: &str,
305        owner_tag: &str,
306    ) -> Result<UninstallPlan, AgentConfigError> {
307        instructions_dir::inline_plan_uninstall(
308            InstructionSurface::id(self),
309            scope,
310            self.inline_layout(scope),
311            name,
312            owner_tag,
313        )
314    }
315
316    fn install_instruction(
317        &self,
318        scope: &Scope,
319        spec: &InstructionSpec,
320    ) -> Result<InstallReport, AgentConfigError> {
321        instructions_dir::inline_install(scope, self.inline_layout(scope)?, spec)
322    }
323
324    fn uninstall_instruction(
325        &self,
326        scope: &Scope,
327        name: &str,
328        owner_tag: &str,
329    ) -> Result<UninstallReport, AgentConfigError> {
330        instructions_dir::inline_uninstall(scope, self.inline_layout(scope)?, name, owner_tag)
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use serde_json::{json, Value};
338    use tempfile::tempdir;
339
340    fn rules_spec(tag: &str, body: &str) -> HookSpec {
341        HookSpec::builder(tag)
342            .command_program("noop", [] as [&str; 0])
343            .rules(body)
344            .build()
345    }
346
347    fn mcp_spec(name: &str, owner: &str) -> McpSpec {
348        McpSpec::builder(name)
349            .owner(owner)
350            .stdio("npx", ["-y", "@example/server"])
351            .build()
352    }
353
354    fn read_json(p: &Path) -> Value {
355        serde_json::from_slice(&std::fs::read(p).unwrap()).unwrap()
356    }
357
358    #[test]
359    fn install_writes_agents_md_block() {
360        let dir = tempdir().unwrap();
361        let agent = QoderCliAgent::new();
362        let scope = Scope::Local(dir.path().to_path_buf());
363        agent
364            .install(&scope, &rules_spec("alpha", "Use Qoder."))
365            .unwrap();
366        let body = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap();
367        assert!(body.contains("Use Qoder."));
368    }
369
370    #[test]
371    fn install_uninstall_round_trip() {
372        let dir = tempdir().unwrap();
373        let agent = QoderCliAgent::new();
374        let scope = Scope::Local(dir.path().to_path_buf());
375        agent.install(&scope, &rules_spec("alpha", "x")).unwrap();
376        agent.uninstall(&scope, "alpha").unwrap();
377        assert!(!dir.path().join("AGENTS.md").exists());
378    }
379
380    #[test]
381    fn install_mcp_writes_local_dot_mcp_json() {
382        let dir = tempdir().unwrap();
383        let agent = QoderCliAgent::new();
384        let scope = Scope::Local(dir.path().to_path_buf());
385        agent
386            .install_mcp(&scope, &mcp_spec("github", "myapp"))
387            .unwrap();
388        let v = read_json(&dir.path().join(".mcp.json"));
389        assert_eq!(v["mcpServers"]["github"]["command"], json!("npx"));
390    }
391
392    #[test]
393    fn install_mcp_idempotent() {
394        let dir = tempdir().unwrap();
395        let agent = QoderCliAgent::new();
396        let scope = Scope::Local(dir.path().to_path_buf());
397        let s = mcp_spec("github", "myapp");
398        agent.install_mcp(&scope, &s).unwrap();
399        let r = agent.install_mcp(&scope, &s).unwrap();
400        assert!(r.already_installed);
401    }
402
403    #[test]
404    fn uninstall_mcp_other_owner_refused() {
405        let dir = tempdir().unwrap();
406        let agent = QoderCliAgent::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
415    #[test]
416    fn plan_install_does_not_write() {
417        let dir = tempdir().unwrap();
418        let agent = QoderCliAgent::new();
419        let scope = Scope::Local(dir.path().to_path_buf());
420        let _plan = agent
421            .plan_install(&scope, &rules_spec("alpha", "rules"))
422            .unwrap();
423        assert!(!dir.path().join("AGENTS.md").exists());
424    }
425}