mirror of
https://github.com/lukaszraczylo/harness-sync.git
synced 2026-10-12 00:45:22 +00:00
spec: harness-sync v1 design
Canonical config at ~/.config/harness-sync/ (git repo) propagates skills, agents, MCP, LLM endpoints (via profiles), and global instructions into each detected harness. Hybrid sync: symlinks where shapes match, rendered files where they differ. Plugin-style adapters; conflict resolution via git merge-file three-way merge tracked in state/.
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
# harness-sync — design
|
||||
|
||||
**Status:** draft (pending user approval)
|
||||
**Date:** 2026-05-23
|
||||
**Author:** Lukasz Raczylo
|
||||
|
||||
## Problem
|
||||
|
||||
Lukasz uses multiple LLM coding harnesses (claude code, crush, kilo, opencode, goose, cagent, zed, others). Each maintains its own copy of:
|
||||
|
||||
- **Skills** (Claude-style markdown + frontmatter)
|
||||
- **Agents / subagents** (markdown with tool allowlists)
|
||||
- **LLM endpoint and provider configuration** (gateway URL + token + model list)
|
||||
- **MCP server registry**
|
||||
- **Global instructions** (`CLAUDE.md`, `AGENTS.md`, `GEMINI.md`, etc.)
|
||||
|
||||
The duplication causes drift, manual copy-paste maintenance, and inconsistent capabilities across harnesses. Lukasz also runs a personal LLM gateway with a dummy token that fronts multiple upstream providers (anthropic, openai, openrouter, groq, ollama, etc.); harnesses should point at the gateway, not at providers directly.
|
||||
|
||||
## Goal
|
||||
|
||||
A single Go binary, `harness-sync`, that:
|
||||
|
||||
1. Maintains `~/.config/harness-sync/` as the canonical source of truth for all of the above.
|
||||
2. On `apply`, propagates canonical content into each detected harness, using symlinks where formats match and rendered/transformed files where they differ.
|
||||
3. Uses git to track render history and resolve conflicts.
|
||||
4. Supports a plugin-style adapter contract so new harnesses can be added by dropping in a Go package and one registry line.
|
||||
|
||||
Non-goals (v1): watcher daemon, GUI, remote sync (user has git), keychain/Vault backends beyond env-var substitution.
|
||||
|
||||
## Architecture
|
||||
|
||||
Single binary, subcommand CLI. Three layers:
|
||||
|
||||
- **Canonical** — hand-edited YAML + markdown under `~/.config/harness-sync/`.
|
||||
- **Adapters** — Go interface implementations, one Go package per supported harness under `internal/adapters/<name>/`. Each adapter knows its harness's file paths and how to render canonical → native schema.
|
||||
- **State** — last-rendered snapshot tracked in the same git repo, used as merge base for conflict detection.
|
||||
|
||||
Flow per `apply`:
|
||||
|
||||
```
|
||||
canonical (YAML/MD) ──► adapter.Render() ──► rendered_new ──┐
|
||||
├─► 3-way merge ──► target file
|
||||
git HEAD: state/<harness>/<path> ──► rendered_old ──────────┤
|
||||
target filesystem ──► target_current ───────────────────────┘
|
||||
│
|
||||
└─► after write: stage state/, git commit
|
||||
```
|
||||
|
||||
## Canonical layout
|
||||
|
||||
```
|
||||
~/.config/harness-sync/
|
||||
├── config.yaml # global: enabled harnesses, default profile, paths
|
||||
├── profiles/
|
||||
│ ├── home.yaml # gateway URL, dummy token, model allowlist, upstream providers
|
||||
│ ├── work.yaml
|
||||
│ └── offline.yaml # e.g. local ollama only
|
||||
├── instructions/
|
||||
│ ├── global.md # rendered to CLAUDE.md / AGENTS.md / GEMINI.md / opencode AGENTS.md per harness
|
||||
│ └── per-harness/<name>.md # optional override
|
||||
├── skills/ # markdown + frontmatter; claude-code shape is canonical
|
||||
│ └── <skill-name>/SKILL.md
|
||||
├── agents/ # markdown + frontmatter
|
||||
│ └── <agent-name>.md
|
||||
├── mcp.yaml # canonical MCP server registry
|
||||
├── state/ # git-tracked render snapshots — DO NOT EDIT BY HAND
|
||||
│ └── <harness>/...
|
||||
└── backups/ # pre-apply backups; git-ignored
|
||||
```
|
||||
|
||||
`~/.config/harness-sync/` is itself a git repository. The user may push it to a private remote for cross-machine sync.
|
||||
|
||||
## Profile model
|
||||
|
||||
A profile bundles the LLM stack. Harnesses bind to the active profile (named in `config.yaml`). Switching profiles re-renders all harness LLM configs in one command.
|
||||
|
||||
```yaml
|
||||
# profiles/home.yaml
|
||||
name: home
|
||||
gateway:
|
||||
url: https://gateway.lan.example.com
|
||||
token: dummy-local-token # plaintext OK; gateway accepts any non-empty token
|
||||
default_model: claude-sonnet-4-6
|
||||
upstreams: # informational: gateway already knows these; harnesses see only models
|
||||
- name: anthropic
|
||||
api_key: ${ANTHROPIC_API_KEY} # env-var substitution, never plaintext for real keys
|
||||
- name: openai
|
||||
api_key: ${OPENAI_API_KEY}
|
||||
- name: openrouter
|
||||
api_key: ${OPENROUTER_API_KEY}
|
||||
- name: ollama
|
||||
base_url: http://10.0.1.21:11434
|
||||
models: # allowlist exposed to harnesses
|
||||
- id: claude-sonnet-4-6
|
||||
alias: sonnet
|
||||
- id: claude-opus-4-7
|
||||
alias: opus
|
||||
- id: gpt-4o
|
||||
alias: gpt
|
||||
```
|
||||
|
||||
Secret handling: dummy gateway token is plaintext (no value if leaked). Any real provider keys reference env vars via `${VAR}`. `harness-sync apply` substitutes at render time; canonical files never contain plaintext provider keys.
|
||||
|
||||
## Adapter interface
|
||||
|
||||
```go
|
||||
package adapter
|
||||
|
||||
type Adapter interface {
|
||||
Name() string // "crush", "claude-code", ...
|
||||
Detect() bool // true if harness config dir exists on this machine
|
||||
Targets() []Target // declarative list of what gets written where
|
||||
Render(c *Canonical, p *Profile) (FileSet, error)
|
||||
Import(fs Filesystem) (CanonicalDelta, error) // reverse: read native files, produce canonical deltas
|
||||
}
|
||||
|
||||
type Target struct {
|
||||
Kind Kind // SymlinkDir | SymlinkFile | RenderedFile
|
||||
SourcePath string // path within canonical (e.g. "skills/")
|
||||
DestPath string // absolute target path (expanded; e.g. "/home/nvm/.config/crush/prompts")
|
||||
Format Format // JSON | TOML | YAML | Markdown | Raw
|
||||
}
|
||||
|
||||
type FileSet map[string]File // dest path -> rendered file (bytes + perms + symlink target)
|
||||
```
|
||||
|
||||
Each adapter is expected to be roughly 150–300 LOC: declare targets, marshal canonical structs into native schema, handle import.
|
||||
|
||||
Adapters live at `internal/adapters/<name>/` and self-register through a static slice. No process-boundary plugin system — YAGNI.
|
||||
|
||||
### v1 adapter set
|
||||
|
||||
- `claudecode` — `~/.claude/{skills,agents,CLAUDE.md}` + `~/.claude/settings.json` (MCP block)
|
||||
- `crush` — `~/.config/crush/`
|
||||
- `kilo` — `~/.config/kilo/`
|
||||
- `opencode` — `~/.config/opencode/`
|
||||
- `goose` — `~/.config/goose/`
|
||||
- `cagent` — `~/.config/cagent/`
|
||||
- `zed` — `~/.config/zed/`
|
||||
|
||||
Each ships with golden-file tests under `internal/adapters/<name>/testdata/`.
|
||||
|
||||
## Sync mechanics by asset
|
||||
|
||||
| Asset | Mechanism | Reasoning |
|
||||
|---|---|---|
|
||||
| `skills/` | symlink directory into harness's expected location (or render if shape differs) | bulk, frequently edited, identical shape across claude-code-likes |
|
||||
| `agents/` | symlink directory or render to native list file | same |
|
||||
| `profiles/<active>.yaml` → endpoint config | **render** to native schema (per-harness keys, format) | each harness uses different keys (`providers`, `models`, `endpoints`) and serialization |
|
||||
| `mcp.yaml` → harness MCP block | render to native (JSON/YAML/TOML) | shape and embedding location vary |
|
||||
| `instructions/global.md` | render/copy to harness's expected filename and path | identical content, different filename per harness |
|
||||
|
||||
## Commands
|
||||
|
||||
```
|
||||
harness-sync init # first run: scan ~/.config + ~/.claude, detect harnesses,
|
||||
# offer multi-select import, merge+dedupe into canonical,
|
||||
# git init, initial commit
|
||||
harness-sync detect # list detected harnesses and their config paths
|
||||
harness-sync diff [harness] # show pending changes (canonical vs target)
|
||||
harness-sync apply [harness] [--dry-run] [--force]
|
||||
# render + write; default does 3-way merge,
|
||||
# --force overwrites without merge, --dry-run shows plan only
|
||||
harness-sync rollback [n] # git revert N commits in canonical repo, then reapply
|
||||
harness-sync profile use <name> # switch active profile in config.yaml + reapply
|
||||
harness-sync profile list
|
||||
harness-sync adapter list # show registered adapters + detection status
|
||||
```
|
||||
|
||||
Default behavior with no subcommand: print `detect` summary + suggest `diff` or `apply`.
|
||||
|
||||
## Conflict handling
|
||||
|
||||
`apply` performs git-style three-way merge per rendered file:
|
||||
|
||||
1. `rendered_new` = adapter output for this run.
|
||||
2. `rendered_old` = `state/<harness>/<path>` at git HEAD (or empty if first apply).
|
||||
3. `target_current` = current contents at destination (or empty if missing).
|
||||
|
||||
```
|
||||
if target_current == rendered_old:
|
||||
fast-path write rendered_new # clean, no manual edits since last apply
|
||||
elif target_current == rendered_new:
|
||||
skip # already in sync
|
||||
else:
|
||||
git merge-file rendered_new rendered_old target_current
|
||||
on clean merge: write result
|
||||
on conflict: write .rej file alongside target, skip, accumulate report
|
||||
```
|
||||
|
||||
After all adapters run successfully, stage and commit the new `state/` tree:
|
||||
|
||||
```
|
||||
git add state/ && git commit -m "apply: <harnesses> profile=<name>"
|
||||
```
|
||||
|
||||
Rollback: `git revert` on the canonical repo, then `apply` again to push the reverted state outward.
|
||||
|
||||
Symlink conflicts: if target should be a symlink and is currently a regular file (or a symlink elsewhere), move it to `backups/<harness>/<timestamp>/` and replace with the correct symlink.
|
||||
|
||||
## Import flow (first run)
|
||||
|
||||
`harness-sync init` on empty `~/.config/harness-sync/`:
|
||||
|
||||
1. Scan `~/.config/*` and `~/.claude/` for known harness layouts (using each adapter's `Detect()`).
|
||||
2. Print discovered harnesses with checkboxes:
|
||||
```
|
||||
Found harnesses. Import from which?
|
||||
[x] claude-code (~/.claude/)
|
||||
[x] crush (~/.config/crush/)
|
||||
[x] kilo (~/.config/kilo/)
|
||||
[ ] cagent (~/.config/cagent/)
|
||||
...
|
||||
```
|
||||
3. For each selected harness, invoke `adapter.Import()` which reads native files and yields a `CanonicalDelta`.
|
||||
4. Merge deltas:
|
||||
- **skills/agents:** dedupe by filename + content hash. Identical → keep one. Divergent → write all variants as `<name>.from-<harness>.md` and emit a TODO for the user to resolve.
|
||||
- **profiles:** create one `imported.yaml` aggregating discovered endpoints; user edits later.
|
||||
- **mcp.yaml:** union by server name; conflicts on same name go into a `# CONFLICT:` block.
|
||||
- **instructions:** concatenate sourced files into `instructions/imported/<harness>.md`; user later promotes a unified version to `global.md`.
|
||||
5. `git init && git add -A && git commit -m "import from {harnesses}"`.
|
||||
|
||||
After init the user has a fully populated, version-controlled canonical tree that they then prune and curate.
|
||||
|
||||
## Error handling
|
||||
|
||||
- Errors wrapped with adapter + operation context: `fmt.Errorf("adapter %s: render %s: %w", ...)`.
|
||||
- `apply` is atomic per-file but not across the run: a fatal error in one adapter does not roll back files already written by previous adapters; the run report lists all writes and failures.
|
||||
- On partial failure, the `state/` commit is **not** created — next run will re-attempt and detect divergence cleanly.
|
||||
- Symlink writes go through `os.Symlink` + atomic rename via tempfile to avoid races.
|
||||
|
||||
## Testing strategy
|
||||
|
||||
- **Unit tests per adapter**: table-driven, with `testdata/` golden fixtures. Input: canonical struct + profile. Output: expected rendered files. Diffs surface on regenerate.
|
||||
- **Integration test**: build the binary, run against a tempdir HOME populated with fake harness configs, assert `init → apply → diff` is empty.
|
||||
- **Merge tests**: cover the three-way merge logic with synthetic `rendered_old`, `rendered_new`, `target_current` permutations.
|
||||
|
||||
## Logging and observability
|
||||
|
||||
- Default output: one line per harness with summary counts (`crush: 12 files synced, 0 conflicts`).
|
||||
- `--verbose`: per-file actions.
|
||||
- `--quiet`: errors only.
|
||||
- No log file; structured logs to stderr if `HARNESS_SYNC_LOG_JSON=1`.
|
||||
|
||||
## Module layout
|
||||
|
||||
```
|
||||
harness-sync/
|
||||
├── cmd/harness-sync/main.go # cobra/cli entrypoint
|
||||
├── internal/
|
||||
│ ├── canonical/ # canonical struct types + loaders
|
||||
│ ├── adapter/ # Adapter interface, registry, common helpers
|
||||
│ ├── adapters/
|
||||
│ │ ├── claudecode/
|
||||
│ │ ├── crush/
|
||||
│ │ ├── kilo/
|
||||
│ │ ├── opencode/
|
||||
│ │ ├── goose/
|
||||
│ │ ├── cagent/
|
||||
│ │ └── zed/
|
||||
│ ├── merge/ # 3-way merge wrapper around git merge-file
|
||||
│ ├── git/ # thin wrapper over `git` CLI invocation
|
||||
│ ├── render/ # format-specific marshallers (JSON/TOML/YAML)
|
||||
│ ├── secrets/ # env-var substitution
|
||||
│ └── ui/ # interactive prompts (init multi-select)
|
||||
├── docs/superpowers/specs/2026-05-23-harness-sync-design.md
|
||||
├── go.mod
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Open questions / decisions to confirm
|
||||
|
||||
- **CLI library:** `spf13/cobra` (familiar, batteries included) vs `urfave/cli` (lighter). Recommend cobra.
|
||||
- **Interactive prompts:** `charmbracelet/huh` or `AlecAivazis/survey`. Recommend huh (aligns with crush's stack).
|
||||
- **YAML library:** `goccy/go-yaml` (preserves comments) vs `gopkg.in/yaml.v3`. Recommend goccy since canonical YAML is hand-edited and comments matter.
|
||||
- **Adapter discovery:** static registry slice (decided: yes; simple, fast).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
1. `harness-sync init` on a fresh machine with several harness configs produces a populated, git-committed canonical tree without data loss.
|
||||
2. `harness-sync apply` renders correct native files into each detected harness; subsequent `harness-sync diff` is empty.
|
||||
3. After hand-editing a synced file, the next `apply` detects the divergence and either three-way-merges cleanly or writes a `.rej` and exits non-zero.
|
||||
4. `harness-sync profile use work` re-renders LLM endpoint config in every harness atomically.
|
||||
5. Adding a new harness requires only a new package under `internal/adapters/` and one line in the registry; existing adapters untouched.
|
||||
6. All adapters have golden-file tests; integration test covers the init → apply → diff cycle.
|
||||
Reference in New Issue
Block a user