Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,14 @@ Five surfaces, each rendered into the harness's own format — dotagents does no
| Factory Droid | yes | yes | yes | yes | -- |
| Hermes | yes | -- | yes | yes | -- |
| OpenCode | yes† | yes | yes | -- | -- |
| Qwen Code | yes, config-driven | yes | yes | yes | skills + MCP§ |
| OMP (pi fork) | yes | yes | yes | --‡ | -- |
| Pi* | yes | --* | --* | -- | -- |

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

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.

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

## Roles

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).
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).

## Commands

Expand Down
11 changes: 11 additions & 0 deletions cmd/dotagents/agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ type agentRole struct {
OMP ompRoleOptions `yaml:"omp"`
Droid droidRoleOptions `yaml:"droid"`
Opencode opencodeRoleOptions `yaml:"opencode"`
Qwen qwenRoleOptions `yaml:"qwen"`
}

func (role *agentRole) UnmarshalYAML(value *yaml.Node) error {
Expand Down Expand Up @@ -89,6 +90,10 @@ func (role *agentRole) UnmarshalYAML(value *yaml.Node) error {
if err := node.Decode(&role.Opencode); err != nil {
return err
}
case "qwen":
if err := node.Decode(&role.Qwen); err != nil {
return err
}
case "tools":
tools, err := decodeRoleTools(node)
if err != nil {
Expand Down Expand Up @@ -147,6 +152,12 @@ type opencodeRoleOptions struct {
Mode string `yaml:"mode"`
}

type qwenRoleOptions struct {
Model string `yaml:"model"`
ApprovalMode string `yaml:"approval_mode"`
Tools []string `yaml:"tools"`
}

var droidToolMapping = map[string][]string{
"bash": {"Execute"},
"edit": {"Edit"},
Expand Down
1 change: 1 addition & 0 deletions cmd/dotagents/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const (
agentOpenCode = "opencode"
agentPi = "pi"
agentOMP = "omp"
agentQwenCode = "qwen-code"
dotagentsSkillsPathValue = "~/.agents/skills"
)

Expand Down
27 changes: 27 additions & 0 deletions cmd/dotagents/harness.go
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,33 @@ func initHarnesses() {
}),
Roles: &RolesCapability{Extension: ".md", Render: renderOMPAgentRole},
},

agentQwenCode: {
Skills: SkillsConfigDriven,
InspectSkills: func(agent agentConfig, expected map[string]string, agentsSkillRoot string, cfg config, home string) (agentReport, error) {
return inspectQwenAgent(agent, expected, agentsSkillRoot, cfg, home)
},
Setup: patchQwenConfig,
MCP: mcpTargetPtr(mcpTarget{
agentName: agentQwenCode,
configPath: qwenSettingsPath,
inspect: inspectJSONMCPServer,
patch: patchJSONMCPServer,
read: readJSONMCPServer,
rootKey: "mcpServers",
}),
Roles: &RolesCapability{Extension: ".md", Render: renderQwenAgentRole},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve imported Qwen roles before enabling role sync

When first-run setup shares an existing ~/.qwen/agents/<name>.md, this capability causes scanNativeRoles to use the generic Markdown importer, which copies native top-level fields such as model, tools, and approvalMode into the canonical file. renderQwenAgentRole instead expects nested qwen options and adds generated metadata, so the expected output differs from the untouched native source; the first sync then classifies that source as an unmanaged conflict and aborts. Add a Qwen-specific conversion that preserves those native fields in canonical qwen options so importing an existing Qwen role can complete.

AGENTS.md reference: AGENTS.md:L17-L19

Useful? React with 👍 / 👎.

Hooks: &hookTarget{
agentName: agentQwenCode,
inspect: inspectQwenHook,
patch: patchQwenHook,
},
RootInstructions: &RootInstructionsCapability{
Path: func(home string) string { return filepath.Join(home, ".qwen", "QWEN.md") },
Expected: func(repoRoot string) string { return filepath.Join(repoRoot, "AGENTS.md") },
},
IntegrationNote: "config-driven via ~/.qwen/settings.json -> skills.directories",
},
}
}

