diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9389755..5f7a5b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,12 +43,6 @@ jobs: build-and-lint: runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - module: - - . - - memory/tools/knowledge-sync steps: - uses: actions/checkout@v4 @@ -58,22 +52,38 @@ jobs: cache: false - name: Test - working-directory: ${{ matrix.module }} run: go test ./... - name: Build - working-directory: ${{ matrix.module }} run: go build ./... - name: Vet - working-directory: ${{ matrix.module }} run: go vet ./... - name: Lint uses: golangci/golangci-lint-action@v7 with: version: v2.12.2 - working-directory: ${{ matrix.module }} + + memory-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Test memory hooks and lib + run: python3 -m unittest discover -s memory/tests + + release-script: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Test release script validation + run: sh scripts/release_test.sh npm-wrapper: runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7a92637..bcb83fb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,36 +1,82 @@ name: Release +# Publishing happens from a v* tag, but only after the tag has been verified +# against main and a green ci.yml run, and after the protected `release` +# environment approves. Configure that environment in repository settings with +# required reviewers, and keep HOMEBREW_TAP_TOKEN scoped to it. + on: push: tags: - "v*" permissions: - contents: write + contents: read jobs: + verify: + runs-on: ubuntu-latest + permissions: + contents: read + actions: read + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + + - name: Verify tag, ancestry, and CI + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -eu + tag="${GITHUB_REF_NAME}" + if ! printf '%s' "$tag" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'; then + echo "::error::not a stable semver tag: $tag" + exit 1 + fi + git fetch --no-tags --quiet origin main + if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then + echo "::error::$GITHUB_SHA is not on origin/main; releases are cut from main only" + exit 1 + fi + successes=$(gh run list --workflow=ci.yml --commit "$GITHUB_SHA" --status completed --limit 20 \ + --json headSha,conclusion \ + --jq "[.[] | select(.headSha == \"$GITHUB_SHA\" and .conclusion == \"success\")] | length") + if [ "$successes" -lt 1 ]; then + echo "::error::no successful ci.yml run found for $GITHUB_SHA" + exit 1 + fi + echo "verified $tag at $GITHUB_SHA (on main, ci.yml green)" + + - name: Release script checks + run: sh scripts/release_test.sh + goreleaser: + needs: verify runs-on: ubuntu-latest + environment: release + permissions: + contents: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: fetch-depth: 0 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version: "1.24" - - uses: goreleaser/goreleaser-action@v6 + - uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6 with: distribution: goreleaser - version: "~> v2" + version: "v2.18.2" args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} npm-publish: - needs: goreleaser + needs: [verify, goreleaser] runs-on: ubuntu-latest permissions: contents: read @@ -39,9 +85,9 @@ jobs: run: working-directory: npm steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 24 registry-url: https://registry.npmjs.org diff --git a/AGENTS.md b/AGENTS.md index a0fb1cc..8ce2580 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,7 +38,7 @@ New starter content requires an explicit product decision and an inventory test # Development -- Go version and module layout are declared by `go.mod` and `go.work`. +- Go version and module layout are declared by `go.mod`. The memory tools under `memory/tools/` are packages of that root module; `setup` scaffolds a standalone `go.mod` beside each copied tool so a user-owned config root can build them without the source checkout. - Install the development CLI with `go install ./cmd/dotagents`. - Use focused package tests while iterating; run `go test ./...` before submission. - Use Python 3's standard library for the dependency-free basic memory tier. diff --git a/README.md b/README.md index 7e1936c..c152f26 100644 --- a/README.md +++ b/README.md @@ -173,18 +173,24 @@ tailscale serve --bg --set-path /dotagents http://127.0.0.1:8765 ## Releases +Releases are cut from `main` after the release PR is reviewed and merged, and only with explicit approval: + ```bash -scripts/release.sh v0.7.0 # verify + tag; CI publishes binaries, brew tap, npm +scripts/release.sh vX.Y.Z # verify + tag; CI publishes binaries, brew tap, npm ``` +The script refuses to run unless the tree is clean, `HEAD` matches `origin/main`, the tag is strict `vMAJOR.MINOR.PATCH`, and every check passes. Pushing the tag starts `.github/workflows/release.yml`, which re-verifies the tag against `main` and a green `ci.yml` run, waits on the protected `release` environment, then publishes binaries, the Homebrew tap, and the npm wrapper. + ## Documentation +- [Overview & comparison](https://yourconscience.github.io/dotagents/) — landing page, sync matrix, positioning - [docs/setup.md](docs/setup.md) — first-run walkthrough, review screen, multi-machine setup - [docs/skills.md](docs/skills.md) — authoring skills, external pins and audits - [docs/roles.md](docs/roles.md) — role format, model tiers, per-harness overrides - [docs/memory.md](docs/memory.md) — memory tiers, rem workflow, vault layout - [docs/comparison.md](docs/comparison.md) — how dotagents differs from rulesync, ruler, openskills - [Troubleshooting](docs/troubleshooting.md) +- [memory/README.md](memory/README.md) — memory layer layout, hooks, and tools Project-level generators (rulesync, ruler) win on tool breadth; dotagents is user-level — one private repo, nine targets deep, pinned externals, review-first memory. Full table in [docs/comparison.md](docs/comparison.md). diff --git a/SPEC.md b/SPEC.md deleted file mode 100644 index e58ca23..0000000 --- a/SPEC.md +++ /dev/null @@ -1,305 +0,0 @@ -# SPEC - -## Goal - -Add a local-first authoring surface for dotagents that works as: - -1. a responsive web UI on desktop and mobile; -2. a Bubble Tea TUI in the terminal; -3. the existing editable YAML file. - -All three are projections of the same canonical `dotagents.yaml` document. The web UI and TUI must never create a second configuration store or write native harness configuration directly. `dotagents sync` remains the only renderer from canonical config into harness-specific files. - -The web UI ships inside the existing `dotagents` binary and runs as a standalone local process. It must not require HarnessKit, Node, or a separate daemon at runtime. - -## Non-goals - -- Moving skill bodies, role Markdown, plugin source, memory, or lock data into `dotagents.yaml`. These remain canonical repo files; YAML owns configuration and references to those files. -- Editing arbitrary files outside the resolved dotagents config root. -- Installing a LaunchAgent, daemon, Tailscale Serve rule, or public tunnel automatically. -- Managing active agent sessions, terminals, prompts, or usage dashboards. -- Writing directly to `~/.claude`, `~/.codex`, `~/.omp`, `~/.hermes`, or other materialized harness directories from the UI. - -## Commands - -The canonical configuration experience is reached two ways: `dotagents config` for the terminal TUI (plus `validate`/`print`) and `dotagents view` for the browser web UI. Both are projections of the same document. `view` takes over the `dotagents view` name from the former HarnessKit launcher, which is renamed to `dotagents inspect` (a separate read-mostly cross-harness inspector, not an authoring surface). - -```text -dotagents config # interactive TUI -dotagents view # web UI, loopback only, opens browser -dotagents view --no-open -dotagents view --addr 127.0.0.1:8765 -dotagents config validate # validate canonical YAML without writing -dotagents config print # print resolved paths and effective config -``` - -If no canonical config exists, `dotagents config` and `dotagents view` direct the user to `dotagents setup`; they do not implement a second first-run flow. - -## Canonical configuration model - -### Files and layers - -- Resolution remains: `--config` → `$DOTAGENTS_HOME/dotagents.yaml` → `~/.agents/dotagents.yaml`. -- `dotagents.yaml` is the canonical shared layer. -- `dotagents.local.yaml` is the optional machine-local canonical overlay. -- The UI exposes three explicit modes: **shared**, **local**, and **effective**. -- Shared and local are editable. Effective is a read-only merged projection. The UI never flattens the overlay back into the shared file. -- `dotagents.lock` stays sync-owned and read-only in this feature. - -### Shared config service - -Add one concrete `configDocument` service used directly by CLI, TUI, and HTTP handlers. Do not add an interface until a second implementation exists. - -Responsibilities: - -1. Resolve the canonical paths with the existing resolver and reject linked worktree roots with `refuseWorktreeRoot`. -2. Read YAML into a `yaml.Node` document and decode the same document into the existing typed `config` struct. -3. Apply the local overlay through the existing merge semantics, extended so a non-nil local `ui` block replaces the shared `ui` block wholesale, matching the overlay's current whole-entry behavior. -4. Validate editable bytes with `validateConfig(..., expand=false)` so portable `~` paths remain unchanged. Use `expand=true` only on a separate typed copy for effective views and sync planning. -5. Address list entries by stable keys: - - agents: normalized `name`; - - MCP servers: `name`; - - hooks: `name`; - - external skills: repository identity derived from `url`. -6. Mutate only the selected YAML nodes. Structured edits must preserve unknown fields, comments, ordering, quoting, and untouched subtrees. -7. Allow raw YAML replacement only after parsing and validation succeeds. -8. Compute a SHA-256 revision from the exact bytes read. Reject stale writes instead of overwriting a file changed by another process. -9. Write atomically in the same directory: temporary file, preserved file mode, flush/close, rename. A failed validation or write leaves the original bytes unchanged. -10. Return the exact before/after unified diff for every proposed save. - -Do not use `yaml.Marshal(config)` for interactive edits: the current setup/MCP writers reserialize the whole struct and can drop comments or unknown future fields. The config UI needs node-level mutation so it remains forward-compatible. - -Before `ui` ships, add typed `UI`/`Links` fields to `config`, extend `mergeConfig`, and route every canonical whole-document writer, including `writeSetupConfig` and `writeEditableMCPConfig`, through `configDocument`. Otherwise an existing `setup` or `mcp` command could silently remove `ui`, comments, or unknown fields. - -### Editable fields - -The structured editor covers every field currently represented by `config`: - -- version; -- agents: name, enabled, detect command, skill root, agent root, role model; -- external skills: URL, branch, skill directory/directories, selected skills, materialize, MCP enablement and target agents; -- MCP servers: name, enabled, command, arguments, environment, and target agents; -- hooks: name, enabled, event, command, timeout, target agents; -- context note token threshold; -- UI navigation links: name and absolute HTTPS URL or origin-relative path. - -### Linked surfaces - -Add an optional UI-only section to the canonical schema: - -```yaml -ui: - links: - - name: Usage - url: /usage -``` - -- Links are navigation metadata, not a sixth synced harness surface. -- `url` accepts an absolute `https://` URL or an origin-relative path beginning with `/`. -- The personal usage dashboard remains an external application. A machine-local overlay may link to its HTTPS URL or same-origin `/usage` route without adding a usage page to dotagents. -- The web UI renders configured links as read-only top-level navigation. Link values remain YAML-authored and are never proxied or embedded. -- TUI shows the same links in its UI section and can copy/open the selected URL where the platform supports it. - -Raw YAML editing remains available in the TUI and through the canonical file. The web UI deliberately exposes only browser-native selection controls for finite choices such as enablement; unknown fields remain untouched. - -## Mutation flow - -Every write follows the same review-first state machine: - -```text -Select option → Validate → Preview YAML diff → Save canonical YAML - ↓ optional, separate action - Preview sync plan → Confirm → Sync -``` - -- Saving YAML does not implicitly run `sync`. -- Sync preview reuses the existing inspection/report pipeline. -- Sync apply requires the preview digest and current config revision. If either changed, preview again. -- Destructive removals and role overwrites retain the existing per-harness confirmation semantics. -- API errors use stable codes (`invalid_yaml`, `invalid_config`, `stale_revision`, `sync_plan_changed`) plus actionable text. - -## Web API - -Minimum JSON API: - -```text -GET /api/state paths, active layer, typed config, raw YAML, revision -POST /api/config/validate candidate raw YAML or structured patch; returns errors + diff -PUT /api/config/raw expected revision + complete YAML; validates and writes -PATCH /api/config expected revision + keyed operations; validates and writes -POST /api/sync/preview returns per-harness plan + digest -POST /api/sync/apply expected revision + plan digest + confirmed destructive items -GET /api/status current per-harness sync state -``` - -No generic filesystem, shell, command, or path endpoint. Responses must not expose environment values marked as secrets; environment keys may be displayed, values are masked by default and only sent when editing the selected canonical YAML field. - -## Web security and mobile access - -- Bind only to loopback. Reject wildcard and non-loopback addresses. -- Generate a random startup token. Bootstrap it into an `HttpOnly`, `SameSite=Strict` session cookie and remove the token from the visible URL. `dotagents view --secure-cookie` additionally marks it `Secure` and is required when the browser reaches the loopback server through Tailscale HTTPS; plain local HTTP omits that flag. -- Require the session for every API request, validate `Origin`, and require a CSRF header for mutations. -- Send `Cache-Control: no-store`, a restrictive CSP, `X-Content-Type-Options: nosniff`, and `frame-ancestors 'none'`. -- Keep configuration and secrets in memory only for the request lifecycle; never log request bodies or tokens. -- Tailscale access is an explicit operator step, for example: - -```bash -dotagents view --no-open --secure-cookie --addr 127.0.0.1:8765 -tailscale serve --bg --set-path /dotagents http://127.0.0.1:8765 -``` - -The Tailscale syntax above was verified against the installed client on 2026-09-12. The application does not install or persist this route itself. - -## Web UX - -### Information architecture - -Desktop uses a compact settings ledger rather than a card dashboard: - -```text -┌ Sources ──────┬ Configuration ───────────────────┬ Change rail ─────┐ -│ Shared │ Agents / MCP / Hooks │ validation │ -│ Local │ selection controls + metadata │ diff + save │ -│ Effective │ read-only merged state │ sync preview │ -└───────────────┴──────────────────────────────────┴───────────────────┘ -``` - -Mobile collapses the same controls into one vertical page with horizontally scrollable source tabs. A sticky bottom bar contains only context-valid actions: **Validate**, **Review**, and **Save**. **Preview sync** and **Sync** remain separate below the change rail. - -### Visual direction - -Treat the product as an instrument panel for configuration provenance, not a generic SaaS dashboard. - -- Memorable element: the **change rail**, which shows the canonical YAML diff for selected settings. -- Layout: dense left-aligned ledger rows, clear nesting, no grid of rounded cards. -- Palette: Catppuccin Mocha base `#1E1E2E`, surface `#181825`, accent `#B4BEFE`, with accessible semantic colors. -- Type: native UI sans for controls; native monospace only for paths, revisions, and diffs. No network fonts. -- Motion: only state transitions for opening an item and revealing validation/diff results; respect reduced motion. -- Accessibility: semantic controls, visible focus, keyboard navigation, 44px mobile targets, safe-area padding, WCAG AA contrast, and no horizontal page overflow at 320px. - -The distinguishing choice is provenance visibility: every value says whether it comes from shared YAML, local overlay, a default, or the effective merge. Remove decoration that does not communicate that state. - -### Frontend delivery - -Use separate `index.html`, CSS, and JavaScript modules embedded with `go:embed`. Start with browser-native HTML controls and ES modules; no frontend framework or build-time Node dependency. Add a framework or editor package only after measured complexity proves native controls insufficient. - -## TUI UX - -Reuse Bubble Tea and Lip Gloss already in the module. The TUI and web UI share the config service and mutation state machine, not rendering code. - -```text -shared | local | effective -agents mcp hooks external skills advanced yaml - -> claude-code enabled ~/.claude/skills - codex enabled ~/.codex/skills - -[e] edit [/] search [y] yaml [v] validate [r] review [s] save [q] quit -``` - -- Wide terminals show section list, detail form, and change rail. -- Narrow terminals show one pane at a time. -- The YAML tab is a real editable text area, not a shell-out to `$EDITOR` in the first version. -- Save always opens the same validation + diff confirmation view used by structured edits. -- External file changes surface as a stale-revision screen with **reload** or **copy unsaved YAML**; never overwrite. - -## Implementation plan - -### Slice 1: canonical document editing - -- Introduce `config_document.go` with YAML node loading, typed decoding, stable-key lookup, validation, revisions, diff generation, and atomic write. -- Add typed `UI`/`Links` fields and explicit local-overlay semantics, then route every canonical whole-document writer, including `writeSetupConfig` and `writeEditableMCPConfig`, through `configDocument` before linked surfaces ship. Existing commands must not strip `ui`, comments, or unknown fields. -- Add `dotagents config validate` and `dotagents config print` as non-interactive proof of the shared service. -- Focused tests: comment/unknown-field preservation, overlay isolation, invalid-write rollback, stale revision, and file-mode preservation. -- Fresh canonical files use mode `0o644`; replacement preserves the existing file mode. - -### Slice 2: TUI authoring - -- Add `dotagents config` using the existing Bubble Tea dependency. -- Implement section navigation, complete field editing, raw YAML, validation, diff review, and save. -- Reuse existing setup review styles where useful, but keep setup import review and config editing as separate models. -- Exercise it against a temporary config root in a real PTY; tests never touch live harness configuration. - -### Slice 3: web server and desktop UI - -- Add `dotagents view` (the web UI) with loopback validation, token bootstrap, security headers, revision-aware APIs, and embedded separate assets. -- Implement selection-only structured controls, diff/save, status, and sync preview. Raw YAML stays out of the web UI. -- Keep sync apply behind a separate explicit confirmation screen. -- Exercise the actual page in a browser against a temporary config root: select setting → diff → save → reload. - -### Slice 4: mobile and Tailscale proof - -- Verify the 320px and current iPhone viewport flows with the real browser surface: navigation, YAML editing, validation errors, diff review, stale-write recovery, and sync confirmation. -- Run the server on loopback and expose it through a temporary Tailscale Serve path; verify HTTPS access from a tailnet client without changing existing routes. -- Document the operator-owned Tailscale command only after the live proof. Do not install persistent automation. - -### Slice 5: safe sync completion and documentation - -- Complete preview-digest guarded sync apply in web and TUI. -- Update README, CLI help, `skills/dotagents/SKILL.md`, setup docs, and release-site copy together. -- Document `dotagents config` (TUI) and `dotagents view` (web UI) as the two projections of canonical authoring; the former HarnessKit launcher is renamed to `dotagents inspect`. -- Remove throwaway smoke scripts and update this spec with Outcome / Deviations. - -## Acceptance tests - -1. A selection change in web updates the selected YAML node, survives reload, and appears immediately in TUI and raw YAML. -2. A TUI edit appears in web after reload and changes no native harness file until explicit sync. -3. The web UI contains no free-text editing widget; finite YAML choices use native checkboxes, toggles, or selects. -4. Structured editing preserves comments, unknown keys, ordering, quoting, and unrelated bytes where possible. -5. Editing `dotagents.local.yaml` changes only the local layer; the effective view reflects the merge and shared YAML remains byte-identical. -6. Invalid YAML or invalid typed config cannot replace the canonical file. -7. Concurrent external modification produces `stale_revision`; neither web nor TUI overwrites it. -8. Save shows the exact YAML diff. Sync shows a separate per-harness plan and rejects a changed digest. -9. Web server rejects non-loopback binds, unauthenticated API calls, invalid origins, CSRF-less mutations, and arbitrary path requests. -10. Desktop and 320px mobile browser smoke complete select → validate → review → save without horizontal page overflow. -11. Temporary Tailscale HTTPS access works while the server remains loopback-bound; removing the temporary route removes remote access. -12. Focused tests and `go test ./...` pass without mutating live harness configuration. -13. A machine-local `ui.links` entry renders as external top-level navigation on desktop and mobile; dotagents does not implement or proxy the linked usage page. - -## Risks / open questions - -- `yaml.v3` preserves node metadata but can still normalize formatting around modified nodes. The Slice 1 preservation tests define the acceptable boundary before UI work starts. -- Environment values may contain secrets. The UI needs masking and log redaction; deciding whether to reveal existing values at all should be made during Slice 1 threat modeling. -- `dotagents.local.yaml` replacement semantics are whole-entry, not field-level. The UI presents only safe finite choices; changing overlay semantics is out of scope. -- Mobile uses native controls and a single vertical page, avoiding a code editor dependency and iOS text-editing problems. -- Existing experimental dashboard work from May 2026 was a read-only catalog and session launcher on an obsolete repo layout. Reuse its proven loopback guardrails and separate-asset lesson, not its API or information architecture. - -## Codebase notes - -- Existing typed schema: `cmd/dotagents/main.go`, `mcp.go`, and `hooks.go`. -- Existing resolution, overlay, validation, and worktree safety: `cmd/dotagents/config.go`. -- Existing TUI foundation: `cmd/dotagents/review.go` and `review_apply.go`. -- Config web UI launcher: `cmd/dotagents/view.go` (serves the loopback web UI in `config_web.go`). -- Existing whole-document writers: `writeSetupConfig` and `writeEditableMCPConfig`; these are insufficient for comment/unknown-field-preserving interactive edits. - -## Outcome / Deviations - -Implemented in the current checkout: - -- `configDocument` now owns YAML node loading, typed validation, stable-key - mutations, SHA-256 revisions, atomic mode-preserving writes, and unified - diffs. Shared and local overlays remain isolated; a non-nil local `ui` - block replaces shared UI links. -- Setup and MCP canonical whole-document writes route through the document - service, preserving unknown fields and comments instead of flattening the - typed struct. -- `dotagents config`, `config validate`, and `config print` are wired. The - Bubble Tea editor has shared/local/effective tabs, raw YAML editing, - validation, diff review, save, and stale-revision protection. -- `dotagents view` embeds separate HTML/CSS/ES-module assets and exposes the - revision-aware state, validation, raw/structured mutation, sync preview, - guarded sync apply, and status APIs. Loopback binding, startup-token - bootstrap, strict session cookies, origin/CSRF checks, security headers, - secret masking, and `--secure-cookie` are implemented. -- Public help, README, skill documentation, setup documentation, and release - site copy describe canonical authoring through `dotagents config` (TUI) and `dotagents view` (web UI). - -Known deviations: - -- The TUI uses a native minimal textarea rather than a full structured form; - every schema field remains editable through its YAML tab. The web UI exposes - only finite enablement choices and renders paths, commands, events, and links - as read-only context. -- Browser and temporary Tailscale HTTPS proof require a desktop browser and - tailnet route outside this checkout. Focused HTTP/API and asset checks are - included; no persistent route or automation is installed. diff --git a/cmd/dotagents/agents.go b/cmd/dotagents/agents.go deleted file mode 100644 index 357642e..0000000 --- a/cmd/dotagents/agents.go +++ /dev/null @@ -1,720 +0,0 @@ -package main - -import ( - "bytes" - "errors" - "fmt" - "io/fs" - "os" - "path/filepath" - "sort" - "strconv" - "strings" - - "gopkg.in/yaml.v3" -) - -const ( - generatedAgentMarker = "Generated by dotagents" - readmeSkillsBeginMarker = "" - readmeSkillsEndMarker = "" -) - -// agentRoleMarkdownExt is the editable starter role source format under -// agents/.md. The YAML frontmatter holds metadata; the Markdown body is -// the role prompt. -const agentRoleMarkdownExt = ".md" - -type agentRole struct { - Name string `yaml:"name"` - Description string `yaml:"description"` - Model string `yaml:"model"` - Effort string `yaml:"effort"` - Tools []string `yaml:"tools"` - Color string `yaml:"color"` - Instructions string `yaml:"-"` - Source string `yaml:"-"` - Claude claudeRoleOptions `yaml:"claude"` - Codex codexRoleOptions `yaml:"codex"` - OMP ompRoleOptions `yaml:"omp"` - Pi piRoleOptions `yaml:"pi"` - Droid droidRoleOptions `yaml:"droid"` - Opencode opencodeRoleOptions `yaml:"opencode"` - Qwen qwenRoleOptions `yaml:"qwen"` -} - -func (role *agentRole) UnmarshalYAML(value *yaml.Node) error { - if value.Kind != yaml.MappingNode { - return fmt.Errorf("agent role must be a mapping") - } - for i := 0; i+1 < len(value.Content); i += 2 { - key := value.Content[i].Value - node := value.Content[i+1] - switch key { - case "name": - if err := node.Decode(&role.Name); err != nil { - return err - } - case "description": - if err := node.Decode(&role.Description); err != nil { - return err - } - case "model": - if err := node.Decode(&role.Model); err != nil { - return err - } - case "effort": - if err := node.Decode(&role.Effort); err != nil { - return err - } - case "color": - if err := node.Decode(&role.Color); err != nil { - return err - } - case "claude": - if err := node.Decode(&role.Claude); err != nil { - return err - } - case "codex": - if err := node.Decode(&role.Codex); err != nil { - return err - } - case "omp": - if err := node.Decode(&role.OMP); err != nil { - return err - } - case "pi": - if err := node.Decode(&role.Pi); err != nil { - return err - } - case "droid": - if err := node.Decode(&role.Droid); err != nil { - return err - } - case "opencode": - 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 { - return err - } - role.Tools = tools - } - } - return nil -} - -func decodeRoleTools(node *yaml.Node) ([]string, error) { - switch node.Kind { - case yaml.SequenceNode: - var tools []string - if err := node.Decode(&tools); err != nil { - return nil, err - } - return tools, nil - case yaml.ScalarNode: - var tools []string - for _, part := range strings.Split(node.Value, ",") { - if tool := strings.TrimSpace(part); tool != "" { - tools = append(tools, tool) - } - } - return tools, nil - default: - return nil, fmt.Errorf("tools must be a YAML sequence or comma-separated string") - } -} - -type claudeRoleOptions struct { - Model string `yaml:"model"` -} - -type codexRoleOptions struct { - Model string `yaml:"model"` - ModelReasoningEffort string `yaml:"model_reasoning_effort"` -} - -type ompRoleOptions struct { - Model string `yaml:"model"` - ThinkingLevel string `yaml:"thinking-level"` -} - -type piRoleOptions struct { - Model string `yaml:"model"` - Thinking string `yaml:"thinking"` -} - -type droidRoleOptions struct { - Model string `yaml:"model"` - ReasoningEffort string `yaml:"reasoning_effort"` - Tools []string `yaml:"tools"` -} - -type opencodeRoleOptions struct { - Model string `yaml:"model"` - Temperature string `yaml:"temperature"` - 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"}, - "glob": {"Glob"}, - "grep": {"Grep"}, - "read": {"Read"}, - "webfetch": {"FetchUrl"}, - "websearch": {"WebSearch"}, - "write": {"Create", "Edit"}, -} - -var droidFallbackTools = []string{"Read", "LS", "Grep", "Glob"} - -type renderedAgentRole struct { - Name string - Source string - Target string - Content string -} - -func expectedAgentRoles(repoRoot string, agent agentConfig) (map[string]renderedAgentRole, error) { - if agent.AgentRoot == "" { - return map[string]renderedAgentRole{}, nil - } - - roles, err := loadAgentRoles(repoRoot) - if err != nil { - return nil, err - } - - rendered := make(map[string]renderedAgentRole) - for _, role := range roles { - target, content, ok := renderAgentRole(role, agent) - if !ok { - continue - } - rendered[role.Name] = renderedAgentRole{ - Name: role.Name, - Source: role.Source, - Target: target, - Content: content, - } - } - return rendered, nil -} - -func loadAgentRoles(repoRoot string) ([]agentRole, error) { - agentsDir := filepath.Join(repoRoot, "agents") - entries, err := os.ReadDir(agentsDir) - if errors.Is(err, fs.ErrNotExist) { - return nil, nil - } - if err != nil { - return nil, fmt.Errorf("read %s: %w", agentsDir, err) - } - - var paths []string - for _, entry := range entries { - if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") || filepath.Ext(entry.Name()) != agentRoleMarkdownExt { - continue - } - paths = append(paths, filepath.Join(agentsDir, entry.Name())) - } - sort.Strings(paths) - return loadMarkdownAgentRoles(paths) -} - -func loadMarkdownAgentRoles(paths []string) ([]agentRole, error) { - roles := make([]agentRole, 0, len(paths)) - seen := make(map[string]struct{}, len(paths)) - for _, path := range paths { - role, err := loadMarkdownAgentRole(path) - if err != nil { - return nil, err - } - if _, ok := seen[role.Name]; ok { - return nil, fmt.Errorf("agent role %s is duplicated", role.Name) - } - seen[role.Name] = struct{}{} - roles = append(roles, role) - } - sort.Slice(roles, func(i, j int) bool { - return roles[i].Name < roles[j].Name - }) - return roles, nil -} - -func loadMarkdownAgentRole(path string) (agentRole, error) { - data, err := os.ReadFile(path) - if err != nil { - return agentRole{}, fmt.Errorf("read %s: %w", path, err) - } - role, err := parseAgentRoleMarkdown(path, data) - if err != nil { - return agentRole{}, err - } - if err := finalizeAgentRole(&role, path); err != nil { - return agentRole{}, err - } - return role, nil -} - -func parseAgentRoleMarkdown(path string, data []byte) (agentRole, error) { - if !bytes.HasPrefix(data, []byte("---\n")) { - return agentRole{}, fmt.Errorf("%s: missing YAML frontmatter", path) - } - rest := data[len("---\n"):] - end := bytes.Index(rest, []byte("\n---")) - if end < 0 { - return agentRole{}, fmt.Errorf("%s: unterminated YAML frontmatter", path) - } - frontmatter := rest[:end] - body := rest[end+len("\n---"):] - if len(body) > 0 && body[0] == '\r' { - body = body[1:] - } - if len(body) > 0 && body[0] == '\n' { - body = body[1:] - } - var role agentRole - if err := yaml.Unmarshal(frontmatter, &role); err != nil { - return agentRole{}, fmt.Errorf("parse %s frontmatter: %w", path, err) - } - role.Instructions = strings.TrimSpace(string(body)) - role.Source = path - return role, nil -} - -func finalizeAgentRole(role *agentRole, source string) error { - role.Name = normalizeAgentName(role.Name) - role.Model = strings.TrimSpace(role.Model) - role.Effort = strings.TrimSpace(role.Effort) - role.Description = strings.TrimSpace(role.Description) - role.Instructions = strings.TrimSpace(role.Instructions) - if role.Name == "" { - return fmt.Errorf("%s is missing name", source) - } - if role.Description == "" { - return fmt.Errorf("%s: role %s is missing description", source, role.Name) - } - if role.Instructions == "" { - return fmt.Errorf("%s: role %s is missing instructions", source, role.Name) - } - return nil -} - -func expectedREADMESkillsBlock(repoRoot string) (string, int, error) { - entries, err := os.ReadDir(filepath.Join(repoRoot, "skills")) - if errors.Is(err, fs.ErrNotExist) { - entries = nil - } else if err != nil { - return "", 0, fmt.Errorf("read skills/: %w", err) - } - - names := make([]string, 0, len(entries)) - for _, entry := range entries { - if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { - continue - } - if !hasFile(filepath.Join(repoRoot, "skills", entry.Name(), "SKILL.md")) { - continue - } - // grill-me is a packaged command alias for grilling, not a standalone - // model-invocable skill in the public inventory. - if entry.Name() == "grill-me" { - continue - } - names = append(names, entry.Name()) - } - sort.Strings(names) - - var b strings.Builder - b.WriteString(readmeSkillsBeginMarker) - b.WriteString("\n") - fmt.Fprintf(&b, "%d skills ship with this repo:\n\n", len(names)) - b.WriteString("`") - b.WriteString(strings.Join(names, "` `")) - b.WriteString("`\n") - b.WriteString(readmeSkillsEndMarker) - return b.String(), len(names), nil -} - -func locateREADMESkillsBlock(content string) (int, int, error) { - if strings.Count(content, readmeSkillsBeginMarker) != 1 || strings.Count(content, readmeSkillsEndMarker) != 1 { - return 0, 0, errors.New("README skills markers must each appear exactly once") - } - start := strings.Index(content, readmeSkillsBeginMarker) - end := strings.Index(content, readmeSkillsEndMarker) - if end < start { - return 0, 0, errors.New("README skills end marker precedes begin marker") - } - return start, end + len(readmeSkillsEndMarker), nil -} - -func renderREADMESkills(repoRoot string) error { - path := filepath.Join(repoRoot, "README.md") - data, err := os.ReadFile(path) - if errors.Is(err, fs.ErrNotExist) { - return nil - } - if err != nil { - return fmt.Errorf("read README.md: %w", err) - } - expected, count, err := expectedREADMESkillsBlock(repoRoot) - if err != nil { - return err - } - content := string(data) - if !strings.Contains(content, readmeSkillsBeginMarker) && !strings.Contains(content, readmeSkillsEndMarker) { - return nil - } - start, end, err := locateREADMESkillsBlock(content) - if err != nil { - return err - } - updated := content[:start] + expected + content[end:] - if updated == content { - fmt.Printf("rendered README skills: %d unchanged\n", count) - return nil - } - if err := os.WriteFile(path, []byte(updated), 0o644); err != nil { - return fmt.Errorf("write README.md: %w", err) - } - fmt.Printf("rendered README skills: %d written\n", count) - return nil -} - -func runRender(opts runOptions) error { - repoRoot, _, _, _, err := loadContext(opts) - if err != nil { - return err - } - return renderCommittedArtifacts(repoRoot) -} - -func renderCommittedArtifacts(repoRoot string) error { - return renderREADMESkills(repoRoot) -} - -func renderAgentRole(role agentRole, agent agentConfig) (string, string, bool) { - h := harnessFor(agent.Name) - if h == nil || h.Roles == nil { - return "", "", false - } - if role.Model == "" && agent.RoleModel != "" { - role.Model = agent.RoleModel - } - target := filepath.Join(agent.AgentRoot, role.Name+h.Roles.Extension) - content := h.Roles.Render(role) - return target, content, true -} - -func renderClaudeAgentRole(role agentRole) string { - model := strings.TrimSpace(role.Claude.Model) - if model == "" { - model = strings.TrimSpace(role.Model) - } - - var b strings.Builder - b.WriteString("---\n") - writeYAMLScalar(&b, "name", role.Name) - writeYAMLScalar(&b, "description", role.Description) - writeYAMLScalar(&b, "model", model) - writeYAMLScalar(&b, "effort", role.Effort) - if len(role.Tools) > 0 { - b.WriteString("tools: ") - b.WriteString(strings.Join(role.Tools, ", ")) - b.WriteString("\n") - } - writeYAMLScalar(&b, "color", role.Color) - b.WriteString("---\n\n") - b.WriteString("\n\n") - b.WriteString(role.Instructions) - b.WriteString("\n") - return b.String() -} - -func renderCodexAgentRole(role agentRole) string { - model := strings.TrimSpace(role.Codex.Model) - if model == "" { - model = codexModelFor(role.Model) - } - effort := strings.TrimSpace(role.Codex.ModelReasoningEffort) - if effort == "" { - effort = strings.TrimSpace(role.Effort) - } - - var b strings.Builder - b.WriteString("# ") - b.WriteString(generatedAgentMarker) - b.WriteString(" from ") - b.WriteString(agentRoleSourceLabel(role)) - b.WriteString("; do not edit directly.\n") - writeTOMLString(&b, "name", role.Name) - writeTOMLString(&b, "description", role.Description) - writeTOMLString(&b, "model", model) - writeTOMLString(&b, "model_reasoning_effort", effort) - writeTOMLMultiline(&b, "developer_instructions", role.Instructions) - return b.String() -} - -// codexModelFor resolves legacy canonical model aliases. Claude-family names -// (opus/sonnet/haiku) are not valid Codex identifiers, so they render without a -// model and Codex uses its own default; anything else passes through verbatim. -func codexModelFor(model string) string { - switch strings.ToLower(strings.TrimSpace(model)) { - case "", "haiku", "sonnet", "opus": - return "" - default: - return strings.TrimSpace(model) - } -} - -func renderDroidAgentRole(role agentRole) string { - model := strings.TrimSpace(role.Droid.Model) - if model == "" { - model = droidModelFor(role.Model) - } - effort := strings.TrimSpace(role.Droid.ReasoningEffort) - if effort == "" { - effort = strings.TrimSpace(role.Effort) - } - tools := role.Droid.Tools - if len(tools) == 0 { - tools = droidToolsFor(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, "reasoningEffort", effort) - if len(tools) > 0 { - b.WriteString("tools:\n") - for _, tool := range tools { - writeYAMLListItem(&b, tool) - } - } - b.WriteString("---\n\n") - b.WriteString("\n\n") - b.WriteString(role.Instructions) - b.WriteString("\n") - return b.String() -} - -// canonicalModelTier reports whether the value is a legacy tier alias rather -// than an exact model identifier. Harnesses without a tier concept must not -// emit these values verbatim. -func canonicalModelTier(model string) bool { - switch strings.ToLower(strings.TrimSpace(model)) { - case "haiku", "sonnet", "opus": - return true - } - return false -} - -func agentRoleSourceLabel(role agentRole) string { - if role.Source == "" { - return "agents/" + role.Name + agentRoleMarkdownExt - } - return "agents/" + filepath.Base(role.Source) -} - -func writeYAMLScalar(b *strings.Builder, key string, value string) { - value = strings.TrimSpace(value) - if value == "" { - return - } - b.WriteString(key) - b.WriteString(": ") - b.WriteString(strconv.Quote(value)) - b.WriteString("\n") -} - -func writeYAMLListItem(b *strings.Builder, value string) { - value = strings.TrimSpace(value) - if value == "" { - return - } - b.WriteString(" - ") - b.WriteString(strconv.Quote(value)) - b.WriteString("\n") -} - -func writeTOMLString(b *strings.Builder, key string, value string) { - value = strings.TrimSpace(value) - if value == "" { - return - } - b.WriteString(key) - b.WriteString(" = ") - b.WriteString(strconv.Quote(value)) - b.WriteString("\n") -} - -func writeTOMLMultiline(b *strings.Builder, key string, value string) { - b.WriteString(key) - b.WriteString(" = ") - b.WriteString(strconv.Quote(value)) - b.WriteString("\n") -} - -func droidModelFor(model string) string { - model = strings.TrimSpace(model) - switch strings.ToLower(model) { - case "haiku": - return "custom:gpt-5.5(low)" - case "sonnet": - return "custom:gpt-5.5(medium)" - case "opus": - return "custom:gpt-5.5(high)" - default: - if model == "" { - return "inherit" - } - return model - } -} - -func droidToolsFor(tools []string) []string { - var out []string - seen := make(map[string]struct{}) - for _, tool := range tools { - for _, mapped := range droidToolMapping[strings.ToLower(strings.TrimSpace(tool))] { - if _, ok := seen[mapped]; ok { - continue - } - seen[mapped] = struct{}{} - out = append(out, mapped) - } - } - if len(tools) > 0 && len(out) == 0 { - return append([]string(nil), droidFallbackTools...) - } - return out -} - -func inspectAgentRoles(report *agentReport, repoRoot string, agent agentConfig) error { - expected, err := expectedAgentRoles(repoRoot, agent) - if err != nil { - return err - } - if len(expected) == 0 { - return nil - } - - names := sortedAgentRoleNames(expected) - for _, name := range names { - rendered := expected[name] - data, err := os.ReadFile(rendered.Target) - if errors.Is(err, fs.ErrNotExist) { - report.MissingAgent = append(report.MissingAgent, name) - report.AddsAgent = append(report.AddsAgent, name) - continue - } - if err != nil { - return fmt.Errorf("read %s: %w", rendered.Target, err) - } - - if string(data) == rendered.Content { - report.ManagedAgent = append(report.ManagedAgent, name) - continue - } - if isManagedAgentFile(rendered.Target, data, repoRoot) { - report.DriftedAgent = append(report.DriftedAgent, name) - report.UpdatesAgent = append(report.UpdatesAgent, name) - continue - } - report.Conflicts = append(report.Conflicts, fmt.Sprintf("agent %s exists but is not dotagents-managed", rendered.Target)) - } - return nil -} - -func isManagedAgentFile(path string, data []byte, repoRoot string) bool { - if strings.Contains(string(data), generatedAgentMarker) { - return true - } - - info, err := os.Lstat(path) - if err != nil || info.Mode()&os.ModeSymlink == 0 { - return false - } - rawTarget, err := os.Readlink(path) - if err != nil { - return false - } - targetAbs := absoluteTarget(path, rawTarget) - repoAgentsRoot := filepath.Join(repoRoot, "agents") - return targetAbs == repoAgentsRoot || strings.HasPrefix(targetAbs, repoAgentsRoot+string(os.PathSeparator)) -} - -func applyAgentRoleSync(reports []agentReport, selected []agentConfig, repoRoot string) error { - agentIndex := make(map[string]agentConfig, len(selected)) - for _, agent := range selected { - agentIndex[agent.Name] = agent - } - for _, report := range reports { - if !report.Detected { - continue - } - agent, ok := agentIndex[report.Name] - if !ok || agent.AgentRoot == "" { - continue - } - if len(report.Conflicts) > 0 { - return fmt.Errorf("%s has conflicts", report.Name) - } - expected, err := expectedAgentRoles(repoRoot, agent) - if err != nil { - return err - } - if len(report.AddsAgent)+len(report.UpdatesAgent) == 0 { - continue - } - if err := os.MkdirAll(agent.AgentRoot, 0o755); err != nil { - return fmt.Errorf("create %s: %w", agent.AgentRoot, err) - } - for _, name := range append(append([]string{}, report.AddsAgent...), report.UpdatesAgent...) { - rendered, ok := expected[name] - if !ok { - continue - } - if err := os.Remove(rendered.Target); err != nil && !errors.Is(err, fs.ErrNotExist) { - return fmt.Errorf("remove %s before rewrite: %w", rendered.Target, err) - } - if err := os.WriteFile(rendered.Target, []byte(rendered.Content), 0o644); err != nil { - return fmt.Errorf("write %s: %w", rendered.Target, err) - } - } - } - return nil -} - -func sortedAgentRoleNames(roles map[string]renderedAgentRole) []string { - names := make([]string, 0, len(roles)) - for name := range roles { - names = append(names, name) - } - sort.Strings(names) - return names -} diff --git a/cmd/dotagents/agents_test.go b/cmd/dotagents/agents_test.go deleted file mode 100644 index 63608d2..0000000 --- a/cmd/dotagents/agents_test.go +++ /dev/null @@ -1,436 +0,0 @@ -package main - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestRenderClaudeAgentRoleQuotesFrontmatter(t *testing.T) { - role := agentRole{ - Name: "reviewer", - Description: "Reviews code: safely", - Model: "sonnet", - Effort: "high", - Tools: []string{"Read", "Grep"}, - Color: "purple", - Instructions: "Review the change.", - } - - got := renderClaudeAgentRole(role) - for _, want := range []string{ - `name: "reviewer"`, - `description: "Reviews code: safely"`, - `model: "sonnet"`, - `effort: "high"`, - `color: "purple"`, - generatedAgentMarker, - "Review the change.", - } { - if !strings.Contains(got, want) { - t.Fatalf("rendered Claude role missing %q:\n%s", want, got) - } - } -} - -func TestRenderClaudeAgentRoleAllowsArbitraryOverride(t *testing.T) { - role := agentRole{ - Name: "builder", - Description: "Builds features", - Model: "opus", - Claude: claudeRoleOptions{Model: "claude-custom-model"}, - Instructions: "Implement the change.", - } - - got := renderClaudeAgentRole(role) - if !strings.Contains(got, `model: "claude-custom-model"`) { - t.Fatalf("rendered Claude role did not use explicit model override:\n%s", got) - } - if strings.Contains(got, `model: "opus"`) { - t.Fatalf("rendered Claude role retained family default after override:\n%s", got) - } -} - -func TestParseAgentRoleMarkdownPreservesHarnessModelOverrides(t *testing.T) { - role, err := parseAgentRoleMarkdown("agents/builder.md", []byte(`--- -name: builder -description: Builds features -model: opus -claude: - model: claude-custom-model -omp: - model: gpt-custom-model ---- - -Implement the change. -`)) - if err != nil { - t.Fatal(err) - } - if role.Claude.Model != "claude-custom-model" { - t.Fatalf("Claude override = %q", role.Claude.Model) - } - if role.OMP.Model != "gpt-custom-model" { - t.Fatalf("OMP override = %q", role.OMP.Model) - } -} - -func TestRenderCodexAgentRoleEscapesControlCharacters(t *testing.T) { - role := agentRole{ - Name: "researcher", - Description: `Find "facts"`, - Model: "sonnet", - Effort: "high", - Instructions: "Line one\nLine two\tTabbed\rReturn", - Codex: codexRoleOptions{ - Model: "test-model-mini", - ModelReasoningEffort: "medium", - }, - } - - got := renderCodexAgentRole(role) - for _, want := range []string{ - `name = "researcher"`, - `model = "test-model-mini"`, - `model_reasoning_effort = "medium"`, - `developer_instructions = "Line one\nLine two\tTabbed\rReturn"`, - generatedAgentMarker, - } { - if !strings.Contains(got, want) { - t.Fatalf("rendered Codex role missing %q:\n%s", want, got) - } - } -} - -func TestRenderOMPAgentRoleHonorsOverride(t *testing.T) { - role := agentRole{ - Name: "researcher", - Description: "Find reliable evidence", - Model: "opus", - Effort: "high", - Instructions: "Compare the sources.", - OMP: ompRoleOptions{ - Model: "gpt-5.6-luna", - ThinkingLevel: "xhigh", - }, - } - - got := renderOMPAgentRole(role) - for _, want := range []string{ - `name: "researcher"`, - "model:\n", - `- "gpt-5.6-luna"`, - `thinking-level: "xhigh"`, - } { - if !strings.Contains(got, want) { - t.Fatalf("rendered OMP role missing %q:\n%s", want, got) - } - } - if strings.Contains(got, `- "opus"`) || strings.Contains(got, "effort:") { - t.Fatalf("OMP role leaked Claude defaults into override:\n%s", got) - } -} - -func TestRenderOMPAgentRoleFallsBackToCanonicalModel(t *testing.T) { - role := agentRole{ - Name: "reviewer", - Description: "Reviews code", - Model: "opus", - Effort: "high", - Instructions: "Review carefully.", - } - - got := renderOMPAgentRole(role) - if !strings.Contains(got, "model:\n") || !strings.Contains(got, `- "opus"`) { - t.Fatalf("OMP role did not fall back to canonical model:\n%s", got) - } - if strings.Contains(got, "thinking-level:") { - t.Fatalf("OMP role invented a thinking-level without an override:\n%s", got) - } -} - -func TestRenderDroidAgentRoleMapsModelAndTools(t *testing.T) { - role := agentRole{ - Name: "builder", - Description: "Builds features", - Model: "sonnet", - Effort: "high", - Tools: []string{"Read", "Glob", "Grep", "Bash", "Write", "Edit", "WebFetch", "WebSearch"}, - Instructions: "Implement the change.", - } - - got := renderDroidAgentRole(role) - for _, want := range []string{ - `name: "builder"`, - `description: "Builds features"`, - `model: "custom:gpt-5.5(medium)"`, - `reasoningEffort: "high"`, - `- "Read"`, - `- "Glob"`, - `- "Grep"`, - `- "Execute"`, - `- "Create"`, - `- "Edit"`, - `- "FetchUrl"`, - `- "WebSearch"`, - generatedAgentMarker, - "Implement the change.", - } { - if !strings.Contains(got, want) { - t.Fatalf("rendered Droid role missing %q:\n%s", want, got) - } - } -} - -func TestRenderCodexAgentRoleOmitsMissingModel(t *testing.T) { - role := agentRole{ - Name: "builder", - Description: "Builds features", - Instructions: "Implement the change.", - } - - got := renderCodexAgentRole(role) - for _, absent := range []string{"model =", "model_reasoning_effort ="} { - if strings.Contains(got, absent) { - t.Fatalf("model-neutral codex role should omit %q:\n%s", absent, got) - } - } -} -func TestCodexModelForNeutralizesAliases(t *testing.T) { - tests := map[string]string{ - "": "", - "sonnet": "", - "opus": "", - "haiku": "", - "gpt-custom": "gpt-custom", - } - - for input, want := range tests { - if got := codexModelFor(input); got != want { - t.Fatalf("codexModelFor(%q) = %q, want %q", input, got, want) - } - } -} - -func TestRenderAgentRolePrefillsConfiguredModel(t *testing.T) { - role := agentRole{Name: "builder", Description: "Builds features", Instructions: "Implement."} - path, content, ok := renderAgentRole(role, agentConfig{Name: agentClaudeCode, AgentRoot: t.TempDir(), RoleModel: "configured-model"}) - if !ok { - t.Fatal("claude role was not rendered") - } - if !strings.Contains(content, `model: "configured-model"`) { - t.Fatalf("role missing configured role_model:\n%s", content) - } - if path == "" { - t.Fatal("empty target path") - } -} - -func TestDroidModelFor(t *testing.T) { - tests := map[string]string{ - "": "inherit", - "sonnet": "custom:gpt-5.5(medium)", - "opus": "custom:gpt-5.5(high)", - "haiku": "custom:gpt-5.5(low)", - "gpt-custom": "gpt-custom", - } - - for input, want := range tests { - if got := droidModelFor(input); got != want { - t.Fatalf("droidModelFor(%q) = %q, want %q", input, got, want) - } - } -} - -func TestDroidToolsForMapsWriteToCreateAndEdit(t *testing.T) { - got := droidToolsFor([]string{"Write"}) - want := []string{"Create", "Edit"} - if len(got) != len(want) { - t.Fatalf("droidToolsFor(Write) = %#v, want %#v", got, want) - } - for i := range want { - if got[i] != want[i] { - t.Fatalf("droidToolsFor(Write) = %#v, want %#v", got, want) - } - } -} - -func TestDroidToolsForFallsBackToReadOnlyWhenNoToolsMap(t *testing.T) { - got := droidToolsFor([]string{"NotebookEdit"}) - want := []string{"Read", "LS", "Grep", "Glob"} - if len(got) != len(want) { - t.Fatalf("droidToolsFor(unmapped) = %#v, want %#v", got, want) - } - for i := range want { - if got[i] != want[i] { - t.Fatalf("droidToolsFor(unmapped) = %#v, want %#v", got, want) - } - } -} - -func writeAgentsFixture(t *testing.T, repoRoot string, name string, data string) { - t.Helper() - agentsDir := filepath.Join(repoRoot, "agents") - if err := os.MkdirAll(agentsDir, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(agentsDir, name), []byte(data), 0o644); err != nil { - t.Fatal(err) - } -} - -func TestLoadAgentRolesMarkdown(t *testing.T) { - repoRoot := t.TempDir() - writeAgentsFixture(t, repoRoot, "reviewer.md", `--- -name: reviewer -description: Reviews changes -model: sonnet -effort: high -tools: [Read, Grep] -color: purple -omp: - model: gpt-5.6-luna - thinking-level: xhigh ---- - -Review the change. -`) - writeAgentsFixture(t, repoRoot, "builder.md", `--- -name: builder -description: Builds features -model: sonnet -tools: Read, Grep ---- - -Implement the change. -`) - - roles, err := loadAgentRoles(repoRoot) - if err != nil { - t.Fatal(err) - } - if len(roles) != 2 { - t.Fatalf("loaded %d roles, want 2", len(roles)) - } - if roles[0].Name != "builder" || roles[1].Name != "reviewer" { - t.Fatalf("roles not sorted by name: %#v", roles) - } - if len(roles[0].Tools) != 2 || roles[0].Tools[0] != "Read" || roles[0].Tools[1] != "Grep" { - t.Fatalf("unexpected scalar tools: %#v", roles[0].Tools) - } - role := roles[1] - if role.Description != "Reviews changes" || role.Instructions != "Review the change." { - t.Fatalf("unexpected role: %#v", role) - } - if len(role.Tools) != 2 || role.Tools[0] != "Read" || role.Tools[1] != "Grep" { - t.Fatalf("unexpected tools: %#v", role.Tools) - } - if role.OMP.Model != "gpt-5.6-luna" || role.OMP.ThinkingLevel != "xhigh" { - t.Fatalf("unexpected OMP options: %#v", role.OMP) - } - if filepath.Base(role.Source) != "reviewer.md" { - t.Fatalf("unexpected source: %q", role.Source) - } -} - -func TestCanonicalResearcherRendersCodexAtMax(t *testing.T) { - repoRoot, err := filepath.Abs(filepath.Join("..", "..")) - if err != nil { - t.Fatal(err) - } - role, err := loadMarkdownAgentRole(filepath.Join(repoRoot, "agents", "researcher.md")) - if err != nil { - t.Fatal(err) - } - - got := renderCodexAgentRole(role) - for _, want := range []string{ - `name = "researcher"`, - `model = "gpt-5.6-luna"`, - `model_reasoning_effort = "max"`, - } { - if !strings.Contains(got, want) { - t.Fatalf("canonical researcher Codex role missing %q:\n%s", want, got) - } - } -} - -func TestCanonicalResearcherRendersOMPAtMaximum(t *testing.T) { - repoRoot, err := filepath.Abs(filepath.Join("..", "..")) - if err != nil { - t.Fatal(err) - } - role, err := loadMarkdownAgentRole(filepath.Join(repoRoot, "agents", "researcher.md")) - if err != nil { - t.Fatal(err) - } - - got := renderOMPAgentRole(role) - for _, want := range []string{ - `name: "researcher"`, - `- "gpt-5.6-luna"`, - `thinking-level: "max"`, - } { - if !strings.Contains(got, want) { - t.Fatalf("canonical researcher OMP role missing %q:\n%s", want, got) - } - } -} - -func TestLoadAgentRolesMarkdownRejectsMappingTools(t *testing.T) { - repoRoot := t.TempDir() - writeAgentsFixture(t, repoRoot, "invalid.md", `--- -name: invalid -description: Invalid tools -tools: {name: Read} ---- - -Do work. -`) - _, err := loadAgentRoles(repoRoot) - if err == nil || !strings.Contains(err.Error(), "tools must be a YAML sequence or comma-separated string") { - t.Fatalf("mapping tools error = %v", err) - } -} - -func TestLoadAgentRolesMarkdownRejectsMissingFrontmatter(t *testing.T) { - repoRoot := t.TempDir() - writeAgentsFixture(t, repoRoot, "reviewer.md", "Review the change.\n") - - if _, err := loadAgentRoles(repoRoot); err == nil || !strings.Contains(err.Error(), "missing YAML frontmatter") { - t.Fatalf("want missing frontmatter error, got %v", err) - } -} - -func TestLoadAgentRolesMarkdownRejectsMissingInstructions(t *testing.T) { - repoRoot := t.TempDir() - writeAgentsFixture(t, repoRoot, "reviewer.md", `--- -name: reviewer -description: Reviews changes ---- -`) - - if _, err := loadAgentRoles(repoRoot); err == nil || !strings.Contains(err.Error(), "missing instructions") { - t.Fatalf("want missing instructions error, got %v", err) - } -} - -func TestIsManagedAgentFile(t *testing.T) { - repoRoot := t.TempDir() - managed := filepath.Join(repoRoot, "managed.toml") - if err := os.WriteFile(managed, []byte("# "+generatedAgentMarker+"\n"), 0o644); err != nil { - t.Fatal(err) - } - if !isManagedAgentFile(managed, []byte("# "+generatedAgentMarker+"\n"), repoRoot) { - t.Fatal("generated marker should be managed") - } - - unmanaged := filepath.Join(repoRoot, "unmanaged.toml") - if err := os.WriteFile(unmanaged, []byte("name = \"local\"\n"), 0o644); err != nil { - t.Fatal(err) - } - if isManagedAgentFile(unmanaged, []byte("name = \"local\"\n"), repoRoot) { - t.Fatal("unmarked real file should not be managed") - } -} diff --git a/cmd/dotagents/main.go b/cmd/dotagents/main.go index 82175c4..eee1241 100644 --- a/cmd/dotagents/main.go +++ b/cmd/dotagents/main.go @@ -1,604 +1,15 @@ package main import ( - "errors" - "flag" "fmt" - "io" "os" - "os/exec" -) - -type config struct { - Version int `yaml:"version"` - Agents []agentConfig `yaml:"agents"` - MCPServers []mcpServerConfig `yaml:"mcp_servers"` - ExternalSkills []externalSkillSource `yaml:"external_skills"` - PublishTargets []publishTarget `yaml:"publish_targets,omitempty"` - Hooks []hookConfig `yaml:"hooks,omitempty"` - UI *uiConfig `yaml:"ui,omitempty"` - // ContextNoteTokens is the estimated skill-listing token threshold above - // which `dotagents doctor` prints a soft context advisory note. Absent - // (nil) uses contextNoteTokensDefault; 0 (or negative) disables the note. - ContextNoteTokens *int `yaml:"context_note_tokens,omitempty"` -} - -type uiConfig struct { - Links []uiLink `yaml:"links,omitempty"` -} - -type uiLink struct { - Name string `yaml:"name"` - URL string `yaml:"url"` -} - -type externalSkillSource struct { - URL string `yaml:"url"` - SkillDir string `yaml:"skill_dir,omitempty"` - SkillDirs []string `yaml:"skill_dirs,omitempty"` - Branch string `yaml:"branch"` - Skills []string `yaml:"skills,omitempty"` - Materialize bool `yaml:"materialize,omitempty"` - MCP bool `yaml:"mcp,omitempty"` - MCPAgents []string `yaml:"mcp_agents,omitempty"` -} - -type agentConfig struct { - Name string `yaml:"name"` - Enabled bool `yaml:"enabled"` - SkillRoot string `yaml:"skill_root"` - AgentRoot string `yaml:"agent_root,omitempty"` - Detect string `yaml:"detect,omitempty"` - RoleModel string `yaml:"role_model,omitempty"` - Packages *[]string `yaml:"packages,omitempty"` -} - -// publishTarget declares a remote skill registry to push canonical skills to. -// It is a publish verb, not a sync entity: unlike agentConfig it has no local -// skill_root to reconcile and no detect key. Skills is an explicit allowlist — -// only named skills are ever uploaded, so a private or experimental skill is -// never shipped by accident. -type publishTarget struct { - Name string `yaml:"name"` - Kind string `yaml:"kind"` // registry kind; only "openai-skills" for now - Enabled bool `yaml:"enabled"` // default off; opt in per target - Skills []string `yaml:"skills"` // allowlist of local skill dir names to publish - VersionStrategy string `yaml:"version_strategy,omitempty"` // new-version (default) | set-default - APIKeyEnv string `yaml:"api_key_env,omitempty"` // env var holding the key; default OPENAI_API_KEY -} -const ( - publishKindOpenAISkills = "openai-skills" - publishDefaultAPIKeyEnv = "OPENAI_API_KEY" - publishStrategyNewVersion = "new-version" - publishStrategySetDefault = "set-default" + "github.com/yourconscience/dotagents/internal/app" ) -type repoLinkReport struct { - Path string - ExpectedTarget string - ActualTarget string - State string -} - -type agentReport struct { - Name string - SkillRoot string - AgentRoot string - ExpectedSkills map[string]string - Detected bool - RootPath string - RootExpected string - RootActual string - RootState string - Managed []string - ManagedAgent []string - ManagedMCP []string - ManagedHook []string - ManagedPackage []string - Drifted []string - DriftedAgent []string - DriftedMCP []string - DriftedHook []string - DriftedPackage []string - Missing []string - MissingAgent []string - MissingMCP []string - MissingHook []string - UnsupportedHook []string - Conflicts []string - StaleManaged []string - External []string - Adds []string - AddsAgent []string - AddsMCP []string - AddsHook []string - Updates []string - UpdatesAgent []string - UpdatesMCP []string - UpdatesHook []string - UpdatesPackage []string - Removes []string - RemovesAgent []string - RemovesPackage []string - Synced bool -} - -func isDetected(agent agentConfig) bool { - if agent.Detect == "" { - return true - } - executable, err := exec.LookPath(agent.Detect) - if err != nil { - return false - } - if harness := harnessFor(agent.Name); harness != nil && harness.Detect != nil { - return harness.Detect(executable) - } - return true -} - -type runOptions struct { - ConfigPath string - ConfigOverride *config - Agents string - Pull bool - E2E bool - SkipPackageAge bool - MemoryTier string - JSONOutput bool - DryRun bool - AssumeYes bool - Stdin io.Reader - Stdout io.Writer - // ConfirmRemovals makes sync preview per-harness removals and role - // overwrites and ask before applying them. Set by setup-driven syncs. - ConfirmRemovals bool - // Verbose expands `status` back to the full per-surface managed and - // external skill lists and native root paths instead of the concise view. - Verbose bool -} - func main() { - if err := run(os.Args[1:]); err != nil { + if err := app.Run(os.Args[1:]); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } } - -func run(args []string) error { - if len(args) == 0 { - printUsage() - return errors.New("missing subcommand") - } - - switch args[0] { - case "setup": - return runSetupCommand(args[1:]) - case "status": - return runStatusCommand(args[1:]) - case "sync": - return runSyncCommand(args[1:]) - case "doctor": - return runDoctorCommand(args[1:]) - case "config": - return runConfigCommand(args[1:]) - case "view": - return runView(args[1:]) - case "inspect": - return runInspect(args[1:]) - case "sessions": - return runSessions(args[1:]) - case "skill": - return runSkillCommand(args[1:]) - case "publish": - return runPublishCommand(args[1:]) - case "mcp": - return runMCP(args[1:]) - case "hook": - return runHookCommand(args[1:]) - case "cron": - opts, err := parseCronFlags(args[1:]) - if err != nil { - return err - } - return runCron(opts) - case "pull": - printRenameNotice("pull", "sync --pull") - opts, err := parseSubcommandFlags("pull", args[1:]) - if err != nil { - return err - } - return runPull(opts) - case "deps": - return runDeprecatedDeps(args[1:]) - case "memsearch": - return runDeprecatedMemsearch(args[1:]) - case "skillify": - printRenameNotice("skillify", "skill new") - return runSkillCommand(append([]string{"new"}, args[1:]...)) - case "render": - printRenameNotice("render", "sync") - opts, err := parseSubcommandFlags("render", args[1:]) - if err != nil { - return err - } - return runRender(opts) - case "audit": - printRenameNotice("audit", "doctor") - opts, err := parseSubcommandFlags("audit", args[1:]) - if err != nil { - return err - } - return runAudit(opts) - case "external": - if len(args) > 1 && args[1] == "list" { - printRenameNotice("external list", "status") - } else if len(args) > 1 && args[1] == "update" { - printRenameNotice("external update", "skill update") - } else { - printRenameNotice("external", "status or skill update") - } - return runExternal(args[1:]) - case "promote": - printRenameNotice("promote", "skill promote") - return runSkillCommand(append([]string{"promote"}, args[1:]...)) - case "dogfood": - printRenameNotice("dogfood", "doctor --e2e") - opts, err := parseSubcommandFlags("dogfood", args[1:]) - if err != nil { - return err - } - return runDogfood(opts) - case "help": - if len(args) == 1 { - printUsage() - return nil - } - if len(args) == 2 && args[1] == "--all" { - printAllUsage() - return nil - } - return errors.New("usage: dotagents help [--all]") - case "-h", "--help": - if len(args) != 1 { - return errors.New("usage: dotagents help [--all]") - } - printUsage() - return nil - default: - printUsage() - return fmt.Errorf("unknown subcommand %q", args[0]) - } -} - -func runSetupCommand(args []string) error { - if len(args) > 0 { - switch args[0] { - case "memsearch": - return runMemsearch(append([]string{"setup"}, args[1:]...)) - } - } - opts, err := parseSetupFlags(args) - if err != nil { - return err - } - return runSetup(opts) -} - -func runStatusCommand(args []string) error { - if len(args) > 0 { - switch args[0] { - case "memsearch": - printRenameNotice("status memsearch", "status") - return runMemsearch(append([]string{"status"}, args[1:]...)) - } - } - opts, err := parseStatusFlags(args) - if err != nil { - return err - } - return runStatus(opts) -} - -func parseStatusFlags(args []string) (runOptions, error) { - fs := flag.NewFlagSet("status", flag.ContinueOnError) - fs.SetOutput(os.Stderr) - - var opts runOptions - fs.StringVar(&opts.ConfigPath, "config", "", "Path to dotagents YAML config") - fs.StringVar(&opts.Agents, "agents", "", "Comma-separated agent names to use for this run") - fs.BoolVar(&opts.SkipPackageAge, "skip-package-age", false, "Skip external package publish-age checks") - fs.BoolVar(&opts.Verbose, "verbose", false, "Show full managed/external skill lists and native root paths") - fs.BoolVar(&opts.Verbose, "v", false, "Show full managed/external skill lists and native root paths") - - if err := fs.Parse(args); err != nil { - return runOptions{}, err - } - if fs.NArg() != 0 { - return runOptions{}, errors.New("status does not accept positional arguments") - } - return opts, nil -} - -func runSyncCommand(args []string) error { - if len(args) > 0 { - switch args[0] { - case "pull": - printRenameNotice("sync pull", "sync --pull") - opts, err := parseSubcommandFlags("sync pull", args[1:]) - if err != nil { - return err - } - return runPull(opts) - case "deps": - opts, err := parseDepsFlags("sync deps", args[1:]) - if err != nil { - return err - } - return runDepsUpdate(opts) - case "render": - printRenameNotice("sync render", "sync") - opts, err := parseSubcommandFlags("sync render", args[1:]) - if err != nil { - return err - } - return runRender(opts) - } - } - opts, err := parseSyncFlags(args) - if err != nil { - return err - } - if opts.Pull { - opts.Pull = false - return runPull(opts) - } - return runSync(opts) -} - -func runDoctorCommand(args []string) error { - if len(args) > 0 { - switch args[0] { - case "audit": - opts, err := parseSubcommandFlags("doctor audit", args[1:]) - if err != nil { - return err - } - return runAudit(opts) - case "deps": - opts, err := parseDepsFlags("doctor deps", args[1:]) - if err != nil { - return err - } - return runDepsCheck(opts) - case "dogfood": - printRenameNotice("doctor dogfood", "doctor --e2e") - opts, err := parseSubcommandFlags("doctor dogfood", args[1:]) - if err != nil { - return err - } - return runDogfood(opts) - } - } - opts, err := parseDoctorFlags(args) - if err != nil { - return err - } - if opts.E2E { - opts.E2E = false - return runDogfood(opts) - } - return runDoctor(opts) -} - -func runSkillCommand(args []string) error { - if len(args) == 0 { - return errors.New("skill requires subcommand: new, list, info, update, promote") - } - switch args[0] { - case "new": - return runSkillify(args[1:]) - case "update": - return runExternalUpdate(args[1:]) - case "list": - return runSkillList(args[1:]) - case "info": - return runSkillInfo(args[1:]) - case "promote": - return runPromote(args[1:]) - case "external": - if len(args) > 1 && args[1] == "list" { - printRenameNotice("skill external list", "status") - } else if len(args) > 1 && args[1] == "update" { - printRenameNotice("skill external update", "skill update") - } else { - printRenameNotice("skill external", "status or skill update") - } - return runExternal(args[1:]) - default: - return fmt.Errorf("unknown skill subcommand %q", args[0]) - } -} - -func runDeprecatedDeps(args []string) error { - if len(args) > 0 { - switch args[0] { - case "check": - printRenameNotice("deps check", "doctor deps") - return runDoctorCommand(append([]string{"deps"}, args[1:]...)) - case "update": - printRenameNotice("deps update", "sync deps") - return runSyncCommand(append([]string{"deps"}, args[1:]...)) - } - } - printRenameNotice("deps", "doctor deps or sync deps") - return runDeps(args) -} - -func runDeprecatedMemsearch(args []string) error { - if len(args) > 0 { - switch args[0] { - case "setup": - printRenameNotice("memsearch setup", "setup memsearch") - return runSetupCommand(append([]string{"memsearch"}, args[1:]...)) - case "status": - printRenameNotice("memsearch status", "status memsearch") - return runStatusCommand(append([]string{"memsearch"}, args[1:]...)) - } - } - printRenameNotice("memsearch", "setup memsearch or status memsearch") - return runMemsearch(args) -} - -func printRenameNotice(oldCommand string, newCommand string) { - fmt.Fprintf(os.Stderr, "dotagents: %q was renamed to %q\n", oldCommand, newCommand) -} - -func parseSubcommandFlags(name string, args []string) (runOptions, error) { - fs := flag.NewFlagSet(name, flag.ContinueOnError) - fs.SetOutput(os.Stderr) - - var opts runOptions - fs.StringVar(&opts.ConfigPath, "config", "", "Path to dotagents YAML config") - fs.StringVar(&opts.Agents, "agents", "", "Comma-separated agent names to use for this run") - fs.BoolVar(&opts.SkipPackageAge, "skip-package-age", false, "Skip external package publish-age checks") - - if err := fs.Parse(args); err != nil { - return runOptions{}, err - } - if fs.NArg() != 0 { - return runOptions{}, fmt.Errorf("%s does not accept positional arguments", name) - } - - return opts, nil -} - -func parseSetupFlags(args []string) (runOptions, error) { - fs := flag.NewFlagSet("setup", flag.ContinueOnError) - fs.SetOutput(os.Stderr) - var opts runOptions - opts.MemoryTier = memoryTierBasic - fs.StringVar(&opts.ConfigPath, "config", "", "Path to dotagents YAML config") - fs.StringVar(&opts.Agents, "agents", "", "Comma-separated agent names to use for this run") - fs.StringVar(&opts.MemoryTier, "memory", memoryTierBasic, "Memory tier: off, basic, or memsearch") - fs.BoolVar(&opts.JSONOutput, "json", false, "Emit detection result as JSON and exit") - fs.BoolVar(&opts.DryRun, "dry-run", false, "Show detected import candidates and exit without changes (overrides --yes)") - fs.BoolVar(&opts.AssumeYes, "yes", false, "Import all detected items without prompting") - if err := fs.Parse(args); err != nil { - return runOptions{}, err - } - if fs.NArg() != 0 { - return runOptions{}, errors.New("setup does not accept positional arguments") - } - return opts, nil -} - -func parseSyncFlags(args []string) (runOptions, error) { - fs := flag.NewFlagSet("sync", flag.ContinueOnError) - fs.SetOutput(os.Stderr) - var opts runOptions - fs.StringVar(&opts.ConfigPath, "config", "", "Path to dotagents YAML config") - fs.StringVar(&opts.Agents, "agents", "", "Comma-separated agent names to use for this run") - fs.BoolVar(&opts.Pull, "pull", false, "Pull the repo before syncing") - if err := fs.Parse(args); err != nil { - return runOptions{}, err - } - if fs.NArg() != 0 { - return runOptions{}, errors.New("sync does not accept positional arguments") - } - return opts, nil -} - -func parseDoctorFlags(args []string) (runOptions, error) { - fs := flag.NewFlagSet("doctor", flag.ContinueOnError) - fs.SetOutput(os.Stderr) - var opts runOptions - fs.StringVar(&opts.ConfigPath, "config", "", "Path to dotagents YAML config") - fs.StringVar(&opts.Agents, "agents", "", "Comma-separated agent names to use for this run") - fs.BoolVar(&opts.E2E, "e2e", false, "Run sync, status, and doctor end to end") - fs.BoolVar(&opts.SkipPackageAge, "skip-package-age", false, "Skip external package publish-age checks") - if err := fs.Parse(args); err != nil { - return runOptions{}, err - } - if fs.NArg() != 0 { - return runOptions{}, errors.New("doctor does not accept positional arguments") - } - return opts, nil -} - -func parseCronFlags(args []string) (cronOptions, error) { - fs := flag.NewFlagSet("cron", flag.ContinueOnError) - fs.SetOutput(os.Stderr) - - var opts cronOptions - fs.StringVar(&opts.ConfigPath, "config", "", "Path to dotagents YAML config") - fs.StringVar(&opts.Agents, "agents", "", "Comma-separated agent names") - fs.BoolVar(&opts.Remove, "remove", false, "Remove the cron entry instead of installing") - fs.BoolVar(&opts.Deps, "deps", false, "Install dependency maintenance cron instead of auto-pull") - fs.StringVar(&opts.Interval, "interval", cronIntervalDefault, "Pull interval: 5m, 15m, 30m, 1h, 6h, 12h, daily, weekly") - - if err := fs.Parse(args); err != nil { - return cronOptions{}, err - } - return opts, nil -} - -func printUsage() { - fmt.Println("dotagents - manage shared skills, MCP, and canonical config") - fmt.Println() - fmt.Println("Commands:") - fmt.Println(" setup Set up this machine and sync configured harnesses") - fmt.Println(" status Show harness, external lock, and memsearch state") - fmt.Println(" sync Regenerate committed artifacts and reconcile harnesses") - fmt.Println(" doctor Check pins, dependencies, and local health") - fmt.Println(" config Author the canonical YAML in an interactive TUI") - fmt.Println(" view Author the canonical YAML in a loopback web UI (browser)") - fmt.Println() - fmt.Println("Supported integrations:") - fmt.Println(" inspect Launch HarnessKit for cross-harness configuration inspection") - fmt.Println(" sessions Launch AgentsView for session search, telemetry, and usage") - fmt.Println() - fmt.Println("Command groups:") - fmt.Println(" skill Inspect, create, update, and promote skills") - fmt.Println(" publish Push canonical skills to a remote skill registry") - fmt.Println(" mcp Manage MCP servers") - fmt.Println(" hook Review and remove native hook registrations") - fmt.Println() - fmt.Println("Run \"dotagents help --all\" for flags, maintenance commands, and compatibility aliases.") -} - -func printAllUsage() { - printUsage() - fmt.Println() - fmt.Println("Canonical forms:") - fmt.Println(" dotagents setup [--memory off|basic|memsearch] [--agents ...] [--yes] [--dry-run] [--json]") - fmt.Println(" dotagents status [--verbose] [--agents ...]") - fmt.Println(" dotagents sync [--pull] [--agents ...]") - fmt.Println(" dotagents doctor [--e2e] [--agents ...]") - fmt.Println(" dotagents config [validate|print] [--config PATH]") - fmt.Println(" dotagents view [--addr 127.0.0.1:8765] [--no-open] [--secure-cookie] [--ssh-host user@host] [--token-file PATH]") - fmt.Println(" dotagents inspect [--no-open] [--ssh-host user@host] [hk serve flags: --port N, --host ADDR, --no-token]") - fmt.Println(" dotagents sessions [--no-open] [--ssh-host user@host] [agentsview serve flags: --port N, --host ADDR, --no-sync]") - fmt.Println(" dotagents skill new [--description ...]") - fmt.Println(" dotagents skill list [--agents ...]") - fmt.Println(" dotagents skill info ") - fmt.Println(" dotagents skill update [name ...]") - fmt.Println(" dotagents skill promote [--dry-run]") - fmt.Println(" dotagents publish [--target NAME] [--skills a,b] [--dry-run] [--json] [--yes]") - fmt.Println(" dotagents mcp [options]") - fmt.Println(" dotagents hook list [--agents ...] [query]") - fmt.Println(" dotagents hook remove [--dry-run] [--agents ...] ") - fmt.Println() - fmt.Println("Maintenance and compatibility aliases:") - fmt.Println(" dotagents cron [--interval 30m|--deps|--remove]") - fmt.Println(" dotagents deps [options]") - fmt.Println(" dotagents memsearch [options]") - fmt.Println(" dotagents pull [options]") - fmt.Println(" dotagents render [options]") - fmt.Println(" dotagents audit [options]") - fmt.Println(" dotagents external [name ...]") - fmt.Println(" dotagents skillify [options]") - fmt.Println(" dotagents promote [--dry-run]") - fmt.Println(" dotagents dogfood [options]") -} diff --git a/cmd/dotagents/omp_agent.go b/cmd/dotagents/omp_agent.go deleted file mode 100644 index 5bc343c..0000000 --- a/cmd/dotagents/omp_agent.go +++ /dev/null @@ -1,63 +0,0 @@ -package main - -import "strings" - -var ompToolMapping = map[string]string{ - "bash": "bash", - "edit": "edit", - "glob": "glob", - "grep": "grep", - "read": "read", - "webfetch": "read", - "websearch": "web_search", - "write": "write", -} - -func renderOMPAgentRole(role agentRole) string { - model := strings.TrimSpace(role.OMP.Model) - if model == "" { - model = strings.TrimSpace(role.Model) - } - - var b strings.Builder - b.WriteString("---\n") - writeYAMLScalar(&b, "name", role.Name) - writeYAMLScalar(&b, "description", role.Description) - if model != "" { - b.WriteString("model:\n") - writeYAMLListItem(&b, model) - } - writeYAMLScalar(&b, "thinking-level", role.OMP.ThinkingLevel) - if tools := ompToolsFor(role.Tools); len(tools) > 0 { - b.WriteString("tools:\n") - for _, tool := range tools { - writeYAMLListItem(&b, tool) - } - } - b.WriteString("---\n\n") - b.WriteString("\n\n") - b.WriteString(role.Instructions) - b.WriteString("\n") - return b.String() -} - -func ompToolsFor(tools []string) []string { - out := make([]string, 0, len(tools)) - seen := make(map[string]struct{}, len(tools)) - for _, tool := range tools { - mapped := ompToolMapping[strings.ToLower(strings.TrimSpace(tool))] - if mapped == "" { - continue - } - if _, ok := seen[mapped]; ok { - continue - } - seen[mapped] = struct{}{} - out = append(out, mapped) - } - return out -} diff --git a/cmd/dotagents/pi_agent.go b/cmd/dotagents/pi_agent.go deleted file mode 100644 index 70bf3ad..0000000 --- a/cmd/dotagents/pi_agent.go +++ /dev/null @@ -1,66 +0,0 @@ -package main - -import "strings" - -var piToolMapping = map[string]string{ - "bash": "bash", - "edit": "edit", - "glob": "find", - "grep": "grep", - "read": "read", - "webfetch": "fetch_content", - "websearch": "web_search", - "write": "write", -} - -// renderPiAgentRole emits the user-agent format consumed by pi-subagents. -// Vanilla Pi itself ignores this directory when the extension is absent. -func renderPiAgentRole(role agentRole) string { - model := strings.TrimSpace(role.Pi.Model) - if model == "" && !canonicalModelTier(role.Model) { - model = strings.TrimSpace(role.Model) - } - thinking := strings.TrimSpace(role.Pi.Thinking) - if thinking == "" { - thinking = strings.TrimSpace(role.Effort) - } - - var b strings.Builder - b.WriteString("---\n") - writeYAMLScalar(&b, "name", role.Name) - writeYAMLScalar(&b, "description", role.Description) - writeYAMLScalar(&b, "model", model) - writeYAMLScalar(&b, "thinking", thinking) - if tools := piToolsFor(role.Tools); len(tools) > 0 { - b.WriteString("tools:\n") - for _, tool := range tools { - writeYAMLListItem(&b, tool) - } - } - b.WriteString("---\n\n") - b.WriteString("\n\n") - b.WriteString(role.Instructions) - b.WriteString("\n") - return b.String() -} - -func piToolsFor(tools []string) []string { - out := make([]string, 0, len(tools)) - seen := make(map[string]struct{}, len(tools)) - for _, tool := range tools { - mapped := piToolMapping[strings.ToLower(strings.TrimSpace(tool))] - if mapped == "" { - continue - } - if _, ok := seen[mapped]; ok { - continue - } - seen[mapped] = struct{}{} - out = append(out, mapped) - } - return out -} diff --git a/docs/harnesskit-integration.md b/docs/harnesskit-integration.md deleted file mode 100644 index 372ebc1..0000000 --- a/docs/harnesskit-integration.md +++ /dev/null @@ -1,53 +0,0 @@ -# HarnessKit integration — design notes - -Status: L0 + L2 shipped. The launcher is `dotagents inspect` (it was `dotagents inspect` until v0.9.0, when `view` became the config web UI). L1 (opt-in install) and L3 (write-through) remain future work. Original design date 2026-09-06. - -## Finding - -[HarnessKit](https://github.com/RealZST/HarnessKit) (RealZST/HarnessKit, Rust, Apache-2.0, ~420★, active) is a web UI (also desktop/CLI) that inspects and manages agent extensions, configs, memory, and rules across 13 harnesses. Verified live against a full stack install on 2026-09-06: it detects and reads the **full dotagents stack** — Claude Code, Codex, **Oh My Pi** (`~/.omp/agent/`), **Hermes** (`~/.hermes/`), plus Gemini CLI, Copilot, OpenCode, Grok Build. This is exactly the coverage (Pi/OMP + Hermes) that CCO and ai-config-sync-manager lack. - -Consequence: dotagents does **not** need to build its own config viewer. HarnessKit already does the read/inspect/audit surface better than we would from scratch, and on every harness we care about. - -## Why integrate, and the one boundary rule - -HarnessKit and dotagents are complementary, not competing: - -- **HarnessKit** = read/inspect/audit dashboard + marketplace. Reads *materialized native dirs*. Its write model is **convergence** ("deploy this extension to every agent"). -- **dotagents** = sync engine + source of truth. Manages the 5 surfaces (skills, MCP, hooks, roles, plugins) via symlinks + `dotagents.lock` + intentional per-harness divergence. - -**Boundary invariant for this integration:** dotagents stays the only writer. HarnessKit is consumed read-mostly. We never route dotagents' managed surfaces *through* HarnessKit's convergence writer, and we never let HK's "deploy to all" become the mechanism that mutates a dotagents-owned symlink. Divergence is a feature here, not drift — HK's model treats it as drift, so its write path is off-limits for managed surfaces. - -## Integration levels - -### L0 — Recommend (docs only, zero coupling) - -Name HarnessKit in `README.md`, `docs/comparison.md`, and the `dotagents` skill as the inspection dashboard: "dotagents owns sync; use HarnessKit to see/audit the result across harnesses." No code. Ships today. - -### L1 — Optional dependency (`deps` / `setup`) - -Register HarnessKit as an **optional, opt-in** external tool: - -- `dotagents setup` offers (never forces) HK install after the first sync, gated behind a prompt. -- `dotagents deps check` reports whether HK is present + version; `deps update` bumps it. -- Honor the existing publish-age gate (`checkExternalPackageAge`, `package_age.go`) — HK is a fast-moving Rust binary; do not auto-pull a release younger than the configured window. - -Install method is still open (release binary vs `cargo install` vs `brew` tap) — do not hardcode one until verified. - -### L2 — Launch command (`dotagents inspect`) - -`dotagents inspect` starts `hk serve`, prints the tokenized URL on its own line, and (locally) opens it in the default browser; `--no-open` skips the launch and `--ssh-host user@host` prints an `ssh -L` tunnel command instead. - -- HarnessKit does its own harness discovery over the native homes (`~/.claude`, `~/.omp`, `~/.hermes`, …), so `inspect` does not load or pass the dotagents config root; a nonstandard `--config`/`$DOTAGENTS_HOME` only relocates dotagents' YAML, not the harness homes HK reads. -- Spawns the HK local server (127.0.0.1, token in URL) and opens it locally (suppressible with `--no-open`). Mirrors the external-CLI launch path (`external_cli.go`, `cli_launch_test.go`). -- Inspection intent, not enforced: `hk serve` has no read-only mode, so HarnessKit's own enable/disable/deploy actions can still write native dirs and bypass dotagents. The launch banner warns against using them on managed surfaces; reconcile drift with `dotagents sync`. - -L0–L2 are the concrete near-term scope. dotagents itself writes no managed surfaces on these paths, but `dotagents inspect` launches HarnessKit, whose own enable/disable/deploy actions can still write native dirs (including managed surfaces) and bypass dotagents; the launch banner cautions against this and drift is reconciled with `dotagents sync`. - -## Recommended first slice - -**L0 + L2, now unblocked** (open questions #1–#3 resolved in HK's favor): - -1. L0 docs pointer — README + `dotagents` SKILL + CLI help. Pointer only, no duplicated harness-compat table. -2. `dotagents inspect` — thin launcher: `exec.LookPath("hk")`, forward args to `hk serve`, inspection framing (writes not enforced — banner cautions), install hint when absent. Implemented in `cmd/dotagents/harnesskit.go` and `inspect_test.go`. -3. L1 opt-in install — deferred until the install method and publish-age window are settled. -4. L3 write-through — separate research spike, no code; keep dotagents the only writer until a go/no-go is decided. diff --git a/docs/openai-skill-registry-publish.md b/docs/openai-skill-registry-publish.md deleted file mode 100644 index 069e587..0000000 --- a/docs/openai-skill-registry-publish.md +++ /dev/null @@ -1,171 +0,0 @@ -# Design: `dotagents publish` — OpenAI skill registry target - -Status: **core implemented on branch `feat/publish-openai-skills` (2026-09-12).** Config schema, lock schema, bundler, upload, and the `dotagents publish` command with dry-run/json/confirmation are built and unit-tested (`publish_test.go`, full suite green). Still inert by default: no targets are configured and every target is opt-in via `enabled: true`. Deferred pending live-API verification: `--prune`, server-side `status` reconcile, and any dependence on list/delete endpoints (undocumented — see §10). No live upload has run yet. -Date: 2026-09-12 -Author trigger: OpenAI Agents API public beta (Codex harness). Background research lives in the maintainer's private knowledge vault under `research/` (not distributed with this repo). - -## 1. Summary - -OpenAI's Agents API loads the same open Agent-Skills `SKILL.md` that dotagents already treats as canonical, and it exposes a **persistent skill registry**: `POST https://api.openai.com/v1/skills` uploads a skill bundle, stores it server-side, returns a `skill_id`, and manages versioned bundles. The Responses API references skills by `skill_id` + `version`. - -This is a **publish verb**, not a sync entity. It does not belong in the `agents:` list. It maps cleanly onto the existing `external_skills` + `dotagents.lock` machinery, but in the opposite direction: `external_skills` materializes remote skills *inward* and pins their upstream commit; `publish` pushes canonical skills *outward* to a remote registry and pins the returned id/version. - -## 2. Why not an `agents:` entity (the decision) - -Every `agentConfig` in `dotagents.yaml` is a locally-installed harness with: -- a `skill_root` (an on-disk directory dotagents reconciles by placing skills), and -- a `detect` key (checks whether the harness is installed on this machine). - -The Agents API has neither. It is a cloud API, not a local install; there is no directory to reconcile and nothing to detect. And it runs the **Codex harness** — the `codex` entity already produces the canonical `SKILL.md` the Agents API consumes. Adding an `openai-agents` row would: -- duplicate the `codex` skill output, -- break the "reconcile a local directory" invariant every current entity satisfies, and -- misfile a per-session runtime concern (`capability_directories`) as a local sync target. - -So the format side needs **zero** work — it is already handled by the `codex` entity. The only genuinely new surface is the registry transport, and that is a publish action. - -### Scope boundary: registry, not sandbox -Two OpenAI skill-loading mechanisms exist: -- **`capability_directories`** — ephemeral, per-session, from sandbox filesystem paths supplied at session-create time by the *application* calling the Agents API. Out of scope for dotagents. -- **`/v1/skills` registry** — persistent, upload-once, reference-by-id. **This is the only surface dotagents targets.** - -## 3. Config schema (`publish_targets:`) - -New top-level section in `dotagents.yaml`, sibling to `agents:`, `external_skills:`, etc. Struct mirrors `externalSkillSource` in style. - -```yaml -publish_targets: - - name: openai # unique target name - kind: openai-skills # registry kind (only value for now) - enabled: false # default off; opt-in per user privacy/cost stance - skills: # explicit allowlist of skill dir names to publish - - jobs - - tech-search - # optional: bump strategy when content changed. default: new-version - version_strategy: new-version # new-version | set-default | dry-run-only - # auth: env var name holding the API key. never inline the key. - api_key_env: OPENAI_API_KEY -``` - -Proposed Go struct (in `main.go`, alongside `externalSkillSource`): - -```go -type publishTarget struct { - Name string `yaml:"name"` - Kind string `yaml:"kind"` // "openai-skills" - Enabled bool `yaml:"enabled"` - Skills []string `yaml:"skills"` // allowlist of local skill dir names - VersionStrategy string `yaml:"version_strategy,omitempty"` // default "new-version" - APIKeyEnv string `yaml:"api_key_env,omitempty"` // default "OPENAI_API_KEY" -} -``` - -Add `PublishTargets []publishTarget `yaml:"publish_targets,omitempty"`` to `config`. - -Design choices: -- **Explicit allowlist, no "publish everything".** Prevents accidentally shipping private/experimental skills to a US-only, no-ZDR service. A skill is published only if named here. -- `enabled: false` by default; the command is a no-op until the user opts a target in. -- No `skill_root`/`detect` — this is a push, not a reconcile. - -## 4. Lock schema additions - -Extend `dotagents.lock` to pin published state, mirroring `externalLockEntry`. - -```yaml -version: 1 -external_skills: - - name: skills - ... -published_skills: - - target: openai - skill: jobs - skill_id: skill_abc123 - version: "4" # string: the registry may return non-numeric pointers (e.g. "latest") - content_hash: sha256:... # hash of the bundled skill tree - published_at: 2026-09-12T10:00:00Z -``` - -Proposed Go structs (in `lock.go`): - -```go -type lockFile struct { - Version int `yaml:"version"` - ExternalSkills []externalLockEntry `yaml:"external_skills"` - PublishedSkills []publishedLockEntry `yaml:"published_skills,omitempty"` -} - -type publishedLockEntry struct { - Target string `yaml:"target"` - Skill string `yaml:"skill"` - SkillID string `yaml:"skill_id"` - Version string `yaml:"version"` - ContentHash string `yaml:"content_hash"` - PublishedAt string `yaml:"published_at"` -} -``` - -The `content_hash` is the idempotency key: if a skill's bundled tree hashes to the same value already in the lock, publish is skipped. This is the outward analogue of `externalLockEntry.Commit`. - -## 5. CLI - -New top-level command, flag style matching `sync`: - -```bash -dotagents publish # publish all enabled targets' allowlisted skills that changed -dotagents publish --target openai # limit to one target -dotagents publish --skills jobs,tech-search # limit to named skills -dotagents publish --dry-run # show what would upload; no network writes -dotagents publish --json # machine-readable result -dotagents publish -y # skip the confirmation prompt (CI) -``` - -Default behavior (no flags): for each `enabled` target, diff each allowlisted skill's `content_hash` against the lock; upload only changed/new skills; update the lock with returned `skill_id`+`version`. Unchanged skills print "up to date" and are skipped. - -Placement: new `cmd/dotagents/publish.go` (flat, matching `sync.go`, `mcp.go`, `promote.go`). Register in the command dispatcher next to `sync`/`mcp`. - -## 6. Bundling & validation (enforce API limits before upload) - -Before any network call, build the bundle and validate against documented limits: -- zip ≤ 50 MB, ≤ 500 files, ≤ 25 MB uncompressed; -- exactly one top-level folder in the zip; -- exactly one `SKILL.md` with valid front matter (`name`, `description`). - -Reuse the existing skill-discovery / skillspec validation (`skill_discovery.go`, `skillspec.go`) for the front-matter and structure checks so the same parser gates both local sync and publish. A skill that fails local validation must fail publish with the same error — no divergent validators. - -Bundling: stdlib only — `archive/zip` + `mime/multipart` + `net/http`. **No OpenAI SDK dependency** (a multipart POST does not warrant one; keeps the tree dependency-free and avoids a new external package to age-check). - -## 7. Auth, privacy, cost gates - -- API key read **only** from the env var named by `api_key_env` (default `OPENAI_API_KEY`). Never inline in `dotagents.yaml`, never write it to the lock, never log it. -- Publish is destructive-adjacent (sends skill content to a third party). It therefore: - - requires `enabled: true` per target, - - requires an explicit skill allowlist, - - prompts for confirmation unless `-y`, showing the exact skill names and target, - - refuses if `OPENAI_API_KEY` is unset rather than falling back to any other credential. -- Print a one-line privacy reminder on every real (non-dry-run) publish: skills are uploaded to a US-only, no-ZDR service; do not publish vault- or secret-bearing skills. - -## 8. `status` integration - -`dotagents status` gains a compact "published" section per enabled target: which allowlisted skills are up to date (hash matches lock), drifted (local hash differs → would re-publish), or never published. Mirrors how `status` already reports managed/drifted/missing for local skill roots. Verbose mode lists `skill_id`+`version`. - -## 9. Verification plan (when built) - -- Unit: bundle builder respects file/size/single-folder limits; content_hash is stable and order-independent; validator rejects missing/duplicate `SKILL.md`. -- Idempotency: second `publish` with no local change performs zero network writes and leaves the lock byte-identical. -- Dry-run: `--dry-run` performs no network writes and prints the planned uploads. -- Live smoke (opt-in, one throwaway skill, real key): upload → assert returned `skill_id`+`version` land in the lock → re-run → assert skip. Delete the test skill from the registry afterward. -- No live test touches allowlisted real skills or the private vault. - -## 10. Open questions - -- Does `/v1/skills` support **delete** and **list**? Needed for a `dotagents publish --prune` (remove registry skills no longer in any allowlist) and for `status` to reconcile against the server rather than only the lock. Verify against the current API before building either. -- Version semantics: does uploading identical content create a new version or dedupe server-side? If the server dedupes, the local `content_hash` skip is an optimization, not a correctness requirement. -- `agents/openai.yaml` sidecar (display_name/icons/brand_color/default_prompt): include in the bundle when present, but never hand-maintain — generate on demand via the upstream `generate_openai_yaml.py`. Out of scope for v1; base `SKILL.md` publishes fine without it. -- Should Codex's own `client.beta.agents.sessions` / `capability_directories` path ever be dotagents' concern? Current answer: no — that is the calling application's runtime, not a config-store sync surface. - -## 11. Non-goals / gate - -- Not building this now. It ships only when there is a concrete long-running cloud-agent workload that justifies metered API billing (tokens + tools + container time), separate from the ChatGPT/Codex subscription. -- Not an `agents:` entity (see §2). Do not add `openai-agents` to the harness list. -- Not touching `capability_directories`, sandboxes, or `codex exec-server`. -- Not a new skill; no new runtime dependency; no automation/cron. -``` diff --git a/docs/site/index.html b/docs/site/index.html index 1fac26b..9d543e1 100644 --- a/docs/site/index.html +++ b/docs/site/index.html @@ -396,7 +396,7 @@

How it compares

Agent rolesrendered nativeyesexperimentalno Pinned + audited externalslock file + auditno pinningno pinningtracks source, no pin Harness coverage9 deepbroadbroadnarrow - Installcurl / mise / gonpm / brewnpmnpm + Installbrew / npm / curl / gonpm / brewnpmnpm @@ -404,6 +404,29 @@

How it compares

+ +
+

Documentation

+

Operational detail lives in the repository, next to the code it describes.

+ +
+

Guides

+
+ + + + + + + + + + +
READMEInstall channels, the five synced surfaces, and the command index
SetupFirst-run walkthrough, review screen, multi-machine setup
SkillsAuthoring skills, external pins, plugin discovery
RolesRole format, model tiers, per-harness overrides
MemoryTiers, the rem workflow, vault layout
ComparisonHow dotagents differs from rulesync, ruler, and openskills
TroubleshootingDoctor first, plus the common failure modes
+
+
+
+