Skip to main content

agent_config/agents/
qwen.rs

1//! Qwen Code integration.
2//!
3//! Qwen Code is Alibaba's terminal coding agent. It is a Gemini-CLI fork, so
4//! the file layout mirrors Gemini's: a `settings.json` that holds the
5//! `mcpServers` map, a `skills/` directory with `SKILL.md` folders, and a
6//! `QWEN.md` memory file.
7//!
8//! Surfaces:
9//!
10//! 1. **Prompt rules**: fenced HTML-comment block in `QWEN.md`.
11//! 2. **MCP servers**: `mcpServers` JSON map in `settings.json`.
12//! 3. **Skills**: directory-scoped `SKILL.md` folders.
13//!
14//! Hooks are not part of Qwen's documented surface, so [`Integration`] only
15//! wires the prompt-rules path and refuses installs that lack
16//! [`HookSpec::rules`].
17
18use std::path::{Path, PathBuf};
19
20use crate::agents::planning as agent_planning;
21use crate::error::AgentConfigError;
22use crate::integration::{
23    InstallReport, InstructionSurface, Integration, McpSurface, SkillSurface, UninstallReport,
24};
25use crate::paths;
26use crate::plan::{InstallPlan, UninstallPlan};
27use crate::scope::{Scope, ScopeKind};
28use crate::spec::{HookSpec, InstructionSpec, McpSpec, SkillSpec};
29use crate::status::StatusReport;
30use crate::util::{
31    file_lock, fs_atomic, instructions_dir, mcp_json_object, md_block, ownership, safe_fs,
32    skills_dir,
33};
34
35/// Qwen Code installer.
36#[derive(Debug, Clone, Copy, Default)]
37pub struct QwenAgent {
38    _private: (),
39}
40
41impl QwenAgent {
42    /// Construct an instance. Stateless.
43    pub const fn new() -> Self {
44        Self { _private: () }
45    }
46
47    fn qwen_home_from_home(home: &Path) -> PathBuf {
48        home.join(".qwen")
49    }
50
51    fn settings_path_from_home(home: &Path) -> PathBuf {
52        Self::qwen_home_from_home(home).join("settings.json")
53    }
54
55    fn memory_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
56        Ok(match scope {
57            Scope::Global => Self::qwen_home_from_home(&paths::home_dir()?).join("QWEN.md"),
58            Scope::Local(p) => p.join("QWEN.md"),
59        })
60    }
61
62    fn mcp_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
63        Ok(match scope {
64            Scope::Global => Self::settings_path_from_home(&paths::home_dir()?),
65            Scope::Local(p) => p.join(".qwen").join("settings.json"),
66        })
67    }
68
69    fn skills_root(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
70        Ok(match scope {
71            Scope::Global => Self::qwen_home_from_home(&paths::home_dir()?).join("skills"),
72            Scope::Local(p) => p.join(".qwen").join("skills"),
73        })
74    }
75
76    /// Directory holding the instruction ownership ledger.
77    /// Global: `~/.qwen/`. Local: `<root>/.qwen/`.
78    fn instruction_config_dir(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
79        Ok(match scope {
80            Scope::Global => Self::qwen_home_from_home(&paths::home_dir()?),
81            Scope::Local(p) => p.join(".qwen"),
82        })
83    }
84}
85
86impl Integration for QwenAgent {
87    fn id(&self) -> &'static str {
88        "qwen"
89    }
90
91    fn display_name(&self) -> &'static str {
92        "Qwen Code"
93    }
94
95    fn supported_scopes(&self) -> &'static [ScopeKind] {
96        &[ScopeKind::Global, ScopeKind::Local]
97    }
98
99    fn status(&self, scope: &Scope, tag: &str) -> Result<StatusReport, AgentConfigError> {
100        HookSpec::validate_tag(tag)?;
101        let path = Self::memory_path(scope)?;
102        StatusReport::for_markdown_block_hook(tag, path)
103    }
104
105    fn plan_install(
106        &self,
107        scope: &Scope,
108        spec: &HookSpec,
109    ) -> Result<InstallPlan, AgentConfigError> {
110        agent_planning::markdown_install(
111            Integration::id(self),
112            scope,
113            spec,
114            Self::memory_path(scope),
115            true,
116        )
117    }
118
119    fn plan_uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallPlan, AgentConfigError> {
120        agent_planning::markdown_uninstall(
121            Integration::id(self),
122            scope,
123            tag,
124            Self::memory_path(scope),
125        )
126    }
127
128    fn install(&self, scope: &Scope, spec: &HookSpec) -> Result<InstallReport, AgentConfigError> {
129        HookSpec::validate_tag(&spec.tag)?;
130        agent_planning::validate_prompt_only_event(Integration::id(self), &spec.event)?;
131        let rules = spec
132            .rules
133            .as_ref()
134            .ok_or(AgentConfigError::MissingSpecField {
135                id: "qwen",
136                field: "rules",
137            })?;
138        let path = Self::memory_path(scope)?;
139        let mut report = InstallReport::default();
140        scope.ensure_contained(&path)?;
141        file_lock::with_lock(&path, || {
142            let host = fs_atomic::read_to_string_or_empty(&path)?;
143            let new_host = md_block::upsert(&host, &spec.tag, &rules.content);
144            let outcome = safe_fs::write(scope, &path, new_host.as_bytes(), true)?;
145            if outcome.no_change {
146                report.already_installed = true;
147            } else if outcome.existed {
148                report.patched.push(outcome.path.clone());
149            } else {
150                report.created.push(outcome.path.clone());
151            }
152            if let Some(b) = outcome.backup {
153                report.backed_up.push(b);
154            }
155            Ok::<(), AgentConfigError>(())
156        })?;
157        Ok(report)
158    }
159
160    fn uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallReport, AgentConfigError> {
161        HookSpec::validate_tag(tag)?;
162        let path = Self::memory_path(scope)?;
163        let mut report = UninstallReport::default();
164        scope.ensure_contained(&path)?;
165        file_lock::with_lock(&path, || {
166            let host = fs_atomic::read_to_string_or_empty(&path)?;
167            let (stripped, removed) = md_block::remove(&host, tag);
168
169            if !removed {
170                report.not_installed = true;
171                return Ok(());
172            }
173
174            if stripped.trim().is_empty() {
175                if safe_fs::restore_backup_if_matches(scope, &path, stripped.as_bytes())? {
176                    report.restored.push(path.clone());
177                } else {
178                    safe_fs::remove_file(scope, &path)?;
179                    report.removed.push(path.clone());
180                }
181            } else {
182                safe_fs::write(scope, &path, stripped.as_bytes(), false)?;
183                report.patched.push(path.clone());
184            }
185            Ok::<(), AgentConfigError>(())
186        })?;
187        Ok(report)
188    }
189}
190
191impl McpSurface for QwenAgent {
192    fn id(&self) -> &'static str {
193        "qwen"
194    }
195
196    fn supported_mcp_scopes(&self) -> &'static [ScopeKind] {
197        &[ScopeKind::Global, ScopeKind::Local]
198    }
199
200    fn mcp_status(
201        &self,
202        scope: &Scope,
203        name: &str,
204        expected_owner: &str,
205    ) -> Result<StatusReport, AgentConfigError> {
206        McpSpec::validate_name(name)?;
207        let cfg = Self::mcp_path(scope)?;
208        let ledger = ownership::mcp_ledger_for(&cfg);
209        let presence = mcp_json_object::config_presence(&cfg, name)?;
210        let recorded = ownership::owner_of(&ledger, name)?;
211        Ok(StatusReport::for_mcp(
212            name,
213            cfg,
214            ledger,
215            presence,
216            expected_owner,
217            recorded,
218        ))
219    }
220
221    fn plan_install_mcp(
222        &self,
223        scope: &Scope,
224        spec: &McpSpec,
225    ) -> Result<InstallPlan, AgentConfigError> {
226        agent_planning::mcp_json_object_install(
227            McpSurface::id(self),
228            scope,
229            spec,
230            Self::mcp_path(scope),
231        )
232    }
233
234    fn plan_uninstall_mcp(
235        &self,
236        scope: &Scope,
237        name: &str,
238        owner_tag: &str,
239    ) -> Result<UninstallPlan, AgentConfigError> {
240        agent_planning::mcp_json_object_uninstall(
241            McpSurface::id(self),
242            scope,
243            name,
244            owner_tag,
245            Self::mcp_path(scope),
246        )
247    }
248
249    fn install_mcp(
250        &self,
251        scope: &Scope,
252        spec: &McpSpec,
253    ) -> Result<InstallReport, AgentConfigError> {
254        spec.validate()?;
255        let cfg = Self::mcp_path(scope)?;
256        spec.validate_local_secret_policy(scope)?;
257        scope.ensure_contained(&cfg)?;
258        let ledger = ownership::mcp_ledger_for(&cfg);
259        mcp_json_object::install(&cfg, &ledger, spec)
260    }
261
262    fn uninstall_mcp(
263        &self,
264        scope: &Scope,
265        name: &str,
266        owner_tag: &str,
267    ) -> Result<UninstallReport, AgentConfigError> {
268        McpSpec::validate_name(name)?;
269        HookSpec::validate_tag(owner_tag)?;
270        let cfg = Self::mcp_path(scope)?;
271        scope.ensure_contained(&cfg)?;
272        let ledger = ownership::mcp_ledger_for(&cfg);
273        mcp_json_object::uninstall(&cfg, &ledger, name, owner_tag, "mcp server")
274    }
275}
276
277impl SkillSurface for QwenAgent {
278    fn id(&self) -> &'static str {
279        "qwen"
280    }
281
282    fn supported_skill_scopes(&self) -> &'static [ScopeKind] {
283        &[ScopeKind::Global, ScopeKind::Local]
284    }
285
286    fn skill_status(
287        &self,
288        scope: &Scope,
289        name: &str,
290        expected_owner: &str,
291    ) -> Result<StatusReport, AgentConfigError> {
292        SkillSpec::validate_name(name)?;
293        let root = Self::skills_root(scope)?;
294        let (dir, manifest, ledger) = skills_dir::paths_for_status(&root, name);
295        let recorded = ownership::owner_of(&ledger, name)?;
296        Ok(StatusReport::for_skill(
297            name,
298            dir,
299            manifest,
300            ledger,
301            expected_owner,
302            recorded,
303        ))
304    }
305
306    fn plan_install_skill(
307        &self,
308        scope: &Scope,
309        spec: &SkillSpec,
310    ) -> Result<InstallPlan, AgentConfigError> {
311        agent_planning::skill_install(
312            SkillSurface::id(self),
313            scope,
314            spec,
315            Self::skills_root(scope),
316        )
317    }
318
319    fn plan_uninstall_skill(
320        &self,
321        scope: &Scope,
322        name: &str,
323        owner_tag: &str,
324    ) -> Result<UninstallPlan, AgentConfigError> {
325        agent_planning::skill_uninstall(
326            SkillSurface::id(self),
327            scope,
328            name,
329            owner_tag,
330            Self::skills_root(scope),
331        )
332    }
333
334    fn install_skill(
335        &self,
336        scope: &Scope,
337        spec: &SkillSpec,
338    ) -> Result<InstallReport, AgentConfigError> {
339        let root = Self::skills_root(scope)?;
340        scope.ensure_contained(&root)?;
341        skills_dir::install(&root, spec)
342    }
343
344    fn uninstall_skill(
345        &self,
346        scope: &Scope,
347        name: &str,
348        owner_tag: &str,
349    ) -> Result<UninstallReport, AgentConfigError> {
350        let root = Self::skills_root(scope)?;
351        scope.ensure_contained(&root)?;
352        skills_dir::uninstall(&root, name, owner_tag)
353    }
354}
355
356impl QwenAgent {
357    fn inline_layout(
358        &self,
359        scope: &Scope,
360    ) -> Result<instructions_dir::InlineLayout, AgentConfigError> {
361        Ok(instructions_dir::InlineLayout {
362            config_dir: Self::instruction_config_dir(scope)?,
363            host_file: Self::memory_path(scope)?,
364        })
365    }
366}
367
368impl InstructionSurface for QwenAgent {
369    fn id(&self) -> &'static str {
370        "qwen"
371    }
372
373    fn supported_instruction_scopes(&self) -> &'static [ScopeKind] {
374        &[ScopeKind::Global, ScopeKind::Local]
375    }
376
377    fn instruction_status(
378        &self,
379        scope: &Scope,
380        name: &str,
381        expected_owner: &str,
382    ) -> Result<StatusReport, AgentConfigError> {
383        instructions_dir::inline_status(self.inline_layout(scope)?, name, expected_owner)
384    }
385
386    fn plan_install_instruction(
387        &self,
388        scope: &Scope,
389        spec: &InstructionSpec,
390    ) -> Result<InstallPlan, AgentConfigError> {
391        instructions_dir::inline_plan_install(
392            InstructionSurface::id(self),
393            scope,
394            self.inline_layout(scope),
395            spec,
396        )
397    }
398
399    fn plan_uninstall_instruction(
400        &self,
401        scope: &Scope,
402        name: &str,
403        owner_tag: &str,
404    ) -> Result<UninstallPlan, AgentConfigError> {
405        instructions_dir::inline_plan_uninstall(
406            InstructionSurface::id(self),
407            scope,
408            self.inline_layout(scope),
409            name,
410            owner_tag,
411        )
412    }
413
414    fn install_instruction(
415        &self,
416        scope: &Scope,
417        spec: &InstructionSpec,
418    ) -> Result<InstallReport, AgentConfigError> {
419        instructions_dir::inline_install(scope, self.inline_layout(scope)?, spec)
420    }
421
422    fn uninstall_instruction(
423        &self,
424        scope: &Scope,
425        name: &str,
426        owner_tag: &str,
427    ) -> Result<UninstallReport, AgentConfigError> {
428        instructions_dir::inline_uninstall(scope, self.inline_layout(scope)?, name, owner_tag)
429    }
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435    use serde_json::{json, Value};
436    use tempfile::tempdir;
437
438    fn rules_spec(tag: &str, body: &str) -> HookSpec {
439        HookSpec::builder(tag)
440            .command_program("noop", [] as [&str; 0])
441            .rules(body)
442            .build()
443    }
444
445    fn mcp_spec(name: &str, owner: &str) -> McpSpec {
446        McpSpec::builder(name)
447            .owner(owner)
448            .stdio("npx", ["-y", "@example/server"])
449            .build()
450    }
451
452    fn skill(name: &str, owner: &str) -> SkillSpec {
453        SkillSpec::builder(name)
454            .owner(owner)
455            .description("Test Qwen skill.")
456            .body("## Goal\nDo it.\n")
457            .build()
458    }
459
460    fn read_json(p: &Path) -> Value {
461        serde_json::from_slice(&std::fs::read(p).unwrap()).unwrap()
462    }
463
464    #[test]
465    fn install_writes_qwen_md_block() {
466        let dir = tempdir().unwrap();
467        let agent = QwenAgent::new();
468        let scope = Scope::Local(dir.path().to_path_buf());
469        agent
470            .install(&scope, &rules_spec("alpha", "Use Qwen rules."))
471            .unwrap();
472        let body = std::fs::read_to_string(dir.path().join("QWEN.md")).unwrap();
473        assert!(body.contains("Use Qwen rules."));
474        assert!(body.contains("AGENT-CONFIG:alpha"));
475    }
476
477    #[test]
478    fn rules_install_idempotent() {
479        let dir = tempdir().unwrap();
480        let agent = QwenAgent::new();
481        let scope = Scope::Local(dir.path().to_path_buf());
482        let spec = rules_spec("alpha", "rules");
483        agent.install(&scope, &spec).unwrap();
484        let r2 = agent.install(&scope, &spec).unwrap();
485        assert!(r2.already_installed);
486    }
487
488    #[test]
489    fn install_uninstall_round_trip() {
490        let dir = tempdir().unwrap();
491        let agent = QwenAgent::new();
492        let scope = Scope::Local(dir.path().to_path_buf());
493        agent
494            .install(&scope, &rules_spec("alpha", "rules"))
495            .unwrap();
496        assert!(agent.is_installed(&scope, "alpha").unwrap());
497        agent.uninstall(&scope, "alpha").unwrap();
498        assert!(!agent.is_installed(&scope, "alpha").unwrap());
499    }
500
501    #[test]
502    fn install_requires_rules() {
503        let dir = tempdir().unwrap();
504        let agent = QwenAgent::new();
505        let scope = Scope::Local(dir.path().to_path_buf());
506        let spec = HookSpec::builder("alpha")
507            .command_program("noop", [] as [&str; 0])
508            .build();
509        let err = agent.install(&scope, &spec).unwrap_err();
510        assert!(matches!(err, AgentConfigError::MissingSpecField { .. }));
511    }
512
513    #[test]
514    fn install_mcp_writes_settings_json() {
515        let dir = tempdir().unwrap();
516        let agent = QwenAgent::new();
517        let scope = Scope::Local(dir.path().to_path_buf());
518        agent
519            .install_mcp(&scope, &mcp_spec("github", "myapp"))
520            .unwrap();
521        let v = read_json(&dir.path().join(".qwen/settings.json"));
522        assert_eq!(v["mcpServers"]["github"]["command"], json!("npx"));
523    }
524
525    #[test]
526    fn install_mcp_idempotent() {
527        let dir = tempdir().unwrap();
528        let agent = QwenAgent::new();
529        let scope = Scope::Local(dir.path().to_path_buf());
530        let s = mcp_spec("github", "myapp");
531        agent.install_mcp(&scope, &s).unwrap();
532        let r = agent.install_mcp(&scope, &s).unwrap();
533        assert!(r.already_installed);
534    }
535
536    #[test]
537    fn install_skill_writes_skills_dir() {
538        let dir = tempdir().unwrap();
539        let agent = QwenAgent::new();
540        let scope = Scope::Local(dir.path().to_path_buf());
541        agent
542            .install_skill(&scope, &skill("alpha-skill", "myapp"))
543            .unwrap();
544        assert!(dir
545            .path()
546            .join(".qwen/skills/alpha-skill/SKILL.md")
547            .exists());
548    }
549
550    #[test]
551    fn skill_owner_mismatch_refused() {
552        let dir = tempdir().unwrap();
553        let agent = QwenAgent::new();
554        let scope = Scope::Local(dir.path().to_path_buf());
555        agent
556            .install_skill(&scope, &skill("alpha-skill", "app-a"))
557            .unwrap();
558        let err = agent
559            .install_skill(&scope, &skill("alpha-skill", "app-b"))
560            .unwrap_err();
561        assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
562    }
563
564    #[test]
565    fn plan_install_does_not_write() {
566        let dir = tempdir().unwrap();
567        let agent = QwenAgent::new();
568        let scope = Scope::Local(dir.path().to_path_buf());
569        let spec = rules_spec("alpha", "rules");
570        let _plan = agent.plan_install(&scope, &spec).unwrap();
571        assert!(!dir.path().join("QWEN.md").exists());
572    }
573}