Expand Down
20 changes: 20 additions & 0 deletions cmd/dotagents/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,23 @@ func patchDroidHook(hook hookConfig, home string) error {
return patchNestedJSONHook(activeDroidHooksConfigPath(home), hook)
}

func inspectQwenHook(hook hookConfig, home string) (string, error) {
return inspectNestedJSONHook(qwenSettingsPath(home), nativeQwenHook(hook))
}

func patchQwenHook(hook hookConfig, home string) error {
return patchNestedJSONHook(qwenSettingsPath(home), nativeQwenHook(hook))
}

func nativeQwenHook(hook hookConfig) hookConfig {
// Qwen Code expresses command hook timeouts in milliseconds. Canonical
// dotagents hook timeouts are seconds, matching the other harnesses.
if hook.Timeout > 0 {
hook.Timeout *= 1000
}
return hook
}

func removeNativeManagedMemoryHooks(home string, root string, prior config, current config) (int, error) {
commands := managedMemoryNativeCommands(home, root, prior, current)
if len(commands) == 0 {
Expand All @@ -266,6 +283,9 @@ func removeNativeManagedMemoryHooks(home string, root string, prior config, curr
func(home string, commands []string) (bool, error) {
return removeGroupedJSONHookCommands(droidLegacyHooksConfigPath(home), commands)
},
func(home string, commands []string) (bool, error) {
return removeGroupedJSONHookCommands(qwenSettingsPath(home), commands)
},
func(home string, commands []string) (bool, error) {
return removeSimpleYAMLHookCommands(filepath.Join(home, ".hermes", "config.yaml"), commands)
},
Expand Down
202 changes: 202 additions & 0 deletions cmd/dotagents/qwen.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
package main

import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
)

func qwenSettingsPath(home string) string {
return filepath.Join(home, ".qwen", "settings.json")
}

func patchQwenConfig(home string, repoRoot string, _ config) (bool, error) {
configPath := qwenSettingsPath(home)
raw := map[string]interface{}{}
data, err := os.ReadFile(configPath)
if err != nil {
if !errors.Is(err, fs.ErrNotExist) {
return false, fmt.Errorf("read %s: %w", configPath, err)
}
} else if err := parseJSONConfig(configPath, data, &raw); err != nil {
return false, fmt.Errorf("parse %s: %w", configPath, err)
}

skillsValue, skillsExists := raw["skills"]
skills, skillsValid := skillsValue.(map[string]interface{})
if skillsExists && skillsValue != nil && !skillsValid {
return false, fmt.Errorf("skills key in %s is not an object", configPath)
}
if !skillsExists || skillsValue == nil {
skills = map[string]interface{}{}
raw["skills"] = skills
}

target := filepath.Join(repoRoot, "skills")
directoriesValue, directoriesExists := skills["directories"]
directories, directoriesValid := directoriesValue.([]interface{})
if directoriesExists && directoriesValue != nil && !directoriesValid {
return false, fmt.Errorf("skills.directories in %s is not an array", configPath)
}
for _, value := range directories {
directory, ok := value.(string)
if ok && filepath.Clean(expandPath(directory, home)) == filepath.Clean(target) {
return false, nil
}
}
skills["directories"] = append(directories, hermesExternalDirValue(home, target))

out, err := json.MarshalIndent(raw, "", " ")
if err != nil {
return false, fmt.Errorf("marshal %s: %w", configPath, err)
}
out = append(out, '\n')
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
return false, fmt.Errorf("create %s: %w", filepath.Dir(configPath), err)
}
if err := os.WriteFile(configPath, out, 0o644); err != nil {
return false, fmt.Errorf("write %s: %w", configPath, err)
}
return true, nil
}

func qwenHasSkillsDirectory(home string, target string) (bool, error) {
configPath := qwenSettingsPath(home)
data, err := os.ReadFile(configPath)
if errors.Is(err, fs.ErrNotExist) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("read %s: %w", configPath, err)
}
var raw map[string]interface{}
if err := parseJSONConfig(configPath, data, &raw); err != nil {
return false, fmt.Errorf("parse %s: %w", configPath, err)
}
skills, _ := raw["skills"].(map[string]interface{})
directories, _ := skills["directories"].([]interface{})
for _, value := range directories {
directory, ok := value.(string)
if ok && filepath.Clean(expandPath(directory, home)) == filepath.Clean(target) {
return true, nil
}
}
return false, nil
}

func inspectQwenAgent(agent agentConfig, expected map[string]string, agentsSkillRoot string, cfg config, home string) (agentReport, error) {
report := agentReport{
Name: agent.Name,
SkillRoot: agent.SkillRoot,
AgentRoot: agent.AgentRoot,
ExpectedSkills: expected,
Detected: isDetected(agent),
}
if !report.Detected {
return report, nil
}

entries, err := os.ReadDir(agent.SkillRoot)
if err == nil {
for _, entry := range entries {
if !strings.HasPrefix(entry.Name(), ".") {
report.External = append(report.External, entry.Name())
}
}
} else if !errors.Is(err, fs.ErrNotExist) {
return agentReport{}, fmt.Errorf("read %s: %w", agent.SkillRoot, err)
}

configured, err := qwenHasSkillsDirectory(home, agentsSkillRoot)
if err != nil {
return agentReport{}, err
}
if !configured {
report.Missing = append(report.Missing, "config skills.directories")
report.Adds = append(report.Adds, "config skills.directories")
}
report.Managed = append(report.Managed, sortedKeys(expected)...)
if err := augmentMCPReport(&report, agent, cfg, home); err != nil {
return agentReport{}, err
}
if err := augmentHookReport(&report, agent, cfg, home); err != nil {
return agentReport{}, err
}
if err := inspectAgentRoles(&report, filepath.Dir(agentsSkillRoot), agent); err != nil {
return agentReport{}, err
}
if h := harnessFor(agent.Name); h != nil && h.RootInstructions != nil {
if err := inspectRootInstructions(&report, h.RootInstructions, filepath.Dir(agentsSkillRoot), home); err != nil {
return agentReport{}, err
}
}

sortReportLists(&report)
report.Synced = isReportSynced(report)
return report, nil
}

var qwenToolMapping = map[string]string{
"bash": "run_shell_command",
"edit": "replace",
"glob": "glob",
"grep": "grep_search",
"read": "read_file",
"webfetch": "web_fetch",
"websearch": "web_search",
"write": "write_file",
}

func renderQwenAgentRole(role agentRole) string {
model := strings.TrimSpace(role.Qwen.Model)
if model == "" {
model = "inherit"
}
tools := role.Qwen.Tools
if len(tools) == 0 {
tools = qwenToolsFor(role.Tools)
}

var b strings.Builder
b.WriteString("---\n")
writeYAMLScalar(&b, "name", role.Name)
writeYAMLScalar(&b, "description", role.Description)
writeYAMLScalar(&b, "model", model)
writeYAMLScalar(&b, "approvalMode", role.Qwen.ApprovalMode)
if len(tools) > 0 {
b.WriteString("tools:\n")
for _, tool := range tools {
writeYAMLListItem(&b, tool)
}
}
b.WriteString("---\n\n")
b.WriteString("<!-- ")
b.WriteString(generatedAgentMarker)
b.WriteString(" from ")
b.WriteString(agentRoleSourceLabel(role))
b.WriteString("; do not edit directly. -->\n\n")
b.WriteString(role.Instructions)
b.WriteString("\n")
return b.String()
}

func qwenToolsFor(tools []string) []string {
out := make([]string, 0, len(tools))
seen := make(map[string]struct{}, len(tools))
for _, tool := range tools {
mapped := qwenToolMapping[strings.ToLower(strings.TrimSpace(tool))]
if mapped == "" {
continue
}
if _, ok := seen[mapped]; ok {
continue
}
seen[mapped] = struct{}{}
out = append(out, mapped)
}
return out
}
Loading
Loading