From ffc7a153d3ba13e8ecdf9c8a7f1a86802a1aa290 Mon Sep 17 00:00:00 2001 From: Kirill Korikov Date: Wed, 10 Jun 2026 10:06:42 +0200 Subject: [PATCH 1/6] remove machine-specific cua-driver skill symlink --- .gitignore | 1 + skills/cua-driver | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 120000 skills/cua-driver diff --git a/.gitignore b/.gitignore index 361f4a6..e5d01e2 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ skills/jobs/tools/portals-scan/portals-scan skills/remote-access/tools/remote-access-bridge/remote-access-bridge skills/handoff/bin/ research/ +skills/cua-driver diff --git a/skills/cua-driver b/skills/cua-driver deleted file mode 120000 index ebe6edb..0000000 --- a/skills/cua-driver +++ /dev/null @@ -1 +0,0 @@ -/Applications/CuaDriver.app/Contents/Resources/Skills/cua-driver \ No newline at end of file From df1f62b62d1f6d5251b3a4a41bcb33687e59b95a Mon Sep 17 00:00:00 2001 From: Kirill Korikov Date: Wed, 10 Jun 2026 10:06:47 +0200 Subject: [PATCH 2/6] self-host repo as a Claude Code plugin and single-plugin marketplace --- .agnix.toml | 5 ++ .claude-plugin/marketplace.json | 15 ++++++ .claude-plugin/plugin.json | 13 ++++++ README.md | 23 ++++++++- agents/architect.md | 24 ++++++++++ agents/builder.md | 25 ++++++++++ agents/researcher.md | 22 +++++++++ agents/reviewer.md | 27 +++++++++++ cmd/dotagents/agents.go | 78 +++++++++++++++++++++++++++++++ cmd/dotagents/agents_test.go | 83 +++++++++++++++++++++++++++++++++ cmd/dotagents/doctor.go | 43 +++++++++++++++++ cmd/dotagents/main.go | 7 +++ dotagents.yaml | 12 +++++ 13 files changed, 375 insertions(+), 2 deletions(-) create mode 100644 .claude-plugin/marketplace.json create mode 100644 .claude-plugin/plugin.json create mode 100644 agents/architect.md create mode 100644 agents/builder.md create mode 100644 agents/researcher.md create mode 100644 agents/reviewer.md diff --git a/.agnix.toml b/.agnix.toml index c1b1363..2884f62 100644 --- a/.agnix.toml +++ b/.agnix.toml @@ -9,6 +9,11 @@ exclude = [ "experimental/**", "research/**", "skills/jobs/tools/portals-scan/portals-scan", + # Generated Claude-native subagent renders (agents/*.md, beside the *.yaml + # sources). Claude Code's docs require a comma-separated `tools:` string; + # agnix wrongly demands a YAML list and cannot parse them. dotagents doctor's + # "plugin agents" check validates these instead. + "agents/*.md", ] [rules] diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..97cb246 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,15 @@ +{ + "name": "yourconscience", + "description": "Self-hosted marketplace for the dotagents cross-agent skill library.", + "owner": { + "name": "Kirill Korikov", + "email": "korikov.kirill@proton.me" + }, + "plugins": [ + { + "name": "dotagents", + "source": "./", + "description": "Cross-agent skill library and subagent roles from the dotagents repo." + } + ] +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..5097b1f --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,13 @@ +{ + "name": "dotagents", + "version": "0.1.0", + "description": "Cross-agent skill library and subagent roles: cmux, tg, jobs, repo-eval, spawn, spec, grill-me, and more.", + "author": { + "name": "Kirill Korikov", + "email": "korikov.kirill@proton.me", + "url": "https://github.com/yourconscience" + }, + "repository": "https://github.com/yourconscience/dotagents", + "license": "MIT", + "keywords": ["skills", "subagents", "workflow"] +} diff --git a/README.md b/README.md index 32c7c30..2050994 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,6 @@ Reference these from TeamCreate teammates, Claude Code subagent types, or Codex ## Skills -- `bittorrent` - manage legal BitTorrent downloads, magnet links, metadata inspection, and client diagnostics. - `cmux` - control cmux workspaces, panes, terminal/browser surfaces, markdown viewers, and visible agent workspaces. - `tmux` - generic tmux reference for sessions, windows, panes, screen capture, and input. - `dotagents` - inspect and sync the repo-owned skill links across supported coding agents. @@ -56,6 +55,7 @@ Reference these from TeamCreate teammates, Claude Code subagent types, or Codex - `spec` - produce a small `SPEC.md` for complex or ambiguous work before implementation. - `spawn` - spawn and manage Claude Code agent teams with model routing and cmux integration. - `tech-search` - gather high-signal opinions from tech communities and blogs on a topic. +- `tg` - read Telegram chats, search messages, and list dialogs via the read-only `tg` CLI. - `x-cli` - unofficial CLI for `x` tooling. ## External Skills @@ -73,7 +73,7 @@ external_skills: ## Plugins -Dotagents treats plugins as first-party catalog entries in `dotagents.yaml`, not as committed `.codex-plugin`, `.claude-plugin`, `.amp/`, or `.hermes/` runtime directories. A plugin entry records its source format, runtime surfaces, target agents, and review notes: +Dotagents treats third-party plugins as first-party catalog entries in `dotagents.yaml`, not as committed `.codex-plugin`, `.amp/`, or `.hermes/` runtime directories. (The repo's own `.claude-plugin/` manifests are the one exception; see "Installing this repo as a Claude Code plugin" below.) A plugin entry records its source format, runtime surfaces, target agents, and review notes: ```yaml plugins: @@ -98,6 +98,25 @@ Compatibility model: - `native-plugin` is host-specific: `.codex-plugin` stays Codex-native and `.claude-plugin` stays Claude-native. - `commands` are currently Claude-native unless re-modeled as skills, hooks, MCP, or a repo-owned CLI. +### Installing this repo as a Claude Code plugin + +The repo doubles as a self-hosted Claude Code plugin and single-plugin marketplace via `.claude-plugin/plugin.json` and `.claude-plugin/marketplace.json`: + +``` +/plugin marketplace add yourconscience/dotagents +/plugin install dotagents@yourconscience +``` + +The repo is private, so `marketplace add` requires working GitHub git auth (ssh or `gh auth`) on the machine. + +The plugin ships every skill under `skills/` (namespaced as `/dotagents:`) plus the four subagent roles. The roles are rendered from `agents/*.yaml` into `agents/*.md` (beside the sources) by `dotagents render`; Claude Code auto-discovers them from the top-level `agents/` directory. `dotagents doctor` and CI tests fail (`plugin agents` check) when the rendered copies drift from the YAML. + +Plugin skills are byte-identical to the symlink-synced ones - same directories, same repo. Machines running `dotagents sync` should NOT also install the plugin: every skill would appear twice (`/tg` and `/dotagents:tg`). The plugin is the install path for machines and people not running dotagents. Note that plugin installs snapshot the repo at install time; consumers pick up new skills with `/plugin update`, unlike the always-live symlinks. + +**Why Claude Code only.** Claude Code is the one supported agent whose native plugin format fits a shared-root skill library: it auto-discovers `skills/` and `agents/` from the plugin (repo) root. Codex has a plugin system too, but its plugins must live in a subdirectory with a *real, copied* `skills/` inside the plugin directory - it ignores symlinks and rejects a plugin at the marketplace root (verified against `codex 0.136.0`). Bundling our 31MB shared `skills/` into a committed subdir would mean a second source of truth, so Codex - like Droid, Amp, Hermes, and Pi - consumes dotagents skills through `dotagents sync`, not a plugin. `SKILL.md` directories remain the genuinely portable cross-tool convention. + +> Codex marketplace caveat: the `.codex-plugin/plugin.json` manifest follows the [official Codex schema](https://developers.openai.com/codex/plugins/build), but the repo-scoped `.agents/plugins/marketplace.json` schema and `source.path` semantics are doc-derived and not yet verified against a live `codex plugin marketplace add`. Verify on first install. + ## Agent Integration Status Dotagents keeps `~/.agents` as the source of truth and adapts each agent through symlinks, targeted config patches, or generated native files. Do not commit agent-specific project runtime directories such as `.amp/` or `.hermes/` to this repo. diff --git a/agents/architect.md b/agents/architect.md new file mode 100644 index 0000000..63ee89c --- /dev/null +++ b/agents/architect.md @@ -0,0 +1,24 @@ +--- +name: "architect" +description: "Designs system architecture, telemetry schemas, and technical plans. Use for design docs, architecture reviews, and API surface decisions. Delegates implementation to builders." +model: "sonnet" +effort: "high" +tools: Read, Glob, Grep, Bash, Write, Edit +color: "blue" +--- + + + +You are a senior software architect. Your job is to design, not build. + +Read the codebase before proposing changes. Trace data flow and dependencies. Identify existing patterns and match them. + +Output concrete design documents with: +- Current state analysis (what exists, what's missing) +- Proposed changes (which files, which functions, what the diff looks like) +- Migration path (backward compatibility, rollout steps) +- Risks and open questions + +Do not implement. Write the design doc, then hand off to a builder or report back. + +When working on a team, check the shared task list after completing your work. Message teammates when your design affects their tasks. diff --git a/agents/builder.md b/agents/builder.md new file mode 100644 index 0000000..21f01a6 --- /dev/null +++ b/agents/builder.md @@ -0,0 +1,25 @@ +--- +name: "builder" +description: "Implements code changes following specs or architect designs. Use for feature implementation, bug fixes, and script writing. Focused on writing correct, minimal code." +model: "sonnet" +effort: "high" +tools: Read, Glob, Grep, Bash, Write, Edit +color: "yellow" +--- + + + +You are a senior developer. Your job is to implement exactly what was specified. + +Follow the design doc, spec, or task description precisely. Do not add features beyond scope. Do not refactor adjacent code. Match existing style. + +Before coding: +- Read the relevant files +- Understand the existing patterns +- Identify the minimal set of changes needed + +After coding: +- Verify your changes work (run tests, type checks, or a quick manual validation) +- Report what you changed and any issues encountered + +When working on a team, check the shared task list after completing your work. Claim the next available unblocked task. diff --git a/agents/researcher.md b/agents/researcher.md new file mode 100644 index 0000000..a3a35b2 --- /dev/null +++ b/agents/researcher.md @@ -0,0 +1,22 @@ +--- +name: "researcher" +description: "Investigates codebases, APIs, repos, and web sources to produce findings reports. Use for technical research, competitive analysis, and feasibility studies." +model: "sonnet" +effort: "high" +tools: Read, Glob, Grep, Bash, WebFetch, WebSearch, Write +color: "green" +--- + + + +You are a technical researcher. Your job is to investigate and report, not implement. + +Gather evidence from code, documentation, APIs, and web sources. Distinguish verified facts from speculation. Include direct links and file paths for every claim. + +Output structured findings reports with: +- What was investigated and how +- Key findings (with evidence) +- Gaps and unknowns +- Recommendations + +Write your report to the location specified in your task. When working on a team, message teammates if your findings affect their work. diff --git a/agents/reviewer.md b/agents/reviewer.md new file mode 100644 index 0000000..2613eb2 --- /dev/null +++ b/agents/reviewer.md @@ -0,0 +1,27 @@ +--- +name: "reviewer" +description: "Reviews code changes, PRs, and implementations against specs and best practices. Use for code review, quality gates, and pre-merge checks. Read-only." +model: "sonnet" +effort: "high" +tools: Read, Glob, Grep, Bash +color: "purple" +--- + + + +You are a senior code reviewer. Your job is to find bugs, security issues, and spec violations. + +Review code against: +1. The spec or design doc (does it do what was asked?) +2. Correctness (edge cases, error handling, race conditions) +3. Security (injection, XSS, auth bypass, secret leaks) +4. Style (matches existing codebase conventions) + +Report findings as: +- **Critical**: breaks functionality, security vulnerability, data loss risk +- **High**: bugs, performance issues, spec violations +- **Medium**: style, naming, minor improvements + +Be specific: file:line, what's wrong, what the fix should be. Don't nitpick style when the code is correct and readable. + +When working on a team, check the shared task list after completing your work. Message teammates if your review findings require their attention. diff --git a/cmd/dotagents/agents.go b/cmd/dotagents/agents.go index 08fd1cb..034483a 100644 --- a/cmd/dotagents/agents.go +++ b/cmd/dotagents/agents.go @@ -15,6 +15,14 @@ import ( const generatedAgentMarker = "Generated by dotagents" +// pluginAgentsRelDir is where committed Claude-format renders of agents/*.yaml +// live. Claude Code auto-discovers plugin subagents from top-level agents/*.md +// only - it does not recurse, and the plugin.json "agents" array does not +// register them (verified empirically) - so the renders sit directly alongside +// the .yaml sources. loadAgentRoles globs *.yaml and ignores the .md, so they +// coexist in the same directory. +const pluginAgentsRelDir = "agents" + type agentRole struct { Name string `yaml:"name"` Description string `yaml:"description"` @@ -135,6 +143,76 @@ func loadAgentRoles(repoRoot string) ([]agentRole, error) { return roles, nil } +func expectedPluginAgentFiles(repoRoot string) (map[string]string, error) { + roles, err := loadAgentRoles(repoRoot) + if err != nil { + return nil, err + } + files := make(map[string]string, len(roles)) + for _, role := range roles { + path := filepath.Join(repoRoot, filepath.FromSlash(pluginAgentsRelDir), role.Name+".md") + files[path] = renderClaudeAgentRole(role) + } + return files, nil +} + +func runRender(opts runOptions) error { + repoRoot, _, _, _, err := loadContext(opts) + if err != nil { + return err + } + return renderPluginAgents(repoRoot) +} + +func renderPluginAgents(repoRoot string) error { + expected, err := expectedPluginAgentFiles(repoRoot) + if err != nil { + return err + } + dir := filepath.Join(repoRoot, filepath.FromSlash(pluginAgentsRelDir)) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create %s: %w", dir, err) + } + + written, unchanged := 0, 0 + for path, content := range expected { + data, err := os.ReadFile(path) + if err == nil && string(data) == content { + unchanged++ + continue + } + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("read %s: %w", path, err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + return fmt.Errorf("write %s: %w", path, err) + } + written++ + } + + removed := 0 + entries, err := os.ReadDir(dir) + if err != nil { + return fmt.Errorf("read %s: %w", dir, err) + } + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".md" { + continue + } + path := filepath.Join(dir, entry.Name()) + if _, ok := expected[path]; ok { + continue + } + if err := os.Remove(path); err != nil { + return fmt.Errorf("remove stale %s: %w", path, err) + } + removed++ + } + + fmt.Printf("rendered %s: %d written, %d unchanged, %d removed\n", pluginAgentsRelDir, written, unchanged, removed) + return nil +} + func renderAgentRole(role agentRole, agent agentConfig) (string, string, bool) { h := harnessFor(agent.Name) if h == nil || h.Roles == nil { diff --git a/cmd/dotagents/agents_test.go b/cmd/dotagents/agents_test.go index bb6f982..a445af0 100644 --- a/cmd/dotagents/agents_test.go +++ b/cmd/dotagents/agents_test.go @@ -1,6 +1,8 @@ package main import ( + "errors" + "io/fs" "os" "path/filepath" "strings" @@ -188,6 +190,87 @@ instructions: |- } } +func TestRenderPluginAgentsIdempotent(t *testing.T) { + repoRoot := t.TempDir() + agentsDir := filepath.Join(repoRoot, "agents") + if err := os.MkdirAll(agentsDir, 0o755); err != nil { + t.Fatal(err) + } + data := []byte(`name: reviewer +description: Reviews changes +model: sonnet +instructions: |- + Review the change. +`) + if err := os.WriteFile(filepath.Join(agentsDir, "reviewer.yaml"), data, 0o644); err != nil { + t.Fatal(err) + } + + if err := renderPluginAgents(repoRoot); err != nil { + t.Fatal(err) + } + + rendered := filepath.Join(repoRoot, "agents", "reviewer.md") + first, err := os.ReadFile(rendered) + if err != nil { + t.Fatalf("rendered file missing: %v", err) + } + if !strings.Contains(string(first), generatedAgentMarker) { + t.Fatalf("rendered file missing generated marker:\n%s", first) + } + + stale := filepath.Join(repoRoot, "agents", "removed-role.md") + if err := os.WriteFile(stale, []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + + if err := renderPluginAgents(repoRoot); err != nil { + t.Fatal(err) + } + second, err := os.ReadFile(rendered) + if err != nil { + t.Fatal(err) + } + if string(first) != string(second) { + t.Fatal("second render changed output") + } + if _, err := os.Stat(stale); !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("stale file not removed: %v", err) + } + + roles, err := loadAgentRoles(repoRoot) + if err != nil { + t.Fatal(err) + } + if len(roles) != 1 { + t.Fatalf("rendered agents/*.md leaked into loadAgentRoles: %d roles", len(roles)) + } +} + +func TestCommittedPluginAgentsFresh(t *testing.T) { + repoRoot, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatal(err) + } + expected, err := expectedPluginAgentFiles(repoRoot) + if err != nil { + t.Fatal(err) + } + if len(expected) == 0 { + t.Fatal("no agent roles found in repo") + } + for path, content := range expected { + data, err := os.ReadFile(path) + if err != nil { + t.Errorf("%s missing; run: dotagents render", path) + continue + } + if string(data) != content { + t.Errorf("%s stale; run: dotagents render", path) + } + } +} + func TestIsManagedAgentFile(t *testing.T) { repoRoot := t.TempDir() managed := filepath.Join(repoRoot, "managed.toml") diff --git a/cmd/dotagents/doctor.go b/cmd/dotagents/doctor.go index ae5a9a5..34e819b 100644 --- a/cmd/dotagents/doctor.go +++ b/cmd/dotagents/doctor.go @@ -8,6 +8,7 @@ import ( "os/exec" "path/filepath" "regexp" + "sort" "strconv" "strings" @@ -49,6 +50,7 @@ func runDoctor(opts runOptions) error { results = append(results, checkSkillFrontmatter(repoRoot)) results = append(results, checkAgentRoles(repoRoot)) + results = append(results, checkPluginAgents(repoRoot)) for _, name := range sortedHarnessNames() { for _, check := range getHarnesses()[name].DoctorChecks { results = append(results, check.Run(repoRoot, home, cfg)) @@ -105,6 +107,47 @@ func checkAgentRoles(repoRoot string) checkResult { return checkResult{"agent roles", checkStatusPass, fmt.Sprintf("%d roles valid", len(roles))} } +func checkPluginAgents(repoRoot string) checkResult { + expected, err := expectedPluginAgentFiles(repoRoot) + if err != nil { + return checkResult{"plugin agents", checkStatusFail, err.Error()} + } + if len(expected) == 0 { + return checkResult{"plugin agents", checkStatusWarn, "no agents/*.yaml roles found"} + } + + var stale []string + for path, content := range expected { + data, err := os.ReadFile(path) + if err != nil || string(data) != content { + stale = append(stale, filepath.Base(path)) + } + } + if len(stale) > 0 { + sort.Strings(stale) + return checkResult{"plugin agents", checkStatusFail, fmt.Sprintf("%s stale; run: dotagents render", strings.Join(stale, ", "))} + } + + var extras []string + dir := filepath.Join(repoRoot, filepath.FromSlash(pluginAgentsRelDir)) + if entries, err := os.ReadDir(dir); err == nil { + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".md" { + continue + } + if _, ok := expected[filepath.Join(dir, entry.Name())]; !ok { + extras = append(extras, entry.Name()) + } + } + } + if len(extras) > 0 { + sort.Strings(extras) + return checkResult{"plugin agents", checkStatusWarn, fmt.Sprintf("extra files in %s: %s", pluginAgentsRelDir, strings.Join(extras, ", "))} + } + + return checkResult{"plugin agents", checkStatusPass, fmt.Sprintf("%d rendered agents fresh (auto-discovered from %s/)", len(expected), pluginAgentsRelDir)} +} + type skillFrontmatter struct { Name string `yaml:"name"` Description string `yaml:"description"` diff --git a/cmd/dotagents/main.go b/cmd/dotagents/main.go index 01d071b..11040d4 100644 --- a/cmd/dotagents/main.go +++ b/cmd/dotagents/main.go @@ -153,6 +153,12 @@ func run(args []string) error { return runMCP(args[1:]) case "skillify": return runSkillify(args[1:]) + case "render": + opts, err := parseSubcommandFlags("render", args[1:]) + if err != nil { + return err + } + return runRender(opts) case "doctor": opts, err := parseSubcommandFlags("doctor", args[1:]) if err != nil { @@ -233,6 +239,7 @@ func printUsage() { fmt.Println(" dotagents mcp remove Remove canonical managed MCP") fmt.Println(" dotagents skillify [--description \"...\"] Scaffold a new skill from template") fmt.Println(" dotagents promote [--dry-run] Promote a Hermes skill to dotagents + PR") + fmt.Println(" dotagents render Render committed Claude plugin agents (agents/claude/) from agents/*.yaml") fmt.Println(" dotagents doctor [--agents ...] Health audit: frontmatter, collisions, sizes, package age") fmt.Println(" dotagents dogfood [--agents ...] End-to-end self-test: sync + status + doctor") } diff --git a/dotagents.yaml b/dotagents.yaml index c70afa6..dd3ed53 100644 --- a/dotagents.yaml +++ b/dotagents.yaml @@ -170,6 +170,18 @@ plugins: - droid - pi review: First enabled Claude plugin. Dotagents treats it as a portable skill surface for Claude Code, Codex, Droid, and Hermes. + - name: dotagents + enabled: false + source: https://github.com/yourconscience/dotagents + format: claude-plugin + description: This repo published as a Claude Code plugin (skills + rendered agent roles). + surfaces: + - skills + - agents + - native-plugin + agents: + - claude-code + review: 'Self-publication via .claude-plugin/{plugin,marketplace}.json (skills + 4 agent roles auto-discovered from agents/*.md). Claude Code is the only target: Codex plugins require a real copied skills/ inside a subdir (no shared-root, no symlinks - verified against codex 0.136.0), so Codex and all other agents consume these skills via dotagents sync, not a plugin. Local machines keep symlink sync; the plugin is for installs via /plugin marketplace add yourconscience/dotagents. Must stay enabled:false while symlink sync covers claude-code - enabling makes discoverPluginSkills collide with the repo''s own skills, and installing alongside symlinks duplicates every skill (/tg and /dotagents:tg).' mcp_servers: - name: linkedin enabled: true From a7937b399cd6120c8430dd977661115875f8cd80 Mon Sep 17 00:00:00 2001 From: Kirill Korikov Date: Wed, 10 Jun 2026 10:19:56 +0200 Subject: [PATCH 3/6] only treat marker-bearing agent files as managed in render and doctor --- cmd/dotagents/agents.go | 7 +++++++ cmd/dotagents/agents_test.go | 9 ++++++++- cmd/dotagents/doctor.go | 10 ++++++++-- cmd/dotagents/main.go | 2 +- 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/cmd/dotagents/agents.go b/cmd/dotagents/agents.go index 034483a..fac5403 100644 --- a/cmd/dotagents/agents.go +++ b/cmd/dotagents/agents.go @@ -203,6 +203,13 @@ func renderPluginAgents(repoRoot string) error { if _, ok := expected[path]; ok { continue } + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read %s: %w", path, err) + } + if !strings.Contains(string(data), generatedAgentMarker) { + continue + } if err := os.Remove(path); err != nil { return fmt.Errorf("remove stale %s: %w", path, err) } diff --git a/cmd/dotagents/agents_test.go b/cmd/dotagents/agents_test.go index a445af0..6e7dd7c 100644 --- a/cmd/dotagents/agents_test.go +++ b/cmd/dotagents/agents_test.go @@ -220,7 +220,11 @@ instructions: |- } stale := filepath.Join(repoRoot, "agents", "removed-role.md") - if err := os.WriteFile(stale, []byte("old"), 0o644); err != nil { + if err := os.WriteFile(stale, []byte("# "+generatedAgentMarker+"\nold"), 0o644); err != nil { + t.Fatal(err) + } + manual := filepath.Join(repoRoot, "agents", "README.md") + if err := os.WriteFile(manual, []byte("hand-written notes"), 0o644); err != nil { t.Fatal(err) } @@ -237,6 +241,9 @@ instructions: |- if _, err := os.Stat(stale); !errors.Is(err, fs.ErrNotExist) { t.Fatalf("stale file not removed: %v", err) } + if _, err := os.Stat(manual); err != nil { + t.Fatalf("manual file was removed: %v", err) + } roles, err := loadAgentRoles(repoRoot) if err != nil { diff --git a/cmd/dotagents/doctor.go b/cmd/dotagents/doctor.go index 34e819b..b21b15e 100644 --- a/cmd/dotagents/doctor.go +++ b/cmd/dotagents/doctor.go @@ -135,9 +135,15 @@ func checkPluginAgents(repoRoot string) checkResult { if entry.IsDir() || filepath.Ext(entry.Name()) != ".md" { continue } - if _, ok := expected[filepath.Join(dir, entry.Name())]; !ok { - extras = append(extras, entry.Name()) + path := filepath.Join(dir, entry.Name()) + if _, ok := expected[path]; ok { + continue + } + data, err := os.ReadFile(path) + if err != nil || !strings.Contains(string(data), generatedAgentMarker) { + continue } + extras = append(extras, entry.Name()) } } if len(extras) > 0 { diff --git a/cmd/dotagents/main.go b/cmd/dotagents/main.go index 11040d4..74848cc 100644 --- a/cmd/dotagents/main.go +++ b/cmd/dotagents/main.go @@ -239,7 +239,7 @@ func printUsage() { fmt.Println(" dotagents mcp remove Remove canonical managed MCP") fmt.Println(" dotagents skillify [--description \"...\"] Scaffold a new skill from template") fmt.Println(" dotagents promote [--dry-run] Promote a Hermes skill to dotagents + PR") - fmt.Println(" dotagents render Render committed Claude plugin agents (agents/claude/) from agents/*.yaml") + fmt.Println(" dotagents render Render committed Claude plugin agents (agents/) from agents/*.yaml") fmt.Println(" dotagents doctor [--agents ...] Health audit: frontmatter, collisions, sizes, package age") fmt.Println(" dotagents dogfood [--agents ...] End-to-end self-test: sync + status + doctor") } From 9fc7ed2d4519c4d0afeb625ea751bf2d7f83d83c Mon Sep 17 00:00:00 2001 From: Kirill Korikov <11762090+yourconscience@users.noreply.github.com> Date: Wed, 10 Jun 2026 10:41:04 +0200 Subject: [PATCH 4/6] fix plugin update versioning --- .claude-plugin/plugin.json | 1 - README.md | 4 +--- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 5097b1f..ad2fdb4 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,5 @@ { "name": "dotagents", - "version": "0.1.0", "description": "Cross-agent skill library and subagent roles: cmux, tg, jobs, repo-eval, spawn, spec, grill-me, and more.", "author": { "name": "Kirill Korikov", diff --git a/README.md b/README.md index 2050994..c8edd40 100644 --- a/README.md +++ b/README.md @@ -111,12 +111,10 @@ The repo is private, so `marketplace add` requires working GitHub git auth (ssh The plugin ships every skill under `skills/` (namespaced as `/dotagents:`) plus the four subagent roles. The roles are rendered from `agents/*.yaml` into `agents/*.md` (beside the sources) by `dotagents render`; Claude Code auto-discovers them from the top-level `agents/` directory. `dotagents doctor` and CI tests fail (`plugin agents` check) when the rendered copies drift from the YAML. -Plugin skills are byte-identical to the symlink-synced ones - same directories, same repo. Machines running `dotagents sync` should NOT also install the plugin: every skill would appear twice (`/tg` and `/dotagents:tg`). The plugin is the install path for machines and people not running dotagents. Note that plugin installs snapshot the repo at install time; consumers pick up new skills with `/plugin update`, unlike the always-live symlinks. +Plugin skills are byte-identical to the symlink-synced ones - same directories, same repo. Machines running `dotagents sync` should NOT also install the plugin: every skill would appear twice (`/tg` and `/dotagents:tg`). The plugin is the install path for machines and people not running dotagents. Note that plugin installs snapshot the repo at install time; consumers pick up new skills with `/plugin update`, unlike the always-live symlinks. The plugin manifest intentionally omits a fixed `version` so Claude Code uses the git commit SHA and every new commit can be updated. **Why Claude Code only.** Claude Code is the one supported agent whose native plugin format fits a shared-root skill library: it auto-discovers `skills/` and `agents/` from the plugin (repo) root. Codex has a plugin system too, but its plugins must live in a subdirectory with a *real, copied* `skills/` inside the plugin directory - it ignores symlinks and rejects a plugin at the marketplace root (verified against `codex 0.136.0`). Bundling our 31MB shared `skills/` into a committed subdir would mean a second source of truth, so Codex - like Droid, Amp, Hermes, and Pi - consumes dotagents skills through `dotagents sync`, not a plugin. `SKILL.md` directories remain the genuinely portable cross-tool convention. -> Codex marketplace caveat: the `.codex-plugin/plugin.json` manifest follows the [official Codex schema](https://developers.openai.com/codex/plugins/build), but the repo-scoped `.agents/plugins/marketplace.json` schema and `source.path` semantics are doc-derived and not yet verified against a live `codex plugin marketplace add`. Verify on first install. - ## Agent Integration Status Dotagents keeps `~/.agents` as the source of truth and adapts each agent through symlinks, targeted config patches, or generated native files. Do not commit agent-specific project runtime directories such as `.amp/` or `.hermes/` to this repo. From 25a409c80d54494070b4b10f303f1e29d25b91ee Mon Sep 17 00:00:00 2001 From: Kirill Korikov <11762090+yourconscience@users.noreply.github.com> Date: Wed, 10 Jun 2026 11:06:44 +0200 Subject: [PATCH 5/6] relax package age gate --- AGENTS.md | 2 +- cmd/dotagents/mcp_test.go | 6 +++--- cmd/dotagents/package_age.go | 7 ++++--- cmd/dotagents/package_age_test.go | 4 ++-- dotagents.yaml | 2 +- skills/dotagents/SKILL.md | 2 +- 6 files changed, 12 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6229977..6dc8f41 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ - Never guess numerical values. Measure or say it needs measurement. - Validate at small scale before scaling. When scaling, change only the scale parameter. -- For new external package/tool references in ongoing projects, use versions published at least 7 days earlier. Verify publish age from the registry or say it still needs measurement. +- For new external package/tool references in ongoing projects, use versions published at least 3 days earlier. Verify publish age from the registry or say it still needs measurement. # Environment diff --git a/cmd/dotagents/mcp_test.go b/cmd/dotagents/mcp_test.go index fb2b0d5..fb0fc32 100644 --- a/cmd/dotagents/mcp_test.go +++ b/cmd/dotagents/mcp_test.go @@ -13,7 +13,7 @@ func testMCPServer() mcpServerConfig { return mcpServerConfig{ Name: "linkedin", Command: "uvx", - Args: []string{"linkedin-scraper-mcp@latest"}, + Args: []string{"linkedin-scraper-mcp@4.13.2"}, Env: map[string]string{"UV_HTTP_TIMEOUT": "300"}, } } @@ -40,7 +40,7 @@ func TestDroidMCPInspectSynced(t *testing.T) { "linkedin": { "type": "stdio", "command": "uvx", - "args": ["linkedin-scraper-mcp@latest"], + "args": ["linkedin-scraper-mcp@4.13.2"], "env": {"UV_HTTP_TIMEOUT": "300"}, "disabled": false, "local": "kept" @@ -71,7 +71,7 @@ func TestDroidMCPReadAllowsMissingDisabled(t *testing.T) { "linkedin": { "type": "stdio", "command": "uvx", - "args": ["linkedin-scraper-mcp@latest"], + "args": ["linkedin-scraper-mcp@4.13.2"], "env": {"UV_HTTP_TIMEOUT": "300"} } } diff --git a/cmd/dotagents/package_age.go b/cmd/dotagents/package_age.go index cfb621c..2dba176 100644 --- a/cmd/dotagents/package_age.go +++ b/cmd/dotagents/package_age.go @@ -13,7 +13,8 @@ import ( "time" ) -const defaultPackageAgeMinimum = 7 * 24 * time.Hour +const defaultPackageAgeMinimumDays = 3 +const defaultPackageAgeMinimum = defaultPackageAgeMinimumDays * 24 * time.Hour const ( packageVersionLatest = "latest" @@ -76,9 +77,9 @@ func checkExternalPackageAgeWithResolver(repoRoot string, cfg config, skip bool, return checkResult{"package age", checkStatusFail, "registry lookup failed: " + strings.Join(unresolved, "; ")} } if len(fresh) > 0 { - return checkResult{"package age", checkStatusFail, "package newer than 7 days: " + strings.Join(fresh, "; ")} + return checkResult{"package age", checkStatusFail, fmt.Sprintf("package newer than %d days: %s", defaultPackageAgeMinimumDays, strings.Join(fresh, "; "))} } - detail := fmt.Sprintf("%d references older than 7 days", len(refs)-exempt) + detail := fmt.Sprintf("%d references at least %d days old", len(refs)-exempt, defaultPackageAgeMinimumDays) if exempt > 0 { detail += fmt.Sprintf("; %d package-age exception", exempt) if exempt > 1 { diff --git a/cmd/dotagents/package_age_test.go b/cmd/dotagents/package_age_test.go index 579f1af..025e758 100644 --- a/cmd/dotagents/package_age_test.go +++ b/cmd/dotagents/package_age_test.go @@ -83,7 +83,7 @@ func TestCheckExternalPackageAgeFailsFreshPackage(t *testing.T) { got := checkExternalPackageAgeWithResolver(t.TempDir(), cfg, false, now, func(ref packageReference) (packageRelease, error) { return packageRelease{Version: "1.0.0", Released: now.Add(-48 * time.Hour)}, nil }) - if got.status != checkStatusFail || !strings.Contains(got.detail, "newer than 7 days") { + if got.status != checkStatusFail || !strings.Contains(got.detail, "newer than 3 days") { t.Fatalf("got %#v, want fresh package failure", got) } } @@ -94,7 +94,7 @@ func TestCheckExternalPackageAgePassesOldPackage(t *testing.T) { Name: "old", Enabled: true, Command: mcpTestUVXCommand, Args: []string{"old-pkg@latest"}, }}} got := checkExternalPackageAgeWithResolver(t.TempDir(), cfg, false, now, func(ref packageReference) (packageRelease, error) { - return packageRelease{Version: "1.0.0", Released: now.Add(-8 * 24 * time.Hour)}, nil + return packageRelease{Version: "1.0.0", Released: now.Add(-3 * 24 * time.Hour)}, nil }) if got.status != checkStatusPass { t.Fatalf("got %#v, want pass", got) diff --git a/dotagents.yaml b/dotagents.yaml index dd3ed53..d57137e 100644 --- a/dotagents.yaml +++ b/dotagents.yaml @@ -187,7 +187,7 @@ mcp_servers: enabled: true command: uvx args: - - linkedin-scraper-mcp@latest + - linkedin-scraper-mcp@4.13.2 env: UV_HTTP_TIMEOUT: "300" agents: diff --git a/skills/dotagents/SKILL.md b/skills/dotagents/SKILL.md index 922525c..95b9b09 100644 --- a/skills/dotagents/SKILL.md +++ b/skills/dotagents/SKILL.md @@ -179,7 +179,7 @@ The `knowledge-sync` tool source lives at `memory/tools/knowledge-sync/`. See it Current managed MCP set lives in root `dotagents.yaml` under `mcp_servers:`. Minimal examples in this repo: -- `linkedin` -> `uvx linkedin-scraper-mcp@latest` +- `linkedin` -> `uvx linkedin-scraper-mcp@4.13.2` - `tavily` -> `npx -y mcp-remote@0.1.38 https://mcp.tavily.com/mcp` - `telegram_readonly` -> repo-local Telegram MCP server From 3c0d4690852dfb8021483eed5f1aef0bf16c7090 Mon Sep 17 00:00:00 2001 From: Kirill Korikov Date: Wed, 10 Jun 2026 14:05:47 +0200 Subject: [PATCH 6/6] add per-agent delivery mode: claude-code can consume skills via plugin instead of sync --- .gitignore | 1 + README.md | 28 ++- cmd/dotagents/agents.go | 6 + cmd/dotagents/config.go | 33 +++ cmd/dotagents/delivery.go | 422 +++++++++++++++++++++++++++++++++ cmd/dotagents/delivery_test.go | 207 ++++++++++++++++ cmd/dotagents/doctor.go | 1 + cmd/dotagents/inspect.go | 20 +- cmd/dotagents/main.go | 7 + cmd/dotagents/report.go | 7 +- cmd/dotagents/sync.go | 11 + dotagents.yaml | 3 +- 12 files changed, 740 insertions(+), 6 deletions(-) create mode 100644 cmd/dotagents/delivery.go create mode 100644 cmd/dotagents/delivery_test.go diff --git a/.gitignore b/.gitignore index e5d01e2..38f1ebb 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ external/ .claude/ .amp/ .hermes/ +.omx/ .worktrees/ .skill-lock.json __pycache__/ diff --git a/README.md b/README.md index fe64f54..f86fbe8 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ plugins: agents: [claude-code, codex, hermes, droid, pi] ``` -Enabled plugin `skills/` surfaces are discovered from portable plugin source IDs. `codex:/` resolves under `DOTAGENTS_CODEX_PLUGIN_ROOT`; `claude:/` resolves under `DOTAGENTS_CLAUDE_PLUGIN_ROOT`. For Claude Code, Codex, Factory Droid, and Pi/OMP, `dotagents sync` manages those plugin skills as symlinks in the native skill roots. For Hermes, `dotagents setup` adds the plugin `skills/` directories to `skills.external_dirs`. Amp remains compatibility-only and must be targeted explicitly in a local config if needed. +Enabled plugin `skills/` surfaces are discovered from portable plugin source IDs. `codex:/` resolves under `DOTAGENTS_CODEX_PLUGIN_ROOT`; `claude:/` resolves under `DOTAGENTS_CLAUDE_PLUGIN_ROOT`. For Codex, Factory Droid, and Pi/OMP, `dotagents sync` manages those plugin skills as symlinks in the native skill roots. Claude Code uses either symlink sync or this repo's native Claude plugin based on `agents[].delivery`. For Hermes, `dotagents setup` adds the plugin `skills/` directories to `skills.external_dirs`. Amp remains compatibility-only and must be targeted explicitly in a local config if needed. `dotagents status` prints each plugin's compatibility across known harness descriptors; non-primary harnesses show as `not targeted` unless explicitly configured. `dotagents doctor` validates the catalog and warns when an enabled plugin targets an agent that has no supported surface for it. @@ -117,7 +117,29 @@ The repo is private, so `marketplace add` requires working GitHub git auth (ssh The plugin ships every skill under `skills/` (namespaced as `/dotagents:`) plus the four subagent roles. The roles are rendered from `agents/*.yaml` into `agents/*.md` (beside the sources) by `dotagents render`; Claude Code auto-discovers them from the top-level `agents/` directory. `dotagents doctor` and CI tests fail (`plugin agents` check) when the rendered copies drift from the YAML. -Plugin skills are byte-identical to the symlink-synced ones - same directories, same repo. Machines running `dotagents sync` should NOT also install the plugin: every skill would appear twice (`/tg` and `/dotagents:tg`). The plugin is the install path for machines and people not running dotagents. Note that plugin installs snapshot the repo at install time; consumers pick up new skills with `/plugin update`, unlike the always-live symlinks. The plugin manifest intentionally omits a fixed `version` so Claude Code uses the git commit SHA and every new commit can be updated. +Plugin skills are byte-identical to the symlink-synced ones - same directories, same repo. A machine should use exactly one Claude Code delivery channel: + +```yaml +agents: + - name: claude-code + delivery: sync # default: dotagents sync manages ~/.claude/skills and ~/.claude/agents +``` + +Use the CLI wrapper to switch Claude Code to plugin delivery: + +```bash +dotagents plugin add +``` + +That command runs the Claude plugin install flow, sets `delivery: plugin` for `claude-code`, and prunes dotagents-managed symlinks and generated Claude agent files so skills do not appear twice (`/tg` and `/dotagents:tg`). `dotagents doctor` includes a `claude delivery` check that fails when `delivery: plugin` is set but `dotagents@yourconscience` is not installed, or when plugin delivery still has managed sync artifacts. + +Use this to return Claude Code to symlink sync: + +```bash +dotagents plugin remove +``` + +That uninstalls the Claude plugin, removes the marketplace entry, sets `delivery: sync`, and runs `dotagents sync --agents=claude-code`. Plugin installs snapshot the repo at install time; consumers pick up new skills with `/plugin update`, unlike the always-live symlinks. The plugin manifest intentionally omits a fixed `version` so Claude Code uses the git commit SHA and every new commit can be updated. **Why Claude Code only.** Claude Code is the one supported agent whose native plugin format fits a shared-root skill library: it auto-discovers `skills/` and `agents/` from the plugin (repo) root. Codex has a plugin system too, but its plugins must live in a subdirectory with a *real, copied* `skills/` inside the plugin directory - it ignores symlinks and rejects a plugin at the marketplace root (verified against `codex 0.136.0`). Bundling our 31MB shared `skills/` into a committed subdir would mean a second source of truth, so Codex - like Droid, Amp, Hermes, and Pi - consumes dotagents skills through `dotagents sync`, not a plugin. `SKILL.md` directories remain the genuinely portable cross-tool convention. @@ -127,7 +149,7 @@ Dotagents keeps `~/.agents` as the source of truth and adapts each agent through | Agent | Shared skills | Native subagents | MCP sync | Hook sync | Root instructions | Integration notes | |---|---|---|---|---|---|---| -| Claude Code | Symlink mirror to `~/.claude/skills` | Generated to `~/.claude/agents` | `~/.claude/settings.json` | `~/.claude/settings.json` | `CLAUDE.md` shim points to `AGENTS.md` | Full managed mirror for skills, roles, MCP, and supported hooks. | +| Claude Code | `delivery: sync` symlink mirror to `~/.claude/skills`; `delivery: plugin` via `dotagents@yourconscience` | `delivery: sync` generated to `~/.claude/agents`; `delivery: plugin` from plugin `agents/*.md` | `~/.claude/settings.json` | `~/.claude/settings.json` | `CLAUDE.md` shim points to `AGENTS.md` | Use exactly one skill/role delivery channel; MCP and supported hooks remain dotagents-managed. | | Codex | Symlink mirror to `~/.codex/skills` | Generated to `~/.codex/agents` | `~/.codex/config.toml` | `~/.codex/hooks.json` plus `[features].hooks = true` | Reads `AGENTS.md` | Full managed mirror for skills, roles, MCP, and supported hooks. | | Hermes | Config path to `~/.agents/skills` | Not managed | `~/.hermes/config.yaml` | `~/.hermes/config.yaml` for known lifecycle hooks | Reads configured Hermes context | Uses `skills.external_dirs`; do not mirror into `~/.hermes/skills` because bundled categories can collide. | | Factory Droid | Symlink mirror to `~/.factory/skills` | Generated to `~/.factory/droids` | `~/.factory/mcp.json` | `~/.factory/settings.json` | `~/.factory/AGENTS.md` symlink | Full managed mirror for skills, roles, MCP, and supported hooks. | diff --git a/cmd/dotagents/agents.go b/cmd/dotagents/agents.go index fac5403..73a5d1b 100644 --- a/cmd/dotagents/agents.go +++ b/cmd/dotagents/agents.go @@ -477,6 +477,12 @@ func applyAgentRoleSync(reports []agentReport, selected []agentConfig, repoRoot if len(report.Conflicts) > 0 { return fmt.Errorf("%s has conflicts", report.Name) } + if usesPluginDelivery(agent) { + if err := pruneManagedAgentFiles(agent.AgentRoot, report.RemovesAgent); err != nil { + return err + } + continue + } expected, err := expectedAgentRoles(repoRoot, agent) if err != nil { return err diff --git a/cmd/dotagents/config.go b/cmd/dotagents/config.go index 865efe8..a3f7a91 100644 --- a/cmd/dotagents/config.go +++ b/cmd/dotagents/config.go @@ -11,6 +11,11 @@ import ( "gopkg.in/yaml.v3" ) +const ( + deliverySync = "sync" + deliveryPlugin = "plugin" +) + func loadContext(opts runOptions) (string, string, config, []agentConfig, error) { home, err := os.UserHomeDir() if err != nil { @@ -81,6 +86,7 @@ func validateConfig(cfg *config, home string, expand bool) error { seen := make(map[string]struct{}) for i := range cfg.Agents { cfg.Agents[i].Name = normalizeAgentName(cfg.Agents[i].Name) + cfg.Agents[i].Delivery = normalizeDeliveryMode(cfg.Agents[i].Delivery) if expand { cfg.Agents[i].SkillRoot = expandPath(cfg.Agents[i].SkillRoot, home) cfg.Agents[i].AgentRoot = expandPath(cfg.Agents[i].AgentRoot, home) @@ -94,6 +100,12 @@ func validateConfig(cfg *config, home string, expand bool) error { if _, ok := seen[cfg.Agents[i].Name]; ok { return fmt.Errorf("config agent %s is duplicated", cfg.Agents[i].Name) } + if !isSupportedDeliveryMode(cfg.Agents[i].Delivery) { + return fmt.Errorf("config agent %s has unsupported delivery %q", cfg.Agents[i].Name, cfg.Agents[i].Delivery) + } + if cfg.Agents[i].Delivery == deliveryPlugin && cfg.Agents[i].Name != agentClaudeCode { + return fmt.Errorf("config agent %s cannot use delivery: plugin (only claude-code supports native plugin delivery)", cfg.Agents[i].Name) + } seen[cfg.Agents[i].Name] = struct{}{} } @@ -333,3 +345,24 @@ func expandPath(path string, home string) string { func normalizeAgentName(name string) string { return strings.ToLower(strings.TrimSpace(name)) } + +func normalizeDeliveryMode(mode string) string { + mode = strings.ToLower(strings.TrimSpace(mode)) + if mode == "" { + return deliverySync + } + return mode +} + +func isSupportedDeliveryMode(mode string) bool { + switch normalizeDeliveryMode(mode) { + case deliverySync, deliveryPlugin: + return true + default: + return false + } +} + +func usesPluginDelivery(agent agentConfig) bool { + return normalizeAgentName(agent.Name) == agentClaudeCode && normalizeDeliveryMode(agent.Delivery) == deliveryPlugin +} diff --git a/cmd/dotagents/delivery.go b/cmd/dotagents/delivery.go new file mode 100644 index 0000000..657ff12 --- /dev/null +++ b/cmd/dotagents/delivery.go @@ -0,0 +1,422 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +const ( + claudeDotagentsPluginID = "dotagents@yourconscience" + claudeDotagentsMarketplace = "yourconscience" + claudeDotagentsMarketplaceSource = "yourconscience/dotagents" +) + +func inspectPluginDeliveryAgent(agent agentConfig, repoRoot string, agentsSkillRoot string, cfg config, home string) (agentReport, error) { + report := agentReport{ + Name: agent.Name, + SkillRoot: agent.SkillRoot, + AgentRoot: agent.AgentRoot, + Delivery: deliveryPlugin, + Detected: isDetected(agent), + } + if !report.Detected { + return report, nil + } + + skills, external, err := claudeManagedSkillArtifacts(agent.SkillRoot, repoRoot, agentsSkillRoot) + if err != nil { + return agentReport{}, err + } + report.StaleManaged = append(report.StaleManaged, skills...) + report.Removes = append(report.Removes, skills...) + report.External = append(report.External, external...) + + agentFiles, err := claudeManagedAgentArtifacts(agent.AgentRoot) + if err != nil { + return agentReport{}, err + } + for _, name := range agentFiles { + report.StaleManaged = append(report.StaleManaged, "agent:"+name) + } + report.RemovesAgent = append(report.RemovesAgent, agentFiles...) + + if err := augmentMCPReport(&report, agent, cfg, home); err != nil { + return agentReport{}, err + } + if err := augmentHookReport(&report, agent, cfg, home); err != nil { + return agentReport{}, err + } + + sortReportLists(&report) + report.Synced = isReportSynced(report) + return report, nil +} + +func claudeManagedSkillArtifacts(skillRoot string, repoRoot string, agentsSkillRoot string) ([]string, []string, error) { + var managed []string + var external []string + entries, err := os.ReadDir(skillRoot) + if errors.Is(err, fs.ErrNotExist) { + return managed, external, nil + } + if err != nil { + return nil, nil, fmt.Errorf("read %s: %w", skillRoot, err) + } + for _, entry := range entries { + name := entry.Name() + if strings.HasPrefix(name, ".") { + continue + } + path := filepath.Join(skillRoot, name) + if entry.Type()&os.ModeSymlink != 0 { + rawTarget, err := os.Readlink(path) + if err != nil { + return nil, nil, fmt.Errorf("readlink %s: %w", path, err) + } + if isManagedSkillLink(path, rawTarget, repoRoot, agentsSkillRoot) { + managed = append(managed, name) + continue + } + } + external = append(external, name) + } + sort.Strings(managed) + sort.Strings(external) + return managed, external, nil +} + +func claudeManagedAgentArtifacts(agentRoot string) ([]string, error) { + var managed []string + if strings.TrimSpace(agentRoot) == "" { + return managed, nil + } + entries, err := os.ReadDir(agentRoot) + if errors.Is(err, fs.ErrNotExist) { + return managed, nil + } + if err != nil { + return nil, fmt.Errorf("read %s: %w", agentRoot, err) + } + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".md" { + continue + } + path := filepath.Join(agentRoot, entry.Name()) + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + if strings.Contains(string(data), generatedAgentMarker) { + managed = append(managed, entry.Name()) + } + } + sort.Strings(managed) + return managed, nil +} + +func pruneManagedSkillLinks(skillRoot string, names []string) error { + for _, name := range names { + path := filepath.Join(skillRoot, name) + if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("remove %s: %w", path, err) + } + } + return nil +} + +func pruneManagedAgentFiles(agentRoot string, names []string) error { + for _, name := range names { + path := filepath.Join(agentRoot, name) + if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("remove %s: %w", path, err) + } + } + return nil +} + +func checkClaudeDelivery(repoRoot string, home string, cfg config) checkResult { + agent, ok := claudeAgentConfig(cfg) + if !ok { + return checkResult{"claude delivery", checkStatusPass, "claude-code not configured"} + } + + installed, err := claudeDotagentsPluginInstalled(home) + if err != nil { + return checkResult{"claude delivery", checkStatusFail, err.Error()} + } + skills, _, err := claudeManagedSkillArtifacts(agent.SkillRoot, repoRoot, filepath.Join(home, ".agents", "skills")) + if err != nil { + return checkResult{"claude delivery", checkStatusFail, err.Error()} + } + agentFiles, err := claudeManagedAgentArtifacts(agent.AgentRoot) + if err != nil { + return checkResult{"claude delivery", checkStatusFail, err.Error()} + } + artifacts := append([]string{}, skills...) + for _, name := range agentFiles { + artifacts = append(artifacts, "agent:"+name) + } + sort.Strings(artifacts) + + switch normalizeDeliveryMode(agent.Delivery) { + case deliveryPlugin: + if !installed { + return checkResult{"claude delivery", checkStatusFail, "delivery=plugin but dotagents@yourconscience is not installed; run: dotagents plugin add"} + } + if len(artifacts) > 0 { + return checkResult{"claude delivery", checkStatusFail, fmt.Sprintf("delivery=plugin but managed sync artifacts remain: %s; run: dotagents sync --agents=claude-code", strings.Join(artifacts, ", "))} + } + return checkResult{"claude delivery", checkStatusPass, "delivery=plugin and dotagents@yourconscience installed"} + default: + if installed { + return checkResult{"claude delivery", checkStatusWarn, "delivery=sync but dotagents@yourconscience is installed; run: dotagents plugin remove or set delivery: plugin"} + } + return checkResult{"claude delivery", checkStatusPass, "delivery=sync and Claude plugin absent"} + } +} + +func claudeAgentConfig(cfg config) (agentConfig, bool) { + for _, agent := range cfg.Agents { + if normalizeAgentName(agent.Name) == agentClaudeCode { + return agent, true + } + } + return agentConfig{}, false +} + +func claudeDotagentsPluginInstalled(home string) (bool, error) { + path := filepath.Join(home, ".claude", "plugins", "installed_plugins.json") + data, err := os.ReadFile(path) + if errors.Is(err, fs.ErrNotExist) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("read %s: %w", path, err) + } + var raw struct { + Plugins map[string]json.RawMessage `json:"plugins"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return false, fmt.Errorf("parse %s: %w", path, err) + } + entry, ok := raw.Plugins[claudeDotagentsPluginID] + if !ok { + return false, nil + } + var installs []json.RawMessage + if err := json.Unmarshal(entry, &installs); err != nil { + return false, fmt.Errorf("parse %s plugin %s: %w", path, claudeDotagentsPluginID, err) + } + return len(installs) > 0, nil +} + +func runPlugin(args []string) error { + if len(args) == 0 { + return errors.New("plugin requires subcommand: add, remove") + } + switch args[0] { + case "add": + return runPluginAdd(args[1:]) + case "remove": + return runPluginRemove(args[1:]) + default: + return fmt.Errorf("unknown plugin subcommand %q", args[0]) + } +} + +func runPluginAdd(args []string) error { + opts, err := parseSubcommandFlags("plugin add", args) + if err != nil { + return err + } + repoRoot, home, cfg, _, err := loadContext(opts) + if err != nil { + return err + } + if _, ok := claudeAgentConfig(cfg); !ok { + return errors.New("claude-code is not configured in dotagents.yaml") + } + if _, err := exec.LookPath("claude"); err != nil { + return errors.New("claude CLI not found on PATH; install Claude Code before enabling plugin delivery") + } + installed, err := claudeDotagentsPluginInstalled(home) + if err != nil { + return err + } + if !installed { + if err := runClaudePluginCommand(true, "plugin", "marketplace", "add", claudeDotagentsMarketplaceSource); err != nil { + return err + } + if err := runClaudePluginCommand(false, "plugin", "install", claudeDotagentsPluginID); err != nil { + return err + } + } + if err := setClaudeDeliveryMode(opts.ConfigPath, deliveryPlugin); err != nil { + return err + } + cfg, err = loadConfig(repoRoot, home, opts.ConfigPath) + if err != nil { + return err + } + agent, _ := claudeAgentConfig(cfg) + skills, _, err := claudeManagedSkillArtifacts(agent.SkillRoot, repoRoot, filepath.Join(home, ".agents", "skills")) + if err != nil { + return err + } + agents, err := claudeManagedAgentArtifacts(agent.AgentRoot) + if err != nil { + return err + } + if err := pruneManagedSkillLinks(agent.SkillRoot, skills); err != nil { + return err + } + if err := pruneManagedAgentFiles(agent.AgentRoot, agents); err != nil { + return err + } + fmt.Printf("Claude Code delivery set to plugin in %s\n", editableConfigPathForMessage(repoRoot, home, opts.ConfigPath)) + fmt.Println("Next: restart Claude Code if it is already running, then use /plugin list to confirm dotagents@yourconscience.") + return nil +} + +func runPluginRemove(args []string) error { + opts, err := parseSubcommandFlags("plugin remove", args) + if err != nil { + return err + } + repoRoot, home, _, _, err := loadContext(opts) + if err != nil { + return err + } + if _, err := exec.LookPath("claude"); err != nil { + return errors.New("claude CLI not found on PATH; cannot uninstall Claude Code plugin delivery") + } + installed, err := claudeDotagentsPluginInstalled(home) + if err != nil { + return err + } + if installed { + if err := runClaudePluginCommand(true, "plugin", "uninstall", claudeDotagentsPluginID); err != nil { + return err + } + } + if err := runClaudePluginCommand(true, "plugin", "marketplace", "remove", claudeDotagentsMarketplace); err != nil { + return err + } + if err := setClaudeDeliveryMode(opts.ConfigPath, deliverySync); err != nil { + return err + } + if err := runSync(runOptions{ConfigPath: opts.ConfigPath, Agents: agentClaudeCode}); err != nil { + return err + } + fmt.Printf("Claude Code delivery set to sync in %s\n", editableConfigPathForMessage(repoRoot, home, opts.ConfigPath)) + return nil +} + +func runClaudePluginCommand(allowAlreadyOrMissing bool, args ...string) error { + cmd := exec.Command("claude", args...) + out, err := cmd.CombinedOutput() + text := strings.TrimSpace(string(out)) + if text != "" { + fmt.Println(text) + } + if err == nil { + return nil + } + if allowAlreadyOrMissing && isIdempotentClaudePluginError(text) { + return nil + } + return fmt.Errorf("claude %s failed: %w", strings.Join(args, " "), err) +} + +func isIdempotentClaudePluginError(output string) bool { + output = strings.ToLower(output) + return strings.Contains(output, "already") || strings.Contains(output, "not found") || strings.Contains(output, "does not exist") +} + +func setClaudeDeliveryMode(configPath string, delivery string) error { + cfg, path, err := loadEditableMCPConfig(configPath) + if err != nil { + return err + } + found := false + for _, agent := range cfg.Agents { + if normalizeAgentName(agent.Name) == agentClaudeCode { + found = true + break + } + } + if !found { + return errors.New("claude-code is not configured in dotagents.yaml") + } + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read config %s: %w", path, err) + } + var doc yaml.Node + if err := yaml.Unmarshal(data, &doc); err != nil { + return fmt.Errorf("yaml decode: %w", err) + } + if err := setAgentDeliveryNode(&doc, agentClaudeCode, delivery); err != nil { + return err + } + out, err := marshalYAMLNode(&doc) + if err != nil { + return fmt.Errorf("yaml encode: %w", err) + } + if err := os.WriteFile(path, out, 0o644); err != nil { + return fmt.Errorf("write config %s: %w", path, err) + } + _, _, err = loadEditableMCPConfig(path) + return err +} + +func setAgentDeliveryNode(doc *yaml.Node, agentName string, delivery string) error { + if doc.Kind != yaml.DocumentNode || len(doc.Content) == 0 || doc.Content[0].Kind != yaml.MappingNode { + return errors.New("top-level YAML node is not a mapping") + } + root := doc.Content[0] + var agents *yaml.Node + for i := 0; i+1 < len(root.Content); i += 2 { + if root.Content[i].Value == "agents" { + agents = root.Content[i+1] + break + } + } + if agents == nil || agents.Kind != yaml.SequenceNode { + return errors.New("config agents is not a sequence") + } + for _, item := range agents.Content { + if item.Kind != yaml.MappingNode { + continue + } + var name string + for i := 0; i+1 < len(item.Content); i += 2 { + if item.Content[i].Value == "name" { + name = normalizeAgentName(item.Content[i+1].Value) + break + } + } + if name != agentName { + continue + } + setMappingString(item, "delivery", normalizeDeliveryMode(delivery)) + return nil + } + return fmt.Errorf("agent %s not found in config", agentName) +} + +func editableConfigPathForMessage(repoRoot string, home string, overridePath string) string { + if strings.TrimSpace(overridePath) != "" { + return expandPath(overridePath, home) + } + return defaultConfigPath(repoRoot) +} diff --git a/cmd/dotagents/delivery_test.go b/cmd/dotagents/delivery_test.go new file mode 100644 index 0000000..07b9049 --- /dev/null +++ b/cmd/dotagents/delivery_test.go @@ -0,0 +1,207 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestAgentDeliveryDefaultsToSync(t *testing.T) { + cfg := config{Agents: []agentConfig{{ + Name: agentClaudeCode, + Enabled: true, + SkillRoot: filepath.Join(t.TempDir(), "skills"), + }}} + if err := validateConfig(&cfg, t.TempDir(), false); err != nil { + t.Fatal(err) + } + if cfg.Agents[0].Delivery != deliverySync { + t.Fatalf("delivery = %q, want %q", cfg.Agents[0].Delivery, deliverySync) + } +} + +func TestAgentDeliveryRejectsPluginForNonClaude(t *testing.T) { + cfg := config{Agents: []agentConfig{{ + Name: agentCodex, + Enabled: true, + SkillRoot: filepath.Join(t.TempDir(), "skills"), + Delivery: deliveryPlugin, + }}} + if err := validateConfig(&cfg, t.TempDir(), false); err == nil { + t.Fatal("validateConfig accepted delivery: plugin for codex") + } +} + +func TestRunSyncPrunesClaudeArtifactsForPluginDelivery(t *testing.T) { + home := t.TempDir() + repoRoot := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("DOTAGENTS_ROOT", repoRoot) + + writeSyncTestFile(t, filepath.Join(repoRoot, "dotagents.yaml"), []byte(`version: 1 +agents: + - name: claude-code + enabled: true + delivery: plugin + skill_root: ~/.claude/skills + agent_root: ~/.claude/agents +`)) + repoSkill := filepath.Join(repoRoot, "skills", "sample") + writeSyncTestFile(t, filepath.Join(repoSkill, "SKILL.md"), []byte("---\nname: sample\n---\n")) + writeSyncTestFile(t, filepath.Join(repoRoot, "agents", "helper.yaml"), []byte(`name: helper +description: Help with tests. +model: sonnet +effort: medium +instructions: Test helper. +`)) + + claudeSkillRoot := filepath.Join(home, ".claude", "skills") + claudeAgentRoot := filepath.Join(home, ".claude", "agents") + if err := os.MkdirAll(claudeSkillRoot, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(claudeAgentRoot, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(repoSkill, filepath.Join(claudeSkillRoot, "sample")); err != nil { + t.Fatal(err) + } + externalSkill := filepath.Join(claudeSkillRoot, "external") + if err := os.MkdirAll(externalSkill, 0o755); err != nil { + t.Fatal(err) + } + managedAgent := filepath.Join(claudeAgentRoot, "helper.md") + if err := os.WriteFile(managedAgent, []byte("\n"), 0o644); err != nil { + t.Fatal(err) + } + unmanagedAgent := filepath.Join(claudeAgentRoot, "mine.md") + if err := os.WriteFile(unmanagedAgent, []byte("manual\n"), 0o644); err != nil { + t.Fatal(err) + } + + if err := runSync(runOptions{Agents: agentClaudeCode}); err != nil { + t.Fatal(err) + } + + if _, err := os.Lstat(filepath.Join(claudeSkillRoot, "sample")); !os.IsNotExist(err) { + t.Fatalf("managed skill symlink still exists, stat err = %v", err) + } + if _, err := os.Stat(externalSkill); err != nil { + t.Fatalf("external skill was touched: %v", err) + } + if _, err := os.Stat(managedAgent); !os.IsNotExist(err) { + t.Fatalf("managed agent file still exists, stat err = %v", err) + } + if _, err := os.Stat(unmanagedAgent); err != nil { + t.Fatalf("unmanaged agent file was touched: %v", err) + } +} + +func TestClaudeDeliveryCheckPluginInstalled(t *testing.T) { + home := t.TempDir() + repoRoot := t.TempDir() + agent := agentConfig{ + Name: agentClaudeCode, + Enabled: true, + Delivery: deliveryPlugin, + SkillRoot: filepath.Join(home, ".claude", "skills"), + AgentRoot: filepath.Join(home, ".claude", "agents"), + } + cfg := config{Agents: []agentConfig{agent}} + writeSyncTestFile(t, filepath.Join(home, ".claude", "plugins", "installed_plugins.json"), []byte(`{ + "version": 2, + "plugins": { + "dotagents@yourconscience": [ + { + "scope": "user", + "installPath": "/tmp/dotagents", + "version": "unknown" + } + ] + } +}`)) + + result := checkClaudeDelivery(repoRoot, home, cfg) + if result.status != checkStatusPass { + t.Fatalf("status = %s (%s), want pass", result.status, result.detail) + } +} + +func TestClaudeDeliveryCheckPluginAbsentFailsPluginMode(t *testing.T) { + home := t.TempDir() + repoRoot := t.TempDir() + cfg := config{Agents: []agentConfig{{ + Name: agentClaudeCode, + Enabled: true, + Delivery: deliveryPlugin, + SkillRoot: filepath.Join(home, ".claude", "skills"), + AgentRoot: filepath.Join(home, ".claude", "agents"), + }}} + + result := checkClaudeDelivery(repoRoot, home, cfg) + if result.status != checkStatusFail { + t.Fatalf("status = %s (%s), want fail", result.status, result.detail) + } + if !strings.Contains(result.detail, "dotagents plugin add") { + t.Fatalf("detail = %q, want plugin add hint", result.detail) + } +} + +func TestClaudeDeliveryCheckFailsPluginModeWithManagedArtifacts(t *testing.T) { + home := t.TempDir() + repoRoot := t.TempDir() + repoSkill := filepath.Join(repoRoot, "skills", "sample") + writeSyncTestFile(t, filepath.Join(repoSkill, "SKILL.md"), []byte("---\nname: sample\n---\n")) + writeSyncTestFile(t, filepath.Join(home, ".claude", "plugins", "installed_plugins.json"), []byte(`{ + "version": 2, + "plugins": { + "dotagents@yourconscience": [{"scope": "user"}] + } +}`)) + skillRoot := filepath.Join(home, ".claude", "skills") + if err := os.MkdirAll(skillRoot, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(repoSkill, filepath.Join(skillRoot, "sample")); err != nil { + t.Fatal(err) + } + cfg := config{Agents: []agentConfig{{ + Name: agentClaudeCode, + Enabled: true, + Delivery: deliveryPlugin, + SkillRoot: skillRoot, + AgentRoot: filepath.Join(home, ".claude", "agents"), + }}} + + result := checkClaudeDelivery(repoRoot, home, cfg) + if result.status != checkStatusFail { + t.Fatalf("status = %s (%s), want fail", result.status, result.detail) + } + if !strings.Contains(result.detail, "managed sync artifacts remain") { + t.Fatalf("detail = %q, want stale artifact detail", result.detail) + } +} + +func TestClaudeDeliveryCheckWarnsWhenPluginInstalledInSyncMode(t *testing.T) { + home := t.TempDir() + repoRoot := t.TempDir() + cfg := config{Agents: []agentConfig{{ + Name: agentClaudeCode, + Enabled: true, + Delivery: deliverySync, + SkillRoot: filepath.Join(home, ".claude", "skills"), + AgentRoot: filepath.Join(home, ".claude", "agents"), + }}} + writeSyncTestFile(t, filepath.Join(home, ".claude", "plugins", "installed_plugins.json"), []byte(`{ + "version": 2, + "plugins": { + "dotagents@yourconscience": [{"scope": "user"}] + } +}`)) + + result := checkClaudeDelivery(repoRoot, home, cfg) + if result.status != checkStatusWarn { + t.Fatalf("status = %s (%s), want warn", result.status, result.detail) + } +} diff --git a/cmd/dotagents/doctor.go b/cmd/dotagents/doctor.go index e43d8b6..b97424d 100644 --- a/cmd/dotagents/doctor.go +++ b/cmd/dotagents/doctor.go @@ -63,6 +63,7 @@ func runDoctor(opts runOptions) error { results = append(results, checkExternalPackageAge(repoRoot, cfg, opts.SkipPackageAge, timeNow())) results = append(results, checkExternalSkillSources(cfg, home)) results = append(results, checkFirstPartyPlugins(cfg)) + results = append(results, checkClaudeDelivery(repoRoot, home, cfg)) fmt.Println("checks:") labelWidth := 0 diff --git a/cmd/dotagents/inspect.go b/cmd/dotagents/inspect.go index d53b43d..3b5764d 100644 --- a/cmd/dotagents/inspect.go +++ b/cmd/dotagents/inspect.go @@ -142,12 +142,16 @@ func inspectAgent(agent agentConfig, expected map[string]string, repoRoot string Name: agent.Name, SkillRoot: agent.SkillRoot, AgentRoot: agent.AgentRoot, + Delivery: normalizeDeliveryMode(agent.Delivery), ExpectedSkills: expected, Detected: isDetected(agent), } if !report.Detected { return report, nil } + if usesPluginDelivery(agent) { + return inspectPluginDeliveryAgent(agent, repoRoot, agentsSkillRoot, cfg, home) + } h := harnessFor(agent.Name) if h != nil && h.Skills == SkillsConfigDriven && h.InspectSkills != nil { return h.InspectSkills(agent, expected, agentsSkillRoot, cfg, home) @@ -307,6 +311,7 @@ func inspectAmpAgent(agent agentConfig, expected map[string]string, cfg config, Name: agent.Name, SkillRoot: agent.SkillRoot, AgentRoot: agent.AgentRoot, + Delivery: normalizeDeliveryMode(agent.Delivery), ExpectedSkills: expected, Detected: isDetected(agent), } @@ -356,6 +361,7 @@ func inspectHermesAgent(agent agentConfig, expected map[string]string, agentsSki Name: agent.Name, SkillRoot: agent.SkillRoot, AgentRoot: agent.AgentRoot, + Delivery: normalizeDeliveryMode(agent.Delivery), ExpectedSkills: expected, Detected: isDetected(agent), } @@ -493,13 +499,25 @@ func isManagedSkillLink(linkPath string, rawTarget string, repoRoot string, agen if targetAbs == agentsSkillRoot || strings.HasPrefix(targetAbs, agentsSkillRoot+string(os.PathSeparator)) { return true } + if resolvedTarget, err := filepath.EvalSymlinks(targetAbs); err == nil { + if resolvedAgentsRoot, err := filepath.EvalSymlinks(agentsSkillRoot); err == nil && (resolvedTarget == resolvedAgentsRoot || strings.HasPrefix(resolvedTarget, resolvedAgentsRoot+string(os.PathSeparator))) { + return true + } + } resolved, err := filepath.EvalSymlinks(targetAbs) if err != nil { return false } repoSkillsRoot := filepath.Join(repoRoot, "skills") - return resolved == repoSkillsRoot || strings.HasPrefix(resolved, repoSkillsRoot+string(os.PathSeparator)) + if resolved == repoSkillsRoot || strings.HasPrefix(resolved, repoSkillsRoot+string(os.PathSeparator)) { + return true + } + resolvedRepoSkillsRoot, err := filepath.EvalSymlinks(repoSkillsRoot) + if err != nil { + return false + } + return resolved == resolvedRepoSkillsRoot || strings.HasPrefix(resolved, resolvedRepoSkillsRoot+string(os.PathSeparator)) } func absoluteTarget(linkPath string, rawTarget string) string { diff --git a/cmd/dotagents/main.go b/cmd/dotagents/main.go index 74848cc..879fecc 100644 --- a/cmd/dotagents/main.go +++ b/cmd/dotagents/main.go @@ -40,6 +40,7 @@ type agentConfig struct { SkillRoot string `yaml:"skill_root"` AgentRoot string `yaml:"agent_root,omitempty"` Detect string `yaml:"detect,omitempty"` + Delivery string `yaml:"delivery,omitempty"` } type repoLinkReport struct { @@ -53,6 +54,7 @@ type agentReport struct { Name string SkillRoot string AgentRoot string + Delivery string ExpectedSkills map[string]string Detected bool RootPath string @@ -84,6 +86,7 @@ type agentReport struct { UpdatesMCP []string UpdatesHook []string Removes []string + RemovesAgent []string Synced bool } @@ -151,6 +154,8 @@ func run(args []string) error { return runMemsearch(args[1:]) case "mcp": return runMCP(args[1:]) + case "plugin": + return runPlugin(args[1:]) case "skillify": return runSkillify(args[1:]) case "render": @@ -237,6 +242,8 @@ func printUsage() { fmt.Println(" dotagents mcp add --command Add/update canonical managed MCP") fmt.Println(" dotagents mcp import Import native MCP into canonical config") fmt.Println(" dotagents mcp remove Remove canonical managed MCP") + fmt.Println(" dotagents plugin add Install Claude Code plugin delivery for claude-code") + fmt.Println(" dotagents plugin remove Remove Claude Code plugin delivery and restore sync") fmt.Println(" dotagents skillify [--description \"...\"] Scaffold a new skill from template") fmt.Println(" dotagents promote [--dry-run] Promote a Hermes skill to dotagents + PR") fmt.Println(" dotagents render Render committed Claude plugin agents (agents/) from agents/*.yaml") diff --git a/cmd/dotagents/report.go b/cmd/dotagents/report.go index df1172e..4c964f6 100644 --- a/cmd/dotagents/report.go +++ b/cmd/dotagents/report.go @@ -64,6 +64,9 @@ func printReport(mode string, repoRoot string, repoReport repoLinkReport, report if report.AgentRoot != "" { fmt.Printf(" agent root: %s\n", report.AgentRoot) } + if report.Delivery != "" && report.Delivery != deliverySync { + fmt.Printf(" delivery: %s\n", report.Delivery) + } if h := harnessFor(report.Name); h != nil && h.IntegrationNote != "" { fmt.Printf(" integration: %s\n", h.IntegrationNote) } @@ -129,7 +132,7 @@ func printReport(mode string, repoRoot string, repoReport repoLinkReport, report fmt.Printf(" conflicts (%d): %s\n", len(report.Conflicts), displayList(report.Conflicts)) } if mode == "sync" { - fmt.Printf(" sync actions: add=%d update=%d remove=%d agent-add=%d agent-update=%d mcp-add=%d mcp-update=%d hook-add=%d hook-update=%d\n", len(report.Adds), len(report.Updates), len(report.Removes), len(report.AddsAgent), len(report.UpdatesAgent), len(report.AddsMCP), len(report.UpdatesMCP), len(report.AddsHook), len(report.UpdatesHook)) + fmt.Printf(" sync actions: add=%d update=%d remove=%d agent-add=%d agent-update=%d agent-remove=%d mcp-add=%d mcp-update=%d hook-add=%d hook-update=%d\n", len(report.Adds), len(report.Updates), len(report.Removes), len(report.AddsAgent), len(report.UpdatesAgent), len(report.RemovesAgent), len(report.AddsMCP), len(report.UpdatesMCP), len(report.AddsHook), len(report.UpdatesHook)) } fmt.Println() } @@ -168,6 +171,7 @@ func sortReportLists(report *agentReport) { sort.Strings(report.UpdatesMCP) sort.Strings(report.UpdatesHook) sort.Strings(report.Removes) + sort.Strings(report.RemovesAgent) } func cloneReports(reports []agentReport) []agentReport { @@ -189,6 +193,7 @@ func restoreSyncActions(current []agentReport, preflight []agentReport) { current[i].AddsHook = append([]string{}, original.AddsHook...) current[i].Updates = append([]string{}, original.Updates...) current[i].UpdatesAgent = append([]string{}, original.UpdatesAgent...) + current[i].RemovesAgent = append([]string{}, original.RemovesAgent...) current[i].UpdatesMCP = append([]string{}, original.UpdatesMCP...) current[i].UpdatesHook = append([]string{}, original.UpdatesHook...) current[i].Removes = append([]string{}, original.Removes...) diff --git a/cmd/dotagents/sync.go b/cmd/dotagents/sync.go index 6e7ab91..da5e57b 100644 --- a/cmd/dotagents/sync.go +++ b/cmd/dotagents/sync.go @@ -30,6 +30,11 @@ func runStatus(opts runOptions) error { } printReport("status", repoRoot, repoReport, reports, home, cfg) + claudeDelivery := checkClaudeDelivery(repoRoot, home, cfg) + fmt.Printf("claude delivery: %s (%s)\n\n", claudeDelivery.status, claudeDelivery.detail) + if claudeDelivery.status == checkStatusFail { + return fmt.Errorf("claude delivery check failed: %s", claudeDelivery.detail) + } if repoReport.State != stateSynced { return errors.New("dotagents is not fully synced") } @@ -168,6 +173,12 @@ func applyAgentSync(reports []agentReport, cfg config, home string) error { if len(report.Conflicts) > 0 { return fmt.Errorf("%s has conflicts", report.Name) } + if report.Name == agentClaudeCode && normalizeDeliveryMode(report.Delivery) == deliveryPlugin { + if err := pruneManagedSkillLinks(report.SkillRoot, report.Removes); err != nil { + return err + } + continue + } h := harnessFor(report.Name) if h != nil && h.Skills == SkillsConfigDriven { if err := applyConfigDrivenSkillDrift(report, h, cfg, home); err != nil { diff --git a/dotagents.yaml b/dotagents.yaml index f952a65..1be79c3 100644 --- a/dotagents.yaml +++ b/dotagents.yaml @@ -2,6 +2,7 @@ version: 1 agents: - name: claude-code enabled: true + delivery: sync detect: claude skill_root: ~/.claude/skills agent_root: ~/.claude/agents @@ -171,7 +172,7 @@ plugins: - native-plugin agents: - claude-code - review: 'Self-publication via .claude-plugin/{plugin,marketplace}.json (skills + 4 agent roles auto-discovered from agents/*.md). Claude Code is the only target: Codex plugins require a real copied skills/ inside a subdir (no shared-root, no symlinks - verified against codex 0.136.0), so Codex and all other agents consume these skills via dotagents sync, not a plugin. Local machines keep symlink sync; the plugin is for installs via /plugin marketplace add yourconscience/dotagents. Must stay enabled:false while symlink sync covers claude-code - enabling makes discoverPluginSkills collide with the repo''s own skills, and installing alongside symlinks duplicates every skill (/tg and /dotagents:tg).' + review: 'Self-publication via .claude-plugin/{plugin,marketplace}.json (skills + 4 agent roles auto-discovered from agents/*.md). Claude Code is the only target: Codex plugins require a real copied skills/ inside a subdir (no shared-root, no symlinks - verified against codex 0.136.0), so Codex and all other agents consume these skills via dotagents sync, not a plugin. Keep this catalog entry enabled:false; local Claude Code delivery is controlled by agents[].delivery (sync or plugin). delivery: plugin installs dotagents@yourconscience and prunes Claude Code symlink artifacts to avoid duplicate /tg and /dotagents:tg commands.' mcp_servers: - name: linkedin enabled: true