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        let rules = spec
131            .rules
132            .as_ref()
133            .ok_or(AgentConfigError::MissingSpecField {
134                id: "qwen",
135                field: "rules",
136            })?;
137        let path = Self::memory_path(scope)?;
138        let mut report = InstallReport::default();
139        scope.ensure_contained(&path)?;
140        file_lock::with_lock(&path, || {
141            let host = fs_atomic::read_to_string_or_empty(&path)?;
142            let new_host = md_block::upsert(&host, &spec.tag, &rules.content);
143            let outcome = safe_fs::write(scope, &path, new_host.as_bytes(), true)?;
144            if outcome.no_change {
145                report.already_installed = true;
146            } else if outcome.existed {
147                report.patched.push(outcome.path.clone());
148            } else {
149                report.created.push(outcome.path.clone());
150            }
151            if let Some(b) = outcome.backup {
152                report.backed_up.push(b);
153            }
154            Ok::<(), AgentConfigError>(())
155        })?;
156        Ok(report)
157    }
158
159    fn uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallReport, AgentConfigError> {
160        HookSpec::validate_tag(tag)?;
161        let path = Self::memory_path(scope)?;
162        let mut report = UninstallReport::default();
163        scope.ensure_contained(&path)?;
164        file_lock::with_lock(&path, || {
165            let host = fs_atomic::read_to_string_or_empty(&path)?;
166            let (stripped, removed) = md_block::remove(&host, tag);
167
168            if !removed {
169                report.not_installed = true;
170                return Ok(());
171            }
172
173            if stripped.trim().is_empty() {
174                if safe_fs::restore_backup_if_matches(scope, &path, stripped.as_bytes())? {
175                    report.restored.push(path.clone());
176                } else {
177                    safe_fs::remove_file(scope, &path)?;
178                    report.removed.push(path.clone());
179                }
180            } else {
181                safe_fs::write(scope, &path, stripped.as_bytes(), false)?;
182                report.patched.push(path.clone());
183            }
184            Ok::<(), AgentConfigError>(())
185        })?;
186        Ok(report)
187    }
188}
189
190impl McpSurface for QwenAgent {
191    fn id(&self) -> &'static str {
192        "qwen"
193    }
194
195    fn supported_mcp_scopes(&self) -> &'static [ScopeKind] {
196        &[ScopeKind::Global, ScopeKind::Local]
197    }
198
199    fn mcp_status(
200        &self,
201        scope: &Scope,
202        name: &str,
203        expected_owner: &str,
204    ) -> Result<StatusReport, AgentConfigError> {
205        McpSpec::validate_name(name)?;
206        let cfg = Self::mcp_path(scope)?;
207        let ledger = ownership::mcp_ledger_for(&cfg);
208        let presence = mcp_json_object::config_presence(&cfg, name)?;
209        let recorded = ownership::owner_of(&ledger, name)?;
210        Ok(StatusReport::for_mcp(
211            name,
212            cfg,
213            ledger,
214            presence,
215            expected_owner,
216            recorded,
217        ))
218    }
219
220    fn plan_install_mcp(
221        &self,
222        scope: &Scope,
223        spec: &McpSpec,
224    ) -> Result<InstallPlan, AgentConfigError> {
225        agent_planning::mcp_json_object_install(
226            McpSurface::id(self),
227            scope,
228            spec,
229            Self::mcp_path(scope),
230        )
231    }
232
233    fn plan_uninstall_mcp(
234        &self,
235        scope: &Scope,
236        name: &str,
237        owner_tag: &str,
238    ) -> Result<UninstallPlan, AgentConfigError> {
239        agent_planning::mcp_json_object_uninstall(
240            McpSurface::id(self),
241            scope,
242            name,
243            owner_tag,
244            Self::mcp_path(scope),
245        )
246    }
247
248    fn install_mcp(
249        &self,
250        scope: &Scope,
251        spec: &McpSpec,
252    ) -> Result<InstallReport, AgentConfigError> {
253        spec.validate()?;
254        let cfg = Self::mcp_path(scope)?;
255        spec.validate_local_secret_policy(scope)?;
256        scope.ensure_contained(&cfg)?;
257        let ledger = ownership::mcp_ledger_for(&cfg);
258        mcp_json_object::install(&cfg, &ledger, spec)
259    }
260
261    fn uninstall_mcp(
262        &self,
263        scope: &Scope,
264        name: &str,
265        owner_tag: &str,
266    ) -> Result<UninstallReport, AgentConfigError> {
267        McpSpec::validate_name(name)?;
268        HookSpec::validate_tag(owner_tag)?;
269        let cfg = Self::mcp_path(scope)?;
270        scope.ensure_contained(&cfg)?;
271        let ledger = ownership::mcp_ledger_for(&cfg);
272        mcp_json_object::uninstall(&cfg, &ledger, name, owner_tag, "mcp server")
273    }
274}
275
276impl SkillSurface for QwenAgent {
277    fn id(&self) -> &'static str {
278        "qwen"
279    }
280
281    fn supported_skill_scopes(&self) -> &'static [ScopeKind] {
282        &[ScopeKind::Global, ScopeKind::Local]
283    }
284
285    fn skill_status(
286        &self,
287        scope: &Scope,
288        name: &str,
289        expected_owner: &str,
290    ) -> Result<StatusReport, AgentConfigError> {
291        SkillSpec::validate_name(name)?;
292        let root = Self::skills_root(scope)?;
293        let (dir, manifest, ledger) = skills_dir::paths_for_status(&root, name);
294        let recorded = ownership::owner_of(&ledger, name)?;
295        Ok(StatusReport::for_skill(
296            name,
297            dir,
298            manifest,
299            ledger,
300            expected_owner,
301            recorded,
302        ))
303    }
304
305    fn plan_install_skill(
306        &self,
307        scope: &Scope,
308        spec: &SkillSpec,
309    ) -> Result<InstallPlan, AgentConfigError> {
310        agent_planning::skill_install(
311            SkillSurface::id(self),
312            scope,
313            spec,
314            Self::skills_root(scope),
315        )
316    }
317
318    fn plan_uninstall_skill(
319        &self,
320        scope: &Scope,
321        name: &str,
322        owner_tag: &str,
323    ) -> Result<UninstallPlan, AgentConfigError> {
324        agent_planning::skill_uninstall(
325            SkillSurface::id(self),
326            scope,
327            name,
328            owner_tag,
329            Self::skills_root(scope),
330        )
331    }
332
333    fn install_skill(
334        &self,
335        scope: &Scope,
336        spec: &SkillSpec,
337    ) -> Result<InstallReport, AgentConfigError> {
338        let root = Self::skills_root(scope)?;
339        scope.ensure_contained(&root)?;
340        skills_dir::install(&root, spec)
341    }
342
343    fn uninstall_skill(
344        &self,
345        scope: &Scope,
346        name: &str,
347        owner_tag: &str,
348    ) -> Result<UninstallReport, AgentConfigError> {
349        let root = Self::skills_root(scope)?;
350        scope.ensure_contained(&root)?;
351        skills_dir::uninstall(&root, name, owner_tag)
352    }
353}
354
355impl QwenAgent {
356    fn inline_layout(
357        &self,
358        scope: &Scope,
359    ) -> Result<instructions_dir::InlineLayout, AgentConfigError> {
360        Ok(instructions_dir::InlineLayout {
361            config_dir: Self::instruction_config_dir(scope)?,
362            host_file: Self::memory_path(scope)?,
363        })
364    }
365}
366
367impl InstructionSurface for QwenAgent {
368    fn id(&self) -> &'static str {
369        "qwen"
370    }
371
372    fn supported_instruction_scopes(&self) -> &'static [ScopeKind] {
373        &[ScopeKind::Global, ScopeKind::Local]
374    }
375
376    fn instruction_status(
377        &self,
378        scope: &Scope,
379        name: &str,
380        expected_owner: &str,
381    ) -> Result<StatusReport, AgentConfigError> {
382        instructions_dir::inline_status(self.inline_layout(scope)?, name, expected_owner)
383    }
384
385    fn plan_install_instruction(
386        &self,
387        scope: &Scope,
388        spec: &InstructionSpec,
389    ) -> Result<InstallPlan, AgentConfigError> {
390        instructions_dir::inline_plan_install(
391            InstructionSurface::id(self),
392            scope,
393            self.inline_layout(scope),
394            spec,
395        )
396    }
397
398    fn plan_uninstall_instruction(
399        &self,
400        scope: &Scope,
401        name: &str,
402        owner_tag: &str,
403    ) -> Result<UninstallPlan, AgentConfigError> {
404        instructions_dir::inline_plan_uninstall(
405            InstructionSurface::id(self),
406            scope,
407            self.inline_layout(scope),
408            name,
409            owner_tag,
410        )
411    }
412
413    fn install_instruction(
414        &self,
415        scope: &Scope,
416        spec: &InstructionSpec,
417    ) -> Result<InstallReport, AgentConfigError> {
418        instructions_dir::inline_install(scope, self.inline_layout(scope)?, spec)
419    }
420
421    fn uninstall_instruction(
422        &self,
423        scope: &Scope,
424        name: &str,
425        owner_tag: &str,
426    ) -> Result<UninstallReport, AgentConfigError> {
427        instructions_dir::inline_uninstall(scope, self.inline_layout(scope)?, name, owner_tag)
428    }
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434    use serde_json::{json, Value};
435    use tempfile::tempdir;
436
437    fn rules_spec(tag: &str, body: &str) -> HookSpec {
438        HookSpec::builder(tag)
439            .command_program("noop", [] as [&str; 0])
440            .rules(body)
441            .build()
442    }
443
444    fn mcp_spec(name: &str, owner: &str) -> McpSpec {
445        McpSpec::builder(name)
446            .owner(owner)
447            .stdio("npx", ["-y", "@example/server"])
448            .build()
449    }
450
451    fn skill(name: &str, owner: &str) -> SkillSpec {
452        SkillSpec::builder(name)
453            .owner(owner)
454            .description("Test Qwen skill.")
455            .body("## Goal\nDo it.\n")
456            .build()
457    }
458
459    fn read_json(p: &Path) -> Value {
460        serde_json::from_slice(&std::fs::read(p).unwrap()).unwrap()
461    }
462
463    #[test]
464    fn install_writes_qwen_md_block() {
465        let dir = tempdir().unwrap();
466        let agent = QwenAgent::new();
467        let scope = Scope::Local(dir.path().to_path_buf());
468        agent
469            .install(&scope, &rules_spec("alpha", "Use Qwen rules."))
470            .unwrap();
471        let body = std::fs::read_to_string(dir.path().join("QWEN.md")).unwrap();
472        assert!(body.contains("Use Qwen rules."));
473        assert!(body.contains("AGENT-CONFIG:alpha"));
474    }
475
476    #[test]
477    fn rules_install_idempotent() {
478        let dir = tempdir().unwrap();
479        let agent = QwenAgent::new();
480        let scope = Scope::Local(dir.path().to_path_buf());
481        let spec = rules_spec("alpha", "rules");
482        agent.install(&scope, &spec).unwrap();
483        let r2 = agent.install(&scope, &spec).unwrap();
484        assert!(r2.already_installed);
485    }
486
487    #[test]
488    fn install_uninstall_round_trip() {
489        let dir = tempdir().unwrap();
490        let agent = QwenAgent::new();
491        let scope = Scope::Local(dir.path().to_path_buf());
492        agent
493            .install(&scope, &rules_spec("alpha", "rules"))
494            .unwrap();
495        assert!(agent.is_installed(&scope, "alpha").unwrap());
496        agent.uninstall(&scope, "alpha").unwrap();
497        assert!(!agent.is_installed(&scope, "alpha").unwrap());
498    }
499
500    #[test]
501    fn install_requires_rules() {
502        let dir = tempdir().unwrap();
503        let agent = QwenAgent::new();
504        let scope = Scope::Local(dir.path().to_path_buf());
505        let spec = HookSpec::builder("alpha")
506            .command_program("noop", [] as [&str; 0])
507            .build();
508        let err = agent.install(&scope, &spec).unwrap_err();
509        assert!(matches!(err, AgentConfigError::MissingSpecField { .. }));
510    }
511
512    #[test]
513    fn install_mcp_writes_settings_json() {
514        let dir = tempdir().unwrap();
515        let agent = QwenAgent::new();
516        let scope = Scope::Local(dir.path().to_path_buf());
517        agent
518            .install_mcp(&scope, &mcp_spec("github", "myapp"))
519            .unwrap();
520        let v = read_json(&dir.path().join(".qwen/settings.json"));
521        assert_eq!(v["mcpServers"]["github"]["command"], json!("npx"));
522    }
523
524    #[test]
525    fn install_mcp_idempotent() {
526        let dir = tempdir().unwrap();
527        let agent = QwenAgent::new();
528        let scope = Scope::Local(dir.path().to_path_buf());
529        let s = mcp_spec("github", "myapp");
530        agent.install_mcp(&scope, &s).unwrap();
531        let r = agent.install_mcp(&scope, &s).unwrap();
532        assert!(r.already_installed);
533    }
534
535    #[test]
536    fn install_skill_writes_skills_dir() {
537        let dir = tempdir().unwrap();
538        let agent = QwenAgent::new();
539        let scope = Scope::Local(dir.path().to_path_buf());
540        agent
541            .install_skill(&scope, &skill("alpha-skill", "myapp"))
542            .unwrap();
543        assert!(dir
544            .path()
545            .join(".qwen/skills/alpha-skill/SKILL.md")
546            .exists());
547    }
548
549    #[test]
550    fn skill_owner_mismatch_refused() {
551        let dir = tempdir().unwrap();
552        let agent = QwenAgent::new();
553        let scope = Scope::Local(dir.path().to_path_buf());
554        agent
555            .install_skill(&scope, &skill("alpha-skill", "app-a"))
556            .unwrap();
557        let err = agent
558            .install_skill(&scope, &skill("alpha-skill", "app-b"))
559            .unwrap_err();
560        assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
561    }
562
563    #[test]
564    fn plan_install_does_not_write() {
565        let dir = tempdir().unwrap();
566        let agent = QwenAgent::new();
567        let scope = Scope::Local(dir.path().to_path_buf());
568        let spec = rules_spec("alpha", "rules");
569        let _plan = agent.plan_install(&scope, &spec).unwrap();
570        assert!(!dir.path().join("QWEN.md").exists());
571    }
572}