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 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}