1use 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#[derive(Debug, Clone, Copy, Default)]
30pub struct QoderCliAgent {
31 _private: (),
32}
33
34impl QoderCliAgent {
35 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 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}