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
16 changes: 16 additions & 0 deletions .agents/plugins/marketplace.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"name": "yourconscience",
"interface": {
"displayName": "dotagents"
},
"plugins": [
{
"name": "dotagents",
"source": {
"source": "local",
"path": "./plugins/dotagents"
},
"category": "Productivity"
}
]
}
47 changes: 42 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,18 @@ This repo is the canonical `~/.agents` layer. It detects installed agent platfor

## Install

Two ways to consume this repo - pick exactly one per machine and harness:

| You want | Do this |
|---|---|
| Just the skills in Claude Code | `/plugin marketplace add yourconscience/dotagents` then `/plugin install dotagents@yourconscience` |
| Just the skills in Codex | `codex plugin marketplace add https://github.com/yourconscience/dotagents` then `codex plugin add dotagents@yourconscience` |
| The full managed setup (skills, roles, MCP, hooks) on any supported harness - Claude Code, Codex, Hermes, Factory Droid, Pi/OMP | Install the `dotagents` CLI (below), clone the repo, run `dotagents setup` |

Plugins snapshot the repo at install time and update through each harness's plugin update flow. `dotagents sync` keeps live symlinks instead. Do not combine both on the same harness or skills appear twice; see "Installing this repo as a plugin" for details and how `delivery:` arbitrates this for Claude Code.

### CLI install

Prebuilt binaries for macOS and Linux (amd64/arm64) are attached to [GitHub Releases](https://github.com/yourconscience/dotagents/releases). With Go installed:

```bash
Expand All @@ -28,9 +40,10 @@ go install ./cmd/dotagents

Ensure the Go install directory is on `PATH`. If `go env GOBIN` is non-empty, add that directory; otherwise add `$(go env GOPATH)/bin`.

After that, use `dotagents` directly:
After that, run first-time machine setup (creates `~/.agents`, patches detected agent configs, syncs), or inspect state directly:

```bash
dotagents setup
dotagents status
dotagents deps check
```
Expand Down Expand Up @@ -133,7 +146,7 @@ External sources are pinned in `dotagents.lock` (commit this file): the first sy

## Plugins

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:
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 self-publication manifests - `.claude-plugin/`, `.agents/plugins/marketplace.json`, and `plugins/dotagents/` - are the exception; see "Installing this repo as a plugin" below.) A plugin entry records its source format, runtime surfaces, target agents, and review notes:

```yaml
plugins:
Expand All @@ -158,9 +171,16 @@ 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
### Installing this repo as a 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`:
The repo self-publishes as a plugin for the two harnesses that have plugin systems:

- Claude Code, via `.claude-plugin/{plugin,marketplace}.json` at the repo root.
- Codex, via `.agents/plugins/marketplace.json` and the `plugins/dotagents/` plugin directory.

Hermes, Factory Droid, and Pi/OMP have no plugin system; they consume skills through `dotagents setup` / `dotagents sync`.

For Claude Code:

```
/plugin marketplace add yourconscience/dotagents
Expand Down Expand Up @@ -195,7 +215,24 @@ 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.
### Installing this repo as a Codex plugin

For Codex:

```bash
codex plugin marketplace add https://github.com/yourconscience/dotagents
codex plugin add dotagents@yourconscience
```

The repo is private, so `marketplace add` requires working GitHub git auth on the machine.

Codex plugin installs require a *real, copied* `skills/` inside the plugin directory - symlinks are silently dropped and a plugin at the marketplace root is rejected (verified against `codex 0.136.0`). So `plugins/dotagents/skills/` is a rendered copy of the canonical `skills/` tree (tracked files only, a few hundred KB), regenerated by `dotagents render` alongside the Claude agent renders. `dotagents doctor` (`codex plugin` check) and CI fail when the copy drifts, which keeps the single-source-of-truth guarantee.

There is no `delivery:` switch for Codex: a machine managed by dotagents keeps the live symlink sync and should not install the Codex plugin on top (skills would appear twice). The plugin is the install path for machines that do not run dotagents. Like the Claude plugin, the manifest intentionally omits a fixed `version` so updates never hide behind a stale version pin; to pick up new skills, run `codex plugin marketplace upgrade`, then `codex plugin remove dotagents@yourconscience` and `codex plugin add dotagents@yourconscience` (re-adding refreshes the cached copy - verified against codex 0.136.0).

Plugins ship full skills, tools included. Skill tool commands resolve relative to the skill's own directory (the base directory the harness reports when a skill loads), never to a fixed checkout path, so bundled CLIs like `pr-triage` inspect and `x-sim` run from any install root - checkout, symlink, or plugin cache. The exceptions are the machine-management skills (`dotagents`, `cmux` hooks, memory sync) which inherently operate on a dotagents-managed machine and say so.

`SKILL.md` directories remain the genuinely portable cross-tool convention; harnesses without a plugin system (Hermes, Droid, Pi, Amp) consume them through `dotagents sync` or by pointing at `skills/` directly.

## Agent Integration Status

Expand Down
5 changes: 4 additions & 1 deletion cmd/dotagents/agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,10 @@ func runRender(opts runOptions) error {
if err != nil {
return err
}
return renderPluginAgents(repoRoot)
if err := renderPluginAgents(repoRoot); err != nil {
return err
}
return renderCodexPluginSkills(repoRoot)
}

func renderPluginAgents(repoRoot string) error {
Expand Down
225 changes: 225 additions & 0 deletions cmd/dotagents/codex_plugin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
package main

import (
"bytes"
"errors"
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
)

// codexPluginRelDir is the whole-repo Codex plugin. Its skills/ subtree is a
// rendered copy of the repo's skills/ because Codex plugin installs ignore
// symlinked skills directories (verified against codex 0.136.0).
const codexPluginRelDir = "plugins/dotagents"

const codexMarketplaceRelPath = ".agents/plugins/marketplace.json"

// codexPluginSourceFiles lists the skill files to mirror, as paths relative to
// the repo root (slash-separated). Tracked and untracked-but-not-ignored files
// are included so gitignored build artifacts and local data stay out.
func codexPluginSourceFiles(repoRoot string) ([]string, error) {
cmd := exec.Command("git", "-C", repoRoot, "ls-files", "--cached", "--others", "--exclude-standard", "-z", "--", "skills")
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("git ls-files skills: %w", err)
}
var files []string
for _, raw := range bytes.Split(out, []byte{0}) {
name := string(raw)
if name == "" {
continue
}
files = append(files, name)
}
sort.Strings(files)
return files, nil
}

func codexPluginSkillsDir(repoRoot string) string {
return filepath.Join(repoRoot, filepath.FromSlash(codexPluginRelDir), "skills")
}

// renderCodexPluginSkills mirrors skills/ into the Codex plugin dir, removing
// files that no longer exist in the source tree.
func renderCodexPluginSkills(repoRoot string) error {
files, err := codexPluginSourceFiles(repoRoot)
if err != nil {
return err
}
destRoot := codexPluginSkillsDir(repoRoot)

expected := make(map[string]bool, len(files))
written, unchanged := 0, 0
for _, rel := range files {
sub := strings.TrimPrefix(rel, "skills/")
src := filepath.Join(repoRoot, filepath.FromSlash(rel))
dest := filepath.Join(destRoot, filepath.FromSlash(sub))
data, err := os.ReadFile(src)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
continue // tracked but deleted in worktree; leave unexpected so the copy is pruned
}
return fmt.Errorf("read %s: %w", src, err)
}
expected[filepath.FromSlash(sub)] = true
current, err := os.ReadFile(dest)
if err == nil && bytes.Equal(current, data) {
unchanged++
continue
Comment on lines +71 to +73

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve rendered file modes when contents match

When a skill file changes only its executable bit (for example a hook or bundled tool is chmodded +x with identical bytes), this early continue treats the rendered Codex copy as fresh and never reaches the os.Stat/WriteFile path that applies info.Mode().Perm(). The new doctor check also compares only bytes, so dotagents render and CI can both pass while plugins/dotagents/skills/... keeps a stale non-executable mode, breaking plugin-installed hooks/scripts on the next install.

Useful? React with 👍 / 👎.

}
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("read %s: %w", dest, err)
}
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return fmt.Errorf("create %s: %w", filepath.Dir(dest), err)
}
info, err := os.Stat(src)
if err != nil {
return fmt.Errorf("stat %s: %w", src, err)
}
if err := os.WriteFile(dest, data, info.Mode().Perm()); err != nil {
return fmt.Errorf("write %s: %w", dest, err)
}
written++
}

removed := 0
if err := filepath.WalkDir(destRoot, func(path string, d fs.DirEntry, err error) error {
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil
}
return err
}
if d.IsDir() {
return nil
}
rel, err := filepath.Rel(destRoot, path)
if err != nil {
return err
}
if expected[rel] {
return nil
}
if err := os.Remove(path); err != nil {
return fmt.Errorf("remove stale %s: %w", path, err)
}
removed++
return nil
}); err != nil {
return err
}
if err := removeEmptyDirs(destRoot); err != nil {
return err
}

fmt.Printf("rendered %s/skills: %d written, %d unchanged, %d removed\n", codexPluginRelDir, written, unchanged, removed)
return nil
}

func removeEmptyDirs(root string) error {
var dirs []string
if err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil
}
return err
}
if d.IsDir() && path != root {
dirs = append(dirs, path)
}
return nil
}); err != nil {
return err
}
// Deepest first so emptied parents are removable in one pass.
sort.Slice(dirs, func(i, j int) bool { return len(dirs[i]) > len(dirs[j]) })
for _, dir := range dirs {
entries, err := os.ReadDir(dir)
if err != nil {
return err
}
if len(entries) == 0 {
if err := os.Remove(dir); err != nil {
return err
}
}
}
return nil
}

func checkCodexPlugin(repoRoot string) checkResult {
const name = "codex plugin"
manifest := filepath.Join(repoRoot, filepath.FromSlash(codexPluginRelDir), ".codex-plugin", "plugin.json")
if !hasFile(manifest) {
return checkResult{name, checkStatusFail, fmt.Sprintf("missing %s/.codex-plugin/plugin.json", codexPluginRelDir)}
}
if !hasFile(filepath.Join(repoRoot, filepath.FromSlash(codexMarketplaceRelPath))) {
return checkResult{name, checkStatusFail, "missing " + codexMarketplaceRelPath}
}

files, err := codexPluginSourceFiles(repoRoot)
if err != nil {
return checkResult{name, checkStatusFail, err.Error()}
}
destRoot := codexPluginSkillsDir(repoRoot)

expected := make(map[string]bool, len(files))
var stale []string
fresh := 0
for _, rel := range files {
sub := filepath.FromSlash(strings.TrimPrefix(rel, "skills/"))
expected[sub] = true
src, err := os.ReadFile(filepath.Join(repoRoot, filepath.FromSlash(rel)))
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
delete(expected, sub)
continue
}
return checkResult{name, checkStatusFail, err.Error()}
}
dest, err := os.ReadFile(filepath.Join(destRoot, sub))
if err != nil || !bytes.Equal(src, dest) {
stale = append(stale, sub)
continue
}
fresh++
}
if err := filepath.WalkDir(destRoot, func(path string, d fs.DirEntry, err error) error {
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil
}
return err
}
if d.IsDir() {
return nil
}
rel, err := filepath.Rel(destRoot, path)
if err != nil {
return err
}
if !expected[rel] {
stale = append(stale, rel+" (extra)")
}
return nil
}); err != nil {
return checkResult{name, checkStatusFail, err.Error()}
}

if len(stale) > 0 {
sort.Strings(stale)
sample := stale
if len(sample) > 5 {
sample = sample[:5]
}
return checkResult{name, checkStatusFail, fmt.Sprintf("%d file(s) stale (%s); run: dotagents render", len(stale), strings.Join(sample, ", "))}
}
return checkResult{name, checkStatusPass, fmt.Sprintf("%d rendered skill files fresh in %s", fresh, codexPluginRelDir)}
}
Loading
Loading