Skip to content

Commit c54dee2

Browse files
add Qwen Code support (#157)
Co-authored-by: Kirill Korikov <11762090+yourconscience@users.noreply.github.com>
1 parent 817e95d commit c54dee2

13 files changed

Lines changed: 540 additions & 7 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,14 @@ Five surfaces, each rendered into the harness's own format — dotagents does no
4242
| Factory Droid | yes | yes | yes | yes | -- |
4343
| Hermes | yes | -- | yes | yes | -- |
4444
| OpenCode | yes† | yes | yes | -- | -- |
45+
| Qwen Code | yes, config-driven | yes | yes | yes | skills + MCP§ |
4546
| OMP (pi fork) | yes | yes | yes | --‡ | -- |
4647
| Pi* | yes | --* | --* | -- | -- |
4748

4849
\* Vanilla [pi](https://github.com/earendil-works/pi) is skills-only by design; the OMP fork is detected as its own target.
4950
† OpenCode reads `~/.agents/skills/` natively; its only hook surface is a JS plugin API.
5051
‡ OMP has no managed hook surface yet; register memory hooks manually if needed.
52+
§ Qwen Code natively loads Agent Plugins v1 skills and MCP servers; dotagents manages those same surfaces without rewriting the plugin.
5153

5254
Amp and OpenClaw read the repo's skills via standard conventions but are not managed. A "yes" above only appears after end-to-end verification.
5355

@@ -70,7 +72,7 @@ Candidates are inert until you promote them into durable instructions — consol
7072

7173
## Roles
7274

73-
Markdown role definitions in `~/.agents/agents/`, rendered to each harness's native format (Claude Markdown, Codex TOML, Droid). Generic `model` tiers (`haiku`/`sonnet`/`opus`) render natively per family; per-harness overrides pin exact ids. Six starter roles ship with the tool; yours win on name collision. Details in [docs/roles.md](docs/roles.md).
75+
Markdown role definitions in `~/.agents/agents/`, rendered to each harness's native format (Claude Markdown, Codex TOML, Qwen Markdown, Droid). Generic `model` tiers (`haiku`/`sonnet`/`opus`) render natively per family; per-harness overrides pin exact ids. Six starter roles ship with the tool; yours win on name collision. Details in [docs/roles.md](docs/roles.md).
7476

7577
## Commands
7678

cmd/dotagents/agents.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ type agentRole struct {
3939
OMP ompRoleOptions `yaml:"omp"`
4040
Droid droidRoleOptions `yaml:"droid"`
4141
Opencode opencodeRoleOptions `yaml:"opencode"`
42+
Qwen qwenRoleOptions `yaml:"qwen"`
4243
}
4344

4445
func (role *agentRole) UnmarshalYAML(value *yaml.Node) error {
@@ -89,6 +90,10 @@ func (role *agentRole) UnmarshalYAML(value *yaml.Node) error {
8990
if err := node.Decode(&role.Opencode); err != nil {
9091
return err
9192
}
93+
case "qwen":
94+
if err := node.Decode(&role.Qwen); err != nil {
95+
return err
96+
}
9297
case "tools":
9398
tools, err := decodeRoleTools(node)
9499
if err != nil {
@@ -147,6 +152,12 @@ type opencodeRoleOptions struct {
147152
Mode string `yaml:"mode"`
148153
}
149154

155+
type qwenRoleOptions struct {
156+
Model string `yaml:"model"`
157+
ApprovalMode string `yaml:"approval_mode"`
158+
Tools []string `yaml:"tools"`
159+
}
160+
150161
var droidToolMapping = map[string][]string{
151162
"bash": {"Execute"},
152163
"edit": {"Edit"},

cmd/dotagents/doctor.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const (
2424
agentOpenCode = "opencode"
2525
agentPi = "pi"
2626
agentOMP = "omp"
27+
agentQwenCode = "qwen-code"
2728
dotagentsSkillsPathValue = "~/.agents/skills"
2829
)
2930

cmd/dotagents/harness.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,33 @@ func initHarnesses() {
253253
}),
254254
Roles: &RolesCapability{Extension: ".md", Render: renderOMPAgentRole},
255255
},
256+
257+
agentQwenCode: {
258+
Skills: SkillsConfigDriven,
259+
InspectSkills: func(agent agentConfig, expected map[string]string, agentsSkillRoot string, cfg config, home string) (agentReport, error) {
260+
return inspectQwenAgent(agent, expected, agentsSkillRoot, cfg, home)
261+
},
262+
Setup: patchQwenConfig,
263+
MCP: mcpTargetPtr(mcpTarget{
264+
agentName: agentQwenCode,
265+
configPath: qwenSettingsPath,
266+
inspect: inspectJSONMCPServer,
267+
patch: patchJSONMCPServer,
268+
read: readJSONMCPServer,
269+
rootKey: "mcpServers",
270+
}),
271+
Roles: &RolesCapability{Extension: ".md", Render: renderQwenAgentRole},
272+
Hooks: &hookTarget{
273+
agentName: agentQwenCode,
274+
inspect: inspectQwenHook,
275+
patch: patchQwenHook,
276+
},
277+
RootInstructions: &RootInstructionsCapability{
278+
Path: func(home string) string { return filepath.Join(home, ".qwen", "QWEN.md") },
279+
Expected: func(repoRoot string) string { return filepath.Join(repoRoot, "AGENTS.md") },
280+
},
281+
IntegrationNote: "config-driven via ~/.qwen/settings.json -> skills.directories",
282+
},
256283
}
257284
}
258285

cmd/dotagents/hooks.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,23 @@ func patchDroidHook(hook hookConfig, home string) error {
247247
return patchNestedJSONHook(activeDroidHooksConfigPath(home), hook)
248248
}
249249

250+
func inspectQwenHook(hook hookConfig, home string) (string, error) {
251+
return inspectNestedJSONHook(qwenSettingsPath(home), nativeQwenHook(hook))
252+
}
253+
254+
func patchQwenHook(hook hookConfig, home string) error {
255+
return patchNestedJSONHook(qwenSettingsPath(home), nativeQwenHook(hook))
256+
}
257+
258+
func nativeQwenHook(hook hookConfig) hookConfig {
259+
// Qwen Code expresses command hook timeouts in milliseconds. Canonical
260+
// dotagents hook timeouts are seconds, matching the other harnesses.
261+
if hook.Timeout > 0 {
262+
hook.Timeout *= 1000
263+
}
264+
return hook
265+
}
266+
250267
func removeNativeManagedMemoryHooks(home string, root string, prior config, current config) (int, error) {
251268
commands := managedMemoryNativeCommands(home, root, prior, current)
252269
if len(commands) == 0 {
@@ -266,6 +283,9 @@ func removeNativeManagedMemoryHooks(home string, root string, prior config, curr
266283
func(home string, commands []string) (bool, error) {
267284
return removeGroupedJSONHookCommands(droidLegacyHooksConfigPath(home), commands)
268285
},
286+
func(home string, commands []string) (bool, error) {
287+
return removeGroupedJSONHookCommands(qwenSettingsPath(home), commands)
288+
},
269289
func(home string, commands []string) (bool, error) {
270290
return removeSimpleYAMLHookCommands(filepath.Join(home, ".hermes", "config.yaml"), commands)
271291
},

cmd/dotagents/qwen.go

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"fmt"
7+
"io/fs"
8+
"os"
9+
"path/filepath"
10+
"strings"
11+
)
12+
13+
func qwenSettingsPath(home string) string {
14+
return filepath.Join(home, ".qwen", "settings.json")
15+
}
16+
17+
func patchQwenConfig(home string, repoRoot string, _ config) (bool, error) {
18+
configPath := qwenSettingsPath(home)
19+
raw := map[string]interface{}{}
20+
data, err := os.ReadFile(configPath)
21+
if err != nil {
22+
if !errors.Is(err, fs.ErrNotExist) {
23+
return false, fmt.Errorf("read %s: %w", configPath, err)
24+
}
25+
} else if err := parseJSONConfig(configPath, data, &raw); err != nil {
26+
return false, fmt.Errorf("parse %s: %w", configPath, err)
27+
}
28+
29+
skillsValue, skillsExists := raw["skills"]
30+
skills, skillsValid := skillsValue.(map[string]interface{})
31+
if skillsExists && skillsValue != nil && !skillsValid {
32+
return false, fmt.Errorf("skills key in %s is not an object", configPath)
33+
}
34+
if !skillsExists || skillsValue == nil {
35+
skills = map[string]interface{}{}
36+
raw["skills"] = skills
37+
}
38+
39+
target := filepath.Join(repoRoot, "skills")
40+
directoriesValue, directoriesExists := skills["directories"]
41+
directories, directoriesValid := directoriesValue.([]interface{})
42+
if directoriesExists && directoriesValue != nil && !directoriesValid {
43+
return false, fmt.Errorf("skills.directories in %s is not an array", configPath)
44+
}
45+
for _, value := range directories {
46+
directory, ok := value.(string)
47+
if ok && filepath.Clean(expandPath(directory, home)) == filepath.Clean(target) {
48+
return false, nil
49+
}
50+
}
51+
skills["directories"] = append(directories, hermesExternalDirValue(home, target))
52+
53+
out, err := json.MarshalIndent(raw, "", " ")
54+
if err != nil {
55+
return false, fmt.Errorf("marshal %s: %w", configPath, err)
56+
}
57+
out = append(out, '\n')
58+
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
59+
return false, fmt.Errorf("create %s: %w", filepath.Dir(configPath), err)
60+
}
61+
if err := os.WriteFile(configPath, out, 0o644); err != nil {
62+
return false, fmt.Errorf("write %s: %w", configPath, err)
63+
}
64+
return true, nil
65+
}
66+
67+
func qwenHasSkillsDirectory(home string, target string) (bool, error) {
68+
configPath := qwenSettingsPath(home)
69+
data, err := os.ReadFile(configPath)
70+
if errors.Is(err, fs.ErrNotExist) {
71+
return false, nil
72+
}
73+
if err != nil {
74+
return false, fmt.Errorf("read %s: %w", configPath, err)
75+
}
76+
var raw map[string]interface{}
77+
if err := parseJSONConfig(configPath, data, &raw); err != nil {
78+
return false, fmt.Errorf("parse %s: %w", configPath, err)
79+
}
80+
skills, _ := raw["skills"].(map[string]interface{})
81+
directories, _ := skills["directories"].([]interface{})
82+
for _, value := range directories {
83+
directory, ok := value.(string)
84+
if ok && filepath.Clean(expandPath(directory, home)) == filepath.Clean(target) {
85+
return true, nil
86+
}
87+
}
88+
return false, nil
89+
}
90+
91+
func inspectQwenAgent(agent agentConfig, expected map[string]string, agentsSkillRoot string, cfg config, home string) (agentReport, error) {
92+
report := agentReport{
93+
Name: agent.Name,
94+
SkillRoot: agent.SkillRoot,
95+
AgentRoot: agent.AgentRoot,
96+
ExpectedSkills: expected,
97+
Detected: isDetected(agent),
98+
}
99+
if !report.Detected {
100+
return report, nil
101+
}
102+
103+
entries, err := os.ReadDir(agent.SkillRoot)
104+
if err == nil {
105+
for _, entry := range entries {
106+
if !strings.HasPrefix(entry.Name(), ".") {
107+
report.External = append(report.External, entry.Name())
108+
}
109+
}
110+
} else if !errors.Is(err, fs.ErrNotExist) {
111+
return agentReport{}, fmt.Errorf("read %s: %w", agent.SkillRoot, err)
112+
}
113+
114+
configured, err := qwenHasSkillsDirectory(home, agentsSkillRoot)
115+
if err != nil {
116+
return agentReport{}, err
117+
}
118+
if !configured {
119+
report.Missing = append(report.Missing, "config skills.directories")
120+
report.Adds = append(report.Adds, "config skills.directories")
121+
}
122+
report.Managed = append(report.Managed, sortedKeys(expected)...)
123+
if err := augmentMCPReport(&report, agent, cfg, home); err != nil {
124+
return agentReport{}, err
125+
}
126+
if err := augmentHookReport(&report, agent, cfg, home); err != nil {
127+
return agentReport{}, err
128+
}
129+
if err := inspectAgentRoles(&report, filepath.Dir(agentsSkillRoot), agent); err != nil {
130+
return agentReport{}, err
131+
}
132+
if h := harnessFor(agent.Name); h != nil && h.RootInstructions != nil {
133+
if err := inspectRootInstructions(&report, h.RootInstructions, filepath.Dir(agentsSkillRoot), home); err != nil {
134+
return agentReport{}, err
135+
}
136+
}
137+
138+
sortReportLists(&report)
139+
report.Synced = isReportSynced(report)
140+
return report, nil
141+
}
142+
143+
var qwenToolMapping = map[string]string{
144+
"bash": "run_shell_command",
145+
"edit": "replace",
146+
"glob": "glob",
147+
"grep": "grep_search",
148+
"read": "read_file",
149+
"webfetch": "web_fetch",
150+
"websearch": "web_search",
151+
"write": "write_file",
152+
}
153+
154+
func renderQwenAgentRole(role agentRole) string {
155+
model := strings.TrimSpace(role.Qwen.Model)
156+
if model == "" {
157+
model = "inherit"
158+
}
159+
tools := role.Qwen.Tools
160+
if len(tools) == 0 {
161+
tools = qwenToolsFor(role.Tools)
162+
}
163+
164+
var b strings.Builder
165+
b.WriteString("---\n")
166+
writeYAMLScalar(&b, "name", role.Name)
167+
writeYAMLScalar(&b, "description", role.Description)
168+
writeYAMLScalar(&b, "model", model)
169+
writeYAMLScalar(&b, "approvalMode", role.Qwen.ApprovalMode)
170+
if len(tools) > 0 {
171+
b.WriteString("tools:\n")
172+
for _, tool := range tools {
173+
writeYAMLListItem(&b, tool)
174+
}
175+
}
176+
b.WriteString("---\n\n")
177+
b.WriteString("<!-- ")
178+
b.WriteString(generatedAgentMarker)
179+
b.WriteString(" from ")
180+
b.WriteString(agentRoleSourceLabel(role))
181+
b.WriteString("; do not edit directly. -->\n\n")
182+
b.WriteString(role.Instructions)
183+
b.WriteString("\n")
184+
return b.String()
185+
}
186+
187+
func qwenToolsFor(tools []string) []string {
188+
out := make([]string, 0, len(tools))
189+
seen := make(map[string]struct{}, len(tools))
190+
for _, tool := range tools {
191+
mapped := qwenToolMapping[strings.ToLower(strings.TrimSpace(tool))]
192+
if mapped == "" {
193+
continue
194+
}
195+
if _, ok := seen[mapped]; ok {
196+
continue
197+
}
198+
seen[mapped] = struct{}{}
199+
out = append(out, mapped)
200+
}
201+
return out
202+
}

0 commit comments

Comments
 (0)