Files
harness-sync/docs/superpowers/plans/2026-05-23-harness-sync.md
T
lukaszraczylo 265263e0a9 plan: harness-sync implementation plan
26 bite-sized tasks across 7 phases (bootstrap, adapter framework,
render/git/merge, apply pipeline, per-harness adapters, CLI commands,
integration test + polish). TDD throughout, frequent commits, exact
code in every step. Adapter-specific schema steps include inspection
commands and reference actual harness configs on this machine.
2026-05-23 02:36:40 +01:00

100 KiB
Raw Blame History

harness-sync Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Single Go binary that maintains ~/.config/harness-sync/ as a canonical, git-versioned source of truth and propagates skills, agents, MCP, LLM endpoints (via profiles), and global instructions into multiple LLM harnesses with three-way-merge conflict resolution.

Architecture: Cobra CLI; canonical YAML+markdown in a git repo; plugin-style adapters per harness (Go interface + static registry); hybrid sync (symlinks for skills/agents, rendered files for LLM/MCP/instructions); conflict resolution via git merge-file.

Tech Stack: Go 1.22+, cobra (CLI), huh (interactive prompts), goccy/go-yaml (YAML preserving comments), pelletier/go-toml/v2, encoding/json, stretchr/testify (tests).

Spec: docs/superpowers/specs/2026-05-23-harness-sync-design.md


File Structure

harness-sync/
├── cmd/harness-sync/main.go              # cobra entrypoint
├── internal/
│   ├── canonical/                        # canonical struct types + loaders
│   │   ├── types.go
│   │   ├── load.go
│   │   └── load_test.go
│   ├── secrets/                          # ${ENV_VAR} substitution
│   │   ├── envsub.go
│   │   └── envsub_test.go
│   ├── render/                           # format-specific marshallers
│   │   ├── json.go
│   │   ├── yaml.go
│   │   ├── toml.go
│   │   └── render_test.go
│   ├── gitx/                             # thin wrapper over `git` CLI
│   │   ├── repo.go
│   │   └── repo_test.go
│   ├── merge/                            # 3-way merge using git merge-file
│   │   ├── merge.go
│   │   └── merge_test.go
│   ├── adapter/                          # Adapter interface, registry, base helpers
│   │   ├── adapter.go
│   │   ├── registry.go
│   │   ├── fileset.go
│   │   └── adapter_test.go
│   ├── adapters/
│   │   ├── claudecode/
│   │   ├── crush/
│   │   ├── kilo/
│   │   ├── opencode/
│   │   ├── goose/
│   │   ├── cagent/
│   │   └── zed/
│   ├── ui/                               # interactive prompts
│   │   ├── prompts.go
│   │   └── prompts_test.go
│   ├── apply/                            # apply pipeline (orchestration)
│   │   ├── apply.go
│   │   └── apply_test.go
│   └── cli/                              # cobra subcommands
│       ├── root.go
│       ├── init.go
│       ├── detect.go
│       ├── diff.go
│       ├── apply.go
│       ├── rollback.go
│       ├── profile.go
│       └── adapter.go
├── docs/superpowers/specs/2026-05-23-harness-sync-design.md
├── docs/superpowers/plans/2026-05-23-harness-sync.md
├── go.mod
├── Makefile
└── README.md

Each file under internal/adapters/<name>/ follows the pattern:

internal/adapters/<name>/
├── <name>.go                             # Adapter impl (Detect, Targets, Render, Import)
├── <name>_test.go                        # golden-file tests
└── testdata/
    ├── canonical_minimal/                # canonical input fixtures
    └── expected/                         # golden outputs

Phase 1: Foundations

Task 1: Bootstrap module

Files:

  • Create: go.mod

  • Create: Makefile

  • Create: README.md

  • Create: cmd/harness-sync/main.go

  • Step 1: Initialise go module

go mod init github.com/lukaszraczylo/harness-sync
go get github.com/spf13/[email protected]
go get github.com/charmbracelet/[email protected]
go get github.com/goccy/[email protected]
go get github.com/pelletier/go-toml/[email protected]
go get github.com/stretchr/[email protected]
  • Step 2: Write entrypoint stub

cmd/harness-sync/main.go:

package main

import (
    "fmt"
    "os"

    "github.com/lukaszraczylo/harness-sync/internal/cli"
)

var version = "dev"

func main() {
    if err := cli.NewRoot(version).Execute(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}
  • Step 3: Write minimal cli.NewRoot to keep build green

internal/cli/root.go:

package cli

import "github.com/spf13/cobra"

func NewRoot(version string) *cobra.Command {
    return &cobra.Command{
        Use:     "harness-sync",
        Short:   "Sync skills, agents, MCP, and LLM endpoints across LLM harnesses",
        Version: version,
        SilenceUsage: true,
    }
}
  • Step 4: Write Makefile
.PHONY: build test lint clean install

BIN := harness-sync
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
LDFLAGS := -ldflags "-X main.version=$(VERSION)"

build:
	go build $(LDFLAGS) -o $(BIN) ./cmd/harness-sync

test:
	go test ./...

lint:
	go vet ./...

install: build
	install -m 0755 $(BIN) $$HOME/.local/bin/$(BIN)

clean:
	rm -f $(BIN)
  • Step 5: Write README skeleton

README.md:

# harness-sync

Sync skills, agents, MCP, and LLM endpoints across multiple LLM harnesses
(claude-code, crush, kilo, opencode, goose, cagent, zed).

Canonical config lives in `~/.config/harness-sync/` (git-tracked). Run
`harness-sync apply` to propagate.

See `docs/superpowers/specs/2026-05-23-harness-sync-design.md` for design.

## Build

    make build && make install
  • Step 6: Verify build + run

Run: make build && ./harness-sync --version Expected: prints harness-sync version dev (or similar)

  • Step 7: Commit
git add go.mod go.sum cmd/harness-sync internal/cli/root.go Makefile README.md
git commit -m "feat: bootstrap module and cobra root command"

Task 2: Canonical types

Files:

  • Create: internal/canonical/types.go

  • Create: internal/canonical/types_test.go

  • Step 1: Write the failing test

internal/canonical/types_test.go:

package canonical

import (
    "testing"

    "github.com/stretchr/testify/assert"
)

func TestProfileActiveModel(t *testing.T) {
    p := &Profile{
        Name: "home",
        Gateway: Gateway{
            URL:          "https://gw",
            Token:        "dummy",
            DefaultModel: "sonnet",
        },
        Models: []Model{
            {ID: "claude-sonnet-4-6", Alias: "sonnet"},
            {ID: "claude-opus-4-7", Alias: "opus"},
        },
    }
    m, ok := p.LookupModel("sonnet")
    assert.True(t, ok)
    assert.Equal(t, "claude-sonnet-4-6", m.ID)

    _, ok = p.LookupModel("missing")
    assert.False(t, ok)
}
  • Step 2: Run test to verify it fails

Run: go test ./internal/canonical/... Expected: FAIL with undefined: Profile

  • Step 3: Implement types

internal/canonical/types.go:

package canonical

type Config struct {
    EnabledHarnesses []string `yaml:"enabled_harnesses"`
    ActiveProfile   string    `yaml:"active_profile"`
    Paths           Paths     `yaml:"paths,omitempty"`
}

type Paths struct {
    Skills       string `yaml:"skills,omitempty"`       // override default ./skills
    Agents       string `yaml:"agents,omitempty"`
    Instructions string `yaml:"instructions,omitempty"`
}

type Profile struct {
    Name      string     `yaml:"name"`
    Gateway   Gateway    `yaml:"gateway"`
    Upstreams []Upstream `yaml:"upstreams,omitempty"`
    Models    []Model    `yaml:"models"`
}

type Gateway struct {
    URL          string `yaml:"url"`
    Token        string `yaml:"token"`
    DefaultModel string `yaml:"default_model"`
}

type Upstream struct {
    Name    string `yaml:"name"`
    APIKey  string `yaml:"api_key,omitempty"`
    BaseURL string `yaml:"base_url,omitempty"`
}

type Model struct {
    ID    string `yaml:"id"`
    Alias string `yaml:"alias,omitempty"`
}

type MCPRegistry struct {
    Servers []MCPServer `yaml:"servers"`
}

type MCPServer struct {
    Name      string            `yaml:"name"`
    Command   string            `yaml:"command,omitempty"`
    Args      []string          `yaml:"args,omitempty"`
    URL       string            `yaml:"url,omitempty"`
    Transport string            `yaml:"transport,omitempty"` // stdio | http | sse
    Env       map[string]string `yaml:"env,omitempty"`
}

type Skill struct {
    Name        string
    Description string
    Body        string // raw markdown including frontmatter
    Path        string // canonical relative path, e.g. "skills/foo/SKILL.md"
}

type Agent struct {
    Name        string
    Description string
    Body        string
    Path        string
}

type Instructions struct {
    Global      string            // markdown body
    PerHarness  map[string]string // harness name -> body
}

type Bundle struct {
    Config       Config
    Profile      Profile
    MCP          MCPRegistry
    Skills       []Skill
    Agents       []Agent
    Instructions Instructions
    Root         string // absolute path to canonical root
}

func (p *Profile) LookupModel(alias string) (Model, bool) {
    for _, m := range p.Models {
        if m.Alias == alias || m.ID == alias {
            return m, true
        }
    }
    return Model{}, false
}
  • Step 4: Run test to verify it passes

Run: go test ./internal/canonical/... Expected: PASS

  • Step 5: Commit
git add internal/canonical
git commit -m "feat(canonical): introduce core domain types"

Task 3: Canonical loader

Files:

  • Create: internal/canonical/load.go

  • Create: internal/canonical/load_test.go

  • Create: internal/canonical/testdata/sample/...

  • Step 1: Create test fixture tree

mkdir -p internal/canonical/testdata/sample/profiles
mkdir -p internal/canonical/testdata/sample/skills/hello
mkdir -p internal/canonical/testdata/sample/agents
mkdir -p internal/canonical/testdata/sample/instructions

Write internal/canonical/testdata/sample/config.yaml:

enabled_harnesses: [claude-code, crush]
active_profile: home

Write internal/canonical/testdata/sample/profiles/home.yaml:

name: home
gateway:
  url: https://gw.lan
  token: dummy
  default_model: sonnet
models:
  - id: claude-sonnet-4-6
    alias: sonnet

Write internal/canonical/testdata/sample/mcp.yaml:

servers:
  - name: filepuff
    command: /usr/local/bin/filepuff
    transport: stdio

Write internal/canonical/testdata/sample/skills/hello/SKILL.md:

---
name: hello
description: example skill
---
hello body

Write internal/canonical/testdata/sample/agents/sample.md:

---
name: sample
description: sample agent
---
sample body

Write internal/canonical/testdata/sample/instructions/global.md:

# Global instructions

Be helpful.
  • Step 2: Write the failing test

internal/canonical/load_test.go:

package canonical

import (
    "testing"

    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func TestLoad(t *testing.T) {
    b, err := Load("testdata/sample")
    require.NoError(t, err)

    assert.Equal(t, "home", b.Config.ActiveProfile)
    assert.Equal(t, []string{"claude-code", "crush"}, b.Config.EnabledHarnesses)

    assert.Equal(t, "home", b.Profile.Name)
    assert.Equal(t, "https://gw.lan", b.Profile.Gateway.URL)

    require.Len(t, b.MCP.Servers, 1)
    assert.Equal(t, "filepuff", b.MCP.Servers[0].Name)

    require.Len(t, b.Skills, 1)
    assert.Equal(t, "hello", b.Skills[0].Name)

    require.Len(t, b.Agents, 1)
    assert.Equal(t, "sample", b.Agents[0].Name)

    assert.Contains(t, b.Instructions.Global, "Be helpful.")
}

func TestLoadMissingProfile(t *testing.T) {
    _, err := Load("testdata/missing")
    assert.Error(t, err)
}
  • Step 3: Run test to verify it fails

Run: go test ./internal/canonical/... Expected: FAIL with undefined: Load

  • Step 4: Implement loader

internal/canonical/load.go:

package canonical

import (
    "fmt"
    "os"
    "path/filepath"
    "strings"

    "github.com/goccy/go-yaml"
)

func Load(root string) (*Bundle, error) {
    info, err := os.Stat(root)
    if err != nil {
        return nil, fmt.Errorf("canonical root %s: %w", root, err)
    }
    if !info.IsDir() {
        return nil, fmt.Errorf("canonical root %s is not a directory", root)
    }

    b := &Bundle{Root: root}

    if err := loadYAML(filepath.Join(root, "config.yaml"), &b.Config); err != nil {
        return nil, err
    }
    if b.Config.ActiveProfile == "" {
        return nil, fmt.Errorf("config.yaml: active_profile is required")
    }

    profPath := filepath.Join(root, "profiles", b.Config.ActiveProfile+".yaml")
    if err := loadYAML(profPath, &b.Profile); err != nil {
        return nil, err
    }

    mcpPath := filepath.Join(root, "mcp.yaml")
    if fileExists(mcpPath) {
        if err := loadYAML(mcpPath, &b.MCP); err != nil {
            return nil, err
        }
    }

    skills, err := loadMarkdownDir(filepath.Join(root, "skills"), "SKILL.md")
    if err != nil {
        return nil, err
    }
    for _, m := range skills {
        b.Skills = append(b.Skills, Skill{
            Name:        m.Name,
            Description: m.Description,
            Body:        m.Body,
            Path:        m.Path,
        })
    }

    agents, err := loadMarkdownDir(filepath.Join(root, "agents"), "")
    if err != nil {
        return nil, err
    }
    for _, m := range agents {
        b.Agents = append(b.Agents, Agent{
            Name:        m.Name,
            Description: m.Description,
            Body:        m.Body,
            Path:        m.Path,
        })
    }

    b.Instructions.PerHarness = map[string]string{}
    if body, err := readFileIfExists(filepath.Join(root, "instructions", "global.md")); err != nil {
        return nil, err
    } else {
        b.Instructions.Global = body
    }

    perHarnessDir := filepath.Join(root, "instructions", "per-harness")
    if entries, err := os.ReadDir(perHarnessDir); err == nil {
        for _, e := range entries {
            if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
                continue
            }
            body, err := os.ReadFile(filepath.Join(perHarnessDir, e.Name()))
            if err != nil {
                return nil, err
            }
            harness := strings.TrimSuffix(e.Name(), ".md")
            b.Instructions.PerHarness[harness] = string(body)
        }
    }

    return b, nil
}

type markdownDoc struct {
    Name        string
    Description string
    Body        string
    Path        string
}

func loadMarkdownDir(dir, requiredFilename string) ([]markdownDoc, error) {
    var docs []markdownDoc
    if !dirExists(dir) {
        return docs, nil
    }
    err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
        if err != nil {
            return err
        }
        if info.IsDir() {
            return nil
        }
        if requiredFilename != "" && filepath.Base(path) != requiredFilename {
            return nil
        }
        if requiredFilename == "" && !strings.HasSuffix(path, ".md") {
            return nil
        }
        body, err := os.ReadFile(path)
        if err != nil {
            return err
        }
        rel, _ := filepath.Rel(dir, path)
        name, desc := parseFrontmatter(body)
        if name == "" {
            name = strings.TrimSuffix(filepath.Base(filepath.Dir(rel)+"/"+filepath.Base(rel)), ".md")
        }
        docs = append(docs, markdownDoc{
            Name:        name,
            Description: desc,
            Body:        string(body),
            Path:        rel,
        })
        return nil
    })
    return docs, err
}

func parseFrontmatter(b []byte) (name, description string) {
    s := string(b)
    if !strings.HasPrefix(s, "---\n") {
        return "", ""
    }
    end := strings.Index(s[4:], "\n---")
    if end == -1 {
        return "", ""
    }
    fm := s[4 : 4+end]
    var meta struct {
        Name        string `yaml:"name"`
        Description string `yaml:"description"`
    }
    _ = yaml.Unmarshal([]byte(fm), &meta)
    return meta.Name, meta.Description
}

func loadYAML(path string, out interface{}) error {
    b, err := os.ReadFile(path)
    if err != nil {
        return fmt.Errorf("read %s: %w", path, err)
    }
    if err := yaml.Unmarshal(b, out); err != nil {
        return fmt.Errorf("parse %s: %w", path, err)
    }
    return nil
}

func readFileIfExists(path string) (string, error) {
    b, err := os.ReadFile(path)
    if os.IsNotExist(err) {
        return "", nil
    }
    if err != nil {
        return "", err
    }
    return string(b), nil
}

func fileExists(path string) bool {
    info, err := os.Stat(path)
    return err == nil && !info.IsDir()
}

func dirExists(path string) bool {
    info, err := os.Stat(path)
    return err == nil && info.IsDir()
}
  • Step 5: Run tests to verify they pass

Run: go test ./internal/canonical/... Expected: PASS

  • Step 6: Commit
git add internal/canonical
git commit -m "feat(canonical): load YAML/markdown into Bundle"

Task 4: Secrets / env-var substitution

Files:

  • Create: internal/secrets/envsub.go

  • Create: internal/secrets/envsub_test.go

  • Step 1: Write the failing test

internal/secrets/envsub_test.go:

package secrets

import (
    "testing"

    "github.com/stretchr/testify/assert"
)

func TestSubstituteSimple(t *testing.T) {
    lookup := func(k string) (string, bool) {
        if k == "FOO" {
            return "bar", true
        }
        return "", false
    }
    out, err := Substitute("hello ${FOO}", lookup)
    assert.NoError(t, err)
    assert.Equal(t, "hello bar", out)
}

func TestSubstituteMissingStrict(t *testing.T) {
    lookup := func(k string) (string, bool) { return "", false }
    _, err := Substitute("${MISSING}", lookup)
    assert.Error(t, err)
}

func TestSubstituteEscaped(t *testing.T) {
    lookup := func(k string) (string, bool) { return "should-not-see", true }
    out, err := Substitute("$${LITERAL}", lookup)
    assert.NoError(t, err)
    assert.Equal(t, "${LITERAL}", out)
}
  • Step 2: Run test to verify it fails

Run: go test ./internal/secrets/... Expected: FAIL with undefined: Substitute

  • Step 3: Implement substitution

internal/secrets/envsub.go:

package secrets

import (
    "fmt"
    "os"
    "strings"
)

// Lookup resolves a placeholder name to a value. Returns false if missing.
type Lookup func(name string) (string, bool)

// OSEnv resolves via os.LookupEnv.
func OSEnv(name string) (string, bool) {
    return os.LookupEnv(name)
}

// Substitute replaces ${NAME} placeholders in s using lookup. Escape with $$.
// Missing keys produce an error so secrets are never silently empty.
func Substitute(s string, lookup Lookup) (string, error) {
    var b strings.Builder
    i := 0
    for i < len(s) {
        if i+1 < len(s) && s[i] == '$' && s[i+1] == '$' {
            b.WriteByte('$')
            i += 2
            continue
        }
        if i+1 < len(s) && s[i] == '$' && s[i+1] == '{' {
            end := strings.IndexByte(s[i+2:], '}')
            if end == -1 {
                return "", fmt.Errorf("unterminated placeholder at offset %d", i)
            }
            name := s[i+2 : i+2+end]
            val, ok := lookup(name)
            if !ok {
                return "", fmt.Errorf("missing env var %q", name)
            }
            b.WriteString(val)
            i += 2 + end + 1
            continue
        }
        b.WriteByte(s[i])
        i++
    }
    return b.String(), nil
}
  • Step 4: Run tests to verify they pass

Run: go test ./internal/secrets/... Expected: PASS

  • Step 5: Commit
git add internal/secrets
git commit -m "feat(secrets): ${VAR} substitution with strict missing-key error"

Phase 2: Adapter framework

Task 5: Adapter interface + FileSet

Files:

  • Create: internal/adapter/adapter.go

  • Create: internal/adapter/fileset.go

  • Create: internal/adapter/adapter_test.go

  • Step 1: Write the failing test

internal/adapter/adapter_test.go:

package adapter

import (
    "testing"

    "github.com/stretchr/testify/assert"
)

func TestFileSetAddAndIterate(t *testing.T) {
    fs := NewFileSet()
    fs.Add(File{Dest: "/tmp/a", Kind: RenderedFile, Content: []byte("a")})
    fs.Add(File{Dest: "/tmp/b", Kind: SymlinkFile, SymlinkTarget: "/canon/b"})

    seen := map[string]Kind{}
    fs.ForEach(func(f File) {
        seen[f.Dest] = f.Kind
    })
    assert.Equal(t, RenderedFile, seen["/tmp/a"])
    assert.Equal(t, SymlinkFile, seen["/tmp/b"])
}

func TestKindString(t *testing.T) {
    assert.Equal(t, "rendered", RenderedFile.String())
    assert.Equal(t, "symlink-file", SymlinkFile.String())
}
  • Step 2: Run test to verify it fails

Run: go test ./internal/adapter/... Expected: FAIL with undefined: NewFileSet

  • Step 3: Implement adapter interface + fileset

internal/adapter/adapter.go:

package adapter

import "github.com/lukaszraczylo/harness-sync/internal/canonical"

type Kind int

const (
    RenderedFile Kind = iota
    SymlinkFile
    SymlinkDir
)

func (k Kind) String() string {
    switch k {
    case RenderedFile:
        return "rendered"
    case SymlinkFile:
        return "symlink-file"
    case SymlinkDir:
        return "symlink-dir"
    }
    return "unknown"
}

type Adapter interface {
    Name() string
    Detect() bool
    Render(b *canonical.Bundle) (*FileSet, error)
    Import(home string) (*ImportResult, error)
}

type ImportResult struct {
    Skills       []canonical.Skill
    Agents       []canonical.Agent
    MCP          []canonical.MCPServer
    Profiles     []canonical.Profile
    Instructions string // raw instruction file body (e.g. CLAUDE.md)
}

internal/adapter/fileset.go:

package adapter

import "os"

type File struct {
    Dest          string
    Kind          Kind
    Content       []byte
    SymlinkTarget string
    Mode          os.FileMode
}

type FileSet struct {
    files []File
}

func NewFileSet() *FileSet { return &FileSet{} }

func (fs *FileSet) Add(f File) {
    if f.Mode == 0 && f.Kind == RenderedFile {
        f.Mode = 0o644
    }
    fs.files = append(fs.files, f)
}

func (fs *FileSet) ForEach(fn func(File)) {
    for _, f := range fs.files {
        fn(f)
    }
}

func (fs *FileSet) Len() int { return len(fs.files) }
  • Step 4: Run tests to verify they pass

Run: go test ./internal/adapter/... Expected: PASS

  • Step 5: Commit
git add internal/adapter
git commit -m "feat(adapter): define Adapter interface and FileSet"

Task 6: Adapter registry

Files:

  • Create: internal/adapter/registry.go

  • Modify: internal/adapter/adapter_test.go (extend)

  • Step 1: Extend tests with a fake adapter

Append to internal/adapter/adapter_test.go:

type fakeAdapter struct{ name string; detect bool }

func (f *fakeAdapter) Name() string                                  { return f.name }
func (f *fakeAdapter) Detect() bool                                  { return f.detect }
func (f *fakeAdapter) Render(_ *canonical.Bundle) (*FileSet, error)  { return NewFileSet(), nil }
func (f *fakeAdapter) Import(_ string) (*ImportResult, error)        { return &ImportResult{}, nil }

func TestRegistry(t *testing.T) {
    r := NewRegistry()
    a := &fakeAdapter{name: "a", detect: true}
    b := &fakeAdapter{name: "b", detect: false}
    r.Register(a)
    r.Register(b)

    assert.Equal(t, []string{"a", "b"}, r.Names())
    assert.Equal(t, []string{"a"}, r.DetectedNames())

    got, ok := r.Get("a")
    assert.True(t, ok)
    assert.Equal(t, "a", got.Name())

    _, ok = r.Get("missing")
    assert.False(t, ok)
}

func TestRegistryDuplicatePanics(t *testing.T) {
    r := NewRegistry()
    r.Register(&fakeAdapter{name: "x"})
    assert.Panics(t, func() { r.Register(&fakeAdapter{name: "x"}) })
}

Add import "github.com/lukaszraczylo/harness-sync/internal/canonical" to test imports if not present.

  • Step 2: Run test to verify it fails

Run: go test ./internal/adapter/... Expected: FAIL with undefined: NewRegistry

  • Step 3: Implement registry

internal/adapter/registry.go:

package adapter

import "fmt"

type Registry struct {
    order []string
    byName map[string]Adapter
}

func NewRegistry() *Registry {
    return &Registry{byName: map[string]Adapter{}}
}

func (r *Registry) Register(a Adapter) {
    if _, exists := r.byName[a.Name()]; exists {
        panic(fmt.Sprintf("adapter %q already registered", a.Name()))
    }
    r.byName[a.Name()] = a
    r.order = append(r.order, a.Name())
}

func (r *Registry) Names() []string {
    out := make([]string, len(r.order))
    copy(out, r.order)
    return out
}

func (r *Registry) DetectedNames() []string {
    var out []string
    for _, n := range r.order {
        if r.byName[n].Detect() {
            out = append(out, n)
        }
    }
    return out
}

func (r *Registry) Get(name string) (Adapter, bool) {
    a, ok := r.byName[name]
    return a, ok
}

func (r *Registry) All() []Adapter {
    out := make([]Adapter, 0, len(r.order))
    for _, n := range r.order {
        out = append(out, r.byName[n])
    }
    return out
}

// Default is the process-global registry used by main(). Tests should
// construct their own Registry via NewRegistry to avoid global state.
var Default = NewRegistry()
  • Step 4: Run tests to verify they pass

Run: go test ./internal/adapter/... Expected: PASS

  • Step 5: Commit
git add internal/adapter
git commit -m "feat(adapter): registry with detect filtering and duplicate guard"

Phase 3: Render, git, merge

Task 7: Render helpers (JSON / YAML / TOML)

Files:

  • Create: internal/render/json.go

  • Create: internal/render/yaml.go

  • Create: internal/render/toml.go

  • Create: internal/render/render_test.go

  • Step 1: Write the failing test

internal/render/render_test.go:

package render

import (
    "testing"

    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func TestJSONIndent(t *testing.T) {
    b, err := JSON(map[string]any{"a": 1, "b": "x"})
    require.NoError(t, err)
    s := string(b)
    assert.Contains(t, s, "\"a\": 1")
    assert.True(t, s[len(s)-1] == '\n', "trailing newline required")
}

func TestYAML(t *testing.T) {
    b, err := YAML(map[string]any{"a": 1})
    require.NoError(t, err)
    assert.Contains(t, string(b), "a: 1")
}

func TestTOML(t *testing.T) {
    b, err := TOML(map[string]any{"a": 1})
    require.NoError(t, err)
    assert.Contains(t, string(b), "a = 1")
}
  • Step 2: Run test to verify it fails

Run: go test ./internal/render/... Expected: FAIL with undefined: JSON

  • Step 3: Implement renderers

internal/render/json.go:

package render

import (
    "bytes"
    "encoding/json"
)

func JSON(v any) ([]byte, error) {
    var buf bytes.Buffer
    enc := json.NewEncoder(&buf)
    enc.SetIndent("", "  ")
    enc.SetEscapeHTML(false)
    if err := enc.Encode(v); err != nil {
        return nil, err
    }
    return buf.Bytes(), nil
}

internal/render/yaml.go:

package render

import "github.com/goccy/go-yaml"

func YAML(v any) ([]byte, error) {
    return yaml.MarshalWithOptions(v, yaml.Indent(2))
}

internal/render/toml.go:

package render

import "github.com/pelletier/go-toml/v2"

func TOML(v any) ([]byte, error) {
    return toml.Marshal(v)
}
  • Step 4: Run tests to verify they pass

Run: go test ./internal/render/... Expected: PASS

  • Step 5: Commit
git add internal/render
git commit -m "feat(render): JSON/YAML/TOML marshallers with deterministic formatting"

Task 8: Git CLI wrapper

Files:

  • Create: internal/gitx/repo.go

  • Create: internal/gitx/repo_test.go

  • Step 1: Write the failing test

internal/gitx/repo_test.go:

package gitx

import (
    "os"
    "path/filepath"
    "testing"

    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func TestInitAddCommit(t *testing.T) {
    dir := t.TempDir()
    r := New(dir)

    require.NoError(t, r.Init())
    require.NoError(t, r.Configure("nobody", "[email protected]"))

    require.NoError(t, os.WriteFile(filepath.Join(dir, "a.txt"), []byte("hi"), 0o644))
    require.NoError(t, r.AddAll())
    require.NoError(t, r.Commit("initial"))

    head, err := r.HeadCommit()
    require.NoError(t, err)
    assert.NotEmpty(t, head)
}

func TestShowFileAtHead(t *testing.T) {
    dir := t.TempDir()
    r := New(dir)
    require.NoError(t, r.Init())
    require.NoError(t, r.Configure("nobody", "[email protected]"))
    require.NoError(t, os.WriteFile(filepath.Join(dir, "a.txt"), []byte("v1"), 0o644))
    require.NoError(t, r.AddAll())
    require.NoError(t, r.Commit("v1"))

    body, err := r.ShowFileAtHead("a.txt")
    require.NoError(t, err)
    assert.Equal(t, "v1", string(body))

    _, err = r.ShowFileAtHead("nope.txt")
    assert.Error(t, err)
}
  • Step 2: Run test to verify it fails

Run: go test ./internal/gitx/... Expected: FAIL with undefined: New

  • Step 3: Implement git wrapper

internal/gitx/repo.go:

package gitx

import (
    "bytes"
    "fmt"
    "os/exec"
)

type Repo struct {
    Dir string
}

func New(dir string) *Repo { return &Repo{Dir: dir} }

func (r *Repo) run(args ...string) ([]byte, error) {
    cmd := exec.Command("git", args...)
    cmd.Dir = r.Dir
    var stdout, stderr bytes.Buffer
    cmd.Stdout = &stdout
    cmd.Stderr = &stderr
    if err := cmd.Run(); err != nil {
        return nil, fmt.Errorf("git %v: %w: %s", args, err, stderr.String())
    }
    return stdout.Bytes(), nil
}

func (r *Repo) Init() error {
    _, err := r.run("init", "-q", "-b", "main")
    return err
}

func (r *Repo) Configure(name, email string) error {
    if _, err := r.run("config", "user.name", name); err != nil {
        return err
    }
    _, err := r.run("config", "user.email", email)
    return err
}

func (r *Repo) AddAll() error {
    _, err := r.run("add", "-A")
    return err
}

func (r *Repo) Add(paths ...string) error {
    args := append([]string{"add", "--"}, paths...)
    _, err := r.run(args...)
    return err
}

func (r *Repo) Commit(msg string) error {
    _, err := r.run("commit", "-q", "-m", msg)
    return err
}

func (r *Repo) HeadCommit() (string, error) {
    b, err := r.run("rev-parse", "HEAD")
    return string(bytes.TrimSpace(b)), err
}

func (r *Repo) ShowFileAtHead(path string) ([]byte, error) {
    return r.run("show", "HEAD:"+path)
}

func (r *Repo) IsRepo() bool {
    _, err := r.run("rev-parse", "--git-dir")
    return err == nil
}

func (r *Repo) Revert(n int) error {
    _, err := r.run("revert", "--no-edit", fmt.Sprintf("HEAD~%d..HEAD", n-1))
    return err
}
  • Step 4: Run tests to verify they pass

Run: go test ./internal/gitx/... Expected: PASS

  • Step 5: Commit
git add internal/gitx
git commit -m "feat(gitx): thin shell wrapper over git CLI for repo ops"

Task 9: Three-way merge

Files:

  • Create: internal/merge/merge.go

  • Create: internal/merge/merge_test.go

  • Step 1: Write the failing test

internal/merge/merge_test.go:

package merge

import (
    "testing"

    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func TestCleanFastForward(t *testing.T) {
    res, err := ThreeWay(Inputs{
        Base:    []byte("a\nb\nc\n"),
        Ours:    []byte("a\nb\nc\nd\n"),
        Theirs:  []byte("a\nb\nc\n"),
    })
    require.NoError(t, err)
    assert.False(t, res.Conflict)
    assert.Equal(t, "a\nb\nc\nd\n", string(res.Body))
}

func TestConflict(t *testing.T) {
    res, err := ThreeWay(Inputs{
        Base:    []byte("a\n"),
        Ours:    []byte("a\nours\n"),
        Theirs:  []byte("a\ntheirs\n"),
    })
    require.NoError(t, err)
    assert.True(t, res.Conflict)
    assert.Contains(t, string(res.Body), "<<<<<<<")
    assert.Contains(t, string(res.Body), ">>>>>>>")
}

func TestNoOpWhenAllEqual(t *testing.T) {
    res, err := ThreeWay(Inputs{
        Base:    []byte("a\n"),
        Ours:    []byte("a\n"),
        Theirs:  []byte("a\n"),
    })
    require.NoError(t, err)
    assert.False(t, res.Conflict)
    assert.Equal(t, "a\n", string(res.Body))
}
  • Step 2: Run test to verify it fails

Run: go test ./internal/merge/... Expected: FAIL with undefined: ThreeWay

  • Step 3: Implement merge wrapper

internal/merge/merge.go:

package merge

import (
    "fmt"
    "os"
    "os/exec"
    "path/filepath"
)

// Inputs:
//   Base   = last-rendered snapshot (state/...)
//   Ours   = new rendered output (what we'd write now)
//   Theirs = current target file contents (what's on disk at the destination)
type Inputs struct {
    Base   []byte
    Ours   []byte
    Theirs []byte
}

type Result struct {
    Body     []byte
    Conflict bool
}

// ThreeWay runs `git merge-file --diff3 ours base theirs` and returns the
// merged body. When Conflict is true, Body contains conflict markers.
func ThreeWay(in Inputs) (*Result, error) {
    tmp, err := os.MkdirTemp("", "harness-sync-merge-")
    if err != nil {
        return nil, err
    }
    defer os.RemoveAll(tmp)

    oursPath := filepath.Join(tmp, "ours")
    basePath := filepath.Join(tmp, "base")
    theirsPath := filepath.Join(tmp, "theirs")

    if err := os.WriteFile(oursPath, in.Ours, 0o644); err != nil {
        return nil, err
    }
    if err := os.WriteFile(basePath, in.Base, 0o644); err != nil {
        return nil, err
    }
    if err := os.WriteFile(theirsPath, in.Theirs, 0o644); err != nil {
        return nil, err
    }

    cmd := exec.Command(
        "git", "merge-file",
        "-L", "harness-sync (new)",
        "-L", "harness-sync (base)",
        "-L", "harness-sync (current)",
        "-p",
        oursPath, basePath, theirsPath,
    )
    out, runErr := cmd.Output()

    res := &Result{Body: out}
    if runErr != nil {
        if ee, ok := runErr.(*exec.ExitError); ok {
            // git merge-file exits with conflict-count on conflict, body still valid
            if ee.ExitCode() > 0 {
                res.Conflict = true
                return res, nil
            }
        }
        return nil, fmt.Errorf("git merge-file: %w", runErr)
    }
    return res, nil
}
  • Step 4: Run tests to verify they pass

Run: go test ./internal/merge/... Expected: PASS

  • Step 5: Commit
git add internal/merge
git commit -m "feat(merge): three-way merge via git merge-file with conflict detection"

Phase 4: Apply pipeline + first adapter

Task 10: Apply pipeline

Files:

  • Create: internal/apply/apply.go
  • Create: internal/apply/apply_test.go

The apply pipeline takes a Bundle + list of adapters and writes their rendered FileSets to disk, recording state and reporting conflicts.

  • Step 1: Write the failing test

internal/apply/apply_test.go:

package apply

import (
    "os"
    "path/filepath"
    "testing"

    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"

    "github.com/lukaszraczylo/harness-sync/internal/adapter"
    "github.com/lukaszraczylo/harness-sync/internal/canonical"
    "github.com/lukaszraczylo/harness-sync/internal/gitx"
)

type stubAdapter struct {
    name  string
    files []adapter.File
}

func (s *stubAdapter) Name() string                                       { return s.name }
func (s *stubAdapter) Detect() bool                                       { return true }
func (s *stubAdapter) Import(_ string) (*adapter.ImportResult, error)     { return &adapter.ImportResult{}, nil }
func (s *stubAdapter) Render(_ *canonical.Bundle) (*adapter.FileSet, error) {
    fs := adapter.NewFileSet()
    for _, f := range s.files {
        fs.Add(f)
    }
    return fs, nil
}

func TestApplyRenderedFileFreshWrite(t *testing.T) {
    root := t.TempDir()
    target := filepath.Join(t.TempDir(), "out.json")

    require.NoError(t, initCanonical(root))

    ad := &stubAdapter{
        name: "stub",
        files: []adapter.File{
            {Dest: target, Kind: adapter.RenderedFile, Content: []byte("{}\n")},
        },
    }
    rep, err := Run(Options{
        Bundle:   &canonical.Bundle{Root: root},
        Adapters: []adapter.Adapter{ad},
        Repo:     gitx.New(root),
    })
    require.NoError(t, err)
    assert.Equal(t, 1, rep.Written)
    assert.Equal(t, 0, rep.Conflicts)

    body, err := os.ReadFile(target)
    require.NoError(t, err)
    assert.Equal(t, "{}\n", string(body))

    // state snapshot recorded
    statePath := filepath.Join(root, "state", "stub", target)
    assert.FileExists(t, statePath)
}

func TestApplyConflictWritesRej(t *testing.T) {
    root := t.TempDir()
    target := filepath.Join(t.TempDir(), "out.txt")

    require.NoError(t, initCanonical(root))
    repo := gitx.New(root)

    // First apply
    ad1 := &stubAdapter{
        name: "stub",
        files: []adapter.File{
            {Dest: target, Kind: adapter.RenderedFile, Content: []byte("base\n")},
        },
    }
    _, err := Run(Options{
        Bundle:   &canonical.Bundle{Root: root},
        Adapters: []adapter.Adapter{ad1},
        Repo:     repo,
    })
    require.NoError(t, err)

    // User edits target out-of-band
    require.NoError(t, os.WriteFile(target, []byte("user-edit\n"), 0o644))

    // Second apply changes the rendered content too
    ad2 := &stubAdapter{
        name: "stub",
        files: []adapter.File{
            {Dest: target, Kind: adapter.RenderedFile, Content: []byte("new\n")},
        },
    }
    rep, err := Run(Options{
        Bundle:   &canonical.Bundle{Root: root},
        Adapters: []adapter.Adapter{ad2},
        Repo:     repo,
    })
    require.NoError(t, err)
    assert.Equal(t, 1, rep.Conflicts)
    assert.FileExists(t, target+".rej")
}

func initCanonical(root string) error {
    if err := os.MkdirAll(filepath.Join(root, "state"), 0o755); err != nil {
        return err
    }
    r := gitx.New(root)
    if err := r.Init(); err != nil {
        return err
    }
    if err := r.Configure("test", "[email protected]"); err != nil {
        return err
    }
    if err := os.WriteFile(filepath.Join(root, ".gitkeep"), []byte{}, 0o644); err != nil {
        return err
    }
    if err := r.AddAll(); err != nil {
        return err
    }
    return r.Commit("init")
}
  • Step 2: Run test to verify it fails

Run: go test ./internal/apply/... Expected: FAIL with undefined: Run

  • Step 3: Implement apply pipeline

internal/apply/apply.go:

package apply

import (
    "errors"
    "fmt"
    "io/fs"
    "os"
    "path/filepath"

    "github.com/lukaszraczylo/harness-sync/internal/adapter"
    "github.com/lukaszraczylo/harness-sync/internal/canonical"
    "github.com/lukaszraczylo/harness-sync/internal/gitx"
    "github.com/lukaszraczylo/harness-sync/internal/merge"
)

type Options struct {
    Bundle   *canonical.Bundle
    Adapters []adapter.Adapter
    Repo     *gitx.Repo
    DryRun   bool
    Force    bool
}

type Report struct {
    Written   int
    Skipped   int
    Conflicts int
    Actions   []Action
}

type Action struct {
    Adapter string
    Dest    string
    Kind    string // "wrote" | "symlinked" | "skipped" | "conflict"
    Note    string
}

func Run(opt Options) (*Report, error) {
    rep := &Report{}
    for _, ad := range opt.Adapters {
        fs, err := ad.Render(opt.Bundle)
        if err != nil {
            return rep, fmt.Errorf("render %s: %w", ad.Name(), err)
        }
        var renderErr error
        fs.ForEach(func(f adapter.File) {
            if renderErr != nil {
                return
            }
            renderErr = handle(opt, ad.Name(), f, rep)
        })
        if renderErr != nil {
            return rep, renderErr
        }
    }
    if !opt.DryRun && rep.Written > 0 && opt.Repo != nil {
        if err := opt.Repo.AddAll(); err != nil {
            return rep, err
        }
        if err := opt.Repo.Commit(fmt.Sprintf("apply: %d files, %d conflicts", rep.Written, rep.Conflicts)); err != nil {
            return rep, err
        }
    }
    return rep, nil
}

func handle(opt Options, adapterName string, f adapter.File, rep *Report) error {
    switch f.Kind {
    case adapter.RenderedFile:
        return handleRendered(opt, adapterName, f, rep)
    case adapter.SymlinkFile, adapter.SymlinkDir:
        return handleSymlink(opt, adapterName, f, rep)
    }
    return fmt.Errorf("unknown file kind for %s", f.Dest)
}

func statePath(root, adapterName, dest string) string {
    return filepath.Join(root, "state", adapterName, dest)
}

func handleRendered(opt Options, adapterName string, f adapter.File, rep *Report) error {
    sp := statePath(opt.Bundle.Root, adapterName, f.Dest)
    base, _ := os.ReadFile(sp) // empty if missing
    current, _ := os.ReadFile(f.Dest)

    if string(current) == string(f.Content) {
        rep.Skipped++
        rep.Actions = append(rep.Actions, Action{Adapter: adapterName, Dest: f.Dest, Kind: "skipped", Note: "already in sync"})
        return writeState(opt, sp, f.Content)
    }

    if opt.Force || len(base) == 0 || string(current) == string(base) {
        if opt.DryRun {
            rep.Actions = append(rep.Actions, Action{Adapter: adapterName, Dest: f.Dest, Kind: "wrote", Note: "would write"})
            return nil
        }
        if err := writeFile(f.Dest, f.Content, f.Mode); err != nil {
            return err
        }
        if err := writeState(opt, sp, f.Content); err != nil {
            return err
        }
        rep.Written++
        rep.Actions = append(rep.Actions, Action{Adapter: adapterName, Dest: f.Dest, Kind: "wrote"})
        return nil
    }

    res, err := merge.ThreeWay(merge.Inputs{Base: base, Ours: f.Content, Theirs: current})
    if err != nil {
        return err
    }
    if res.Conflict {
        if opt.DryRun {
            rep.Conflicts++
            rep.Actions = append(rep.Actions, Action{Adapter: adapterName, Dest: f.Dest, Kind: "conflict", Note: "would write .rej"})
            return nil
        }
        if err := os.WriteFile(f.Dest+".rej", res.Body, 0o644); err != nil {
            return err
        }
        rep.Conflicts++
        rep.Actions = append(rep.Actions, Action{Adapter: adapterName, Dest: f.Dest, Kind: "conflict", Note: "wrote .rej"})
        return nil
    }
    if opt.DryRun {
        rep.Actions = append(rep.Actions, Action{Adapter: adapterName, Dest: f.Dest, Kind: "wrote", Note: "would merge"})
        return nil
    }
    if err := writeFile(f.Dest, res.Body, f.Mode); err != nil {
        return err
    }
    if err := writeState(opt, sp, f.Content); err != nil {
        return err
    }
    rep.Written++
    rep.Actions = append(rep.Actions, Action{Adapter: adapterName, Dest: f.Dest, Kind: "wrote", Note: "merged"})
    return nil
}

func handleSymlink(opt Options, adapterName string, f adapter.File, rep *Report) error {
    existing, err := os.Readlink(f.Dest)
    if err == nil && existing == f.SymlinkTarget {
        rep.Skipped++
        rep.Actions = append(rep.Actions, Action{Adapter: adapterName, Dest: f.Dest, Kind: "skipped", Note: "symlink already correct"})
        return nil
    }
    if opt.DryRun {
        rep.Actions = append(rep.Actions, Action{Adapter: adapterName, Dest: f.Dest, Kind: "symlinked", Note: "would link"})
        return nil
    }
    if _, statErr := os.Lstat(f.Dest); statErr == nil {
        // back up existing
        backup := filepath.Join(opt.Bundle.Root, "backups", adapterName, filepath.Base(f.Dest))
        if err := os.MkdirAll(filepath.Dir(backup), 0o755); err != nil {
            return err
        }
        if err := os.Rename(f.Dest, backup); err != nil && !errors.Is(err, fs.ErrNotExist) {
            return err
        }
    }
    if err := os.MkdirAll(filepath.Dir(f.Dest), 0o755); err != nil {
        return err
    }
    if err := os.Symlink(f.SymlinkTarget, f.Dest); err != nil {
        return err
    }
    rep.Written++
    rep.Actions = append(rep.Actions, Action{Adapter: adapterName, Dest: f.Dest, Kind: "symlinked"})
    return nil
}

func writeFile(dest string, body []byte, mode os.FileMode) error {
    if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
        return err
    }
    tmp := dest + ".tmp"
    if mode == 0 {
        mode = 0o644
    }
    if err := os.WriteFile(tmp, body, mode); err != nil {
        return err
    }
    return os.Rename(tmp, dest)
}

func writeState(opt Options, statePath string, body []byte) error {
    if opt.DryRun {
        return nil
    }
    if err := os.MkdirAll(filepath.Dir(statePath), 0o755); err != nil {
        return err
    }
    return os.WriteFile(statePath, body, 0o644)
}
  • Step 4: Run tests to verify they pass

Run: go test ./internal/apply/... Expected: PASS

  • Step 5: Commit
git add internal/apply
git commit -m "feat(apply): render+merge+write pipeline with state snapshots and .rej conflicts"

Task 11: claudecode adapter (Detect + Render)

Files:

  • Create: internal/adapters/claudecode/claudecode.go
  • Create: internal/adapters/claudecode/claudecode_test.go
  • Create: internal/adapters/claudecode/testdata/

For claude-code, canonical skills/agents map 1:1 to ~/.claude/skills/ and ~/.claude/agents/. Global instructions → ~/.claude/CLAUDE.md. MCP block lives in ~/.claude/settings.json.

  • Step 1: Inspect actual claude-code layout to ground the adapter
ls ~/.claude/
ls ~/.claude/skills/ | head
ls ~/.claude/agents/ | head
test -f ~/.claude/settings.json && head -c 2000 ~/.claude/settings.json

Note: the adapter only needs to produce files in known locations. Settings.json shape: add mcpServers key with name → {command, args, env, transport}.

  • Step 2: Write the failing test

internal/adapters/claudecode/claudecode_test.go:

package claudecode

import (
    "path/filepath"
    "testing"

    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"

    "github.com/lukaszraczylo/harness-sync/internal/adapter"
    "github.com/lukaszraczylo/harness-sync/internal/canonical"
)

func TestRenderProducesExpectedTargets(t *testing.T) {
    home := t.TempDir()
    ad := New(WithHome(home))
    b := &canonical.Bundle{
        Root: "/canon",
        Config: canonical.Config{ActiveProfile: "home"},
        MCP: canonical.MCPRegistry{Servers: []canonical.MCPServer{
            {Name: "filepuff", Command: "/bin/filepuff", Transport: "stdio"},
        }},
        Instructions: canonical.Instructions{Global: "# global"},
    }
    fs, err := ad.Render(b)
    require.NoError(t, err)

    seen := map[string]adapter.File{}
    fs.ForEach(func(f adapter.File) { seen[f.Dest] = f })

    skillsDest := filepath.Join(home, ".claude", "skills")
    assert.Equal(t, adapter.SymlinkDir, seen[skillsDest].Kind)
    assert.Equal(t, "/canon/skills", seen[skillsDest].SymlinkTarget)

    agentsDest := filepath.Join(home, ".claude", "agents")
    assert.Equal(t, adapter.SymlinkDir, seen[agentsDest].Kind)
    assert.Equal(t, "/canon/agents", seen[agentsDest].SymlinkTarget)

    claudemdDest := filepath.Join(home, ".claude", "CLAUDE.md")
    assert.Equal(t, adapter.RenderedFile, seen[claudemdDest].Kind)
    assert.Contains(t, string(seen[claudemdDest].Content), "# global")

    settingsDest := filepath.Join(home, ".claude", "settings.json")
    assert.Equal(t, adapter.RenderedFile, seen[settingsDest].Kind)
    assert.Contains(t, string(seen[settingsDest].Content), "\"filepuff\"")
    assert.Contains(t, string(seen[settingsDest].Content), "\"command\": \"/bin/filepuff\"")
}
  • Step 3: Run test to verify it fails

Run: go test ./internal/adapters/claudecode/... Expected: FAIL with undefined: New

  • Step 4: Implement adapter

internal/adapters/claudecode/claudecode.go:

package claudecode

import (
    "os"
    "path/filepath"

    "github.com/lukaszraczylo/harness-sync/internal/adapter"
    "github.com/lukaszraczylo/harness-sync/internal/canonical"
    "github.com/lukaszraczylo/harness-sync/internal/render"
)

const name = "claude-code"

type Adapter struct {
    home string
}

type Option func(*Adapter)

func WithHome(h string) Option { return func(a *Adapter) { a.home = h } }

func New(opts ...Option) *Adapter {
    a := &Adapter{home: defaultHome()}
    for _, o := range opts {
        o(a)
    }
    return a
}

func defaultHome() string {
    h, err := os.UserHomeDir()
    if err != nil {
        return ""
    }
    return h
}

func (a *Adapter) Name() string { return name }

func (a *Adapter) Detect() bool {
    _, err := os.Stat(filepath.Join(a.home, ".claude"))
    return err == nil
}

func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) {
    fs := adapter.NewFileSet()
    base := filepath.Join(a.home, ".claude")

    fs.Add(adapter.File{
        Dest:          filepath.Join(base, "skills"),
        Kind:          adapter.SymlinkDir,
        SymlinkTarget: filepath.Join(b.Root, "skills"),
    })
    fs.Add(adapter.File{
        Dest:          filepath.Join(base, "agents"),
        Kind:          adapter.SymlinkDir,
        SymlinkTarget: filepath.Join(b.Root, "agents"),
    })

    instructions := b.Instructions.Global
    if override, ok := b.Instructions.PerHarness[name]; ok && override != "" {
        instructions = override
    }
    fs.Add(adapter.File{
        Dest:    filepath.Join(base, "CLAUDE.md"),
        Kind:    adapter.RenderedFile,
        Content: []byte(instructions),
    })

    settings, err := renderSettings(b)
    if err != nil {
        return nil, err
    }
    fs.Add(adapter.File{
        Dest:    filepath.Join(base, "settings.json"),
        Kind:    adapter.RenderedFile,
        Content: settings,
    })

    return fs, nil
}

func renderSettings(b *canonical.Bundle) ([]byte, error) {
    mcp := map[string]any{}
    for _, s := range b.MCP.Servers {
        entry := map[string]any{}
        if s.Command != "" {
            entry["command"] = s.Command
        }
        if len(s.Args) > 0 {
            entry["args"] = s.Args
        }
        if s.URL != "" {
            entry["url"] = s.URL
        }
        if s.Transport != "" {
            entry["transport"] = s.Transport
        }
        if len(s.Env) > 0 {
            entry["env"] = s.Env
        }
        mcp[s.Name] = entry
    }
    return render.JSON(map[string]any{
        "mcpServers": mcp,
    })
}

func (a *Adapter) Import(home string) (*adapter.ImportResult, error) {
    return importFrom(home)
}
  • Step 5: Run tests to verify they pass

Run: go test ./internal/adapters/claudecode/... Expected: PASS

  • Step 6: Commit
git add internal/adapters/claudecode
git commit -m "feat(claudecode): adapter with skills+agents symlinks, CLAUDE.md, settings.json"

Task 12: claudecode adapter Import

Files:

  • Create: internal/adapters/claudecode/import.go

  • Modify: internal/adapters/claudecode/claudecode_test.go

  • Step 1: Extend tests with an Import case

Append to internal/adapters/claudecode/claudecode_test.go:

import (
    "encoding/json"
    "os"
)

func TestImport(t *testing.T) {
    home := t.TempDir()
    base := filepath.Join(home, ".claude")
    require.NoError(t, os.MkdirAll(filepath.Join(base, "skills", "hello"), 0o755))
    require.NoError(t, os.MkdirAll(filepath.Join(base, "agents"), 0o755))

    require.NoError(t, os.WriteFile(
        filepath.Join(base, "skills", "hello", "SKILL.md"),
        []byte("---\nname: hello\ndescription: x\n---\nbody"), 0o644))
    require.NoError(t, os.WriteFile(
        filepath.Join(base, "agents", "agentA.md"),
        []byte("---\nname: agentA\ndescription: y\n---\nagent body"), 0o644))
    require.NoError(t, os.WriteFile(
        filepath.Join(base, "CLAUDE.md"),
        []byte("# global"), 0o644))

    settings, _ := json.Marshal(map[string]any{
        "mcpServers": map[string]any{
            "filepuff": map[string]any{
                "command":   "/bin/filepuff",
                "transport": "stdio",
            },
        },
    })
    require.NoError(t, os.WriteFile(
        filepath.Join(base, "settings.json"), settings, 0o644))

    ad := New(WithHome(home))
    res, err := ad.Import(home)
    require.NoError(t, err)

    require.Len(t, res.Skills, 1)
    assert.Equal(t, "hello", res.Skills[0].Name)
    require.Len(t, res.Agents, 1)
    assert.Equal(t, "agentA", res.Agents[0].Name)
    require.Len(t, res.MCP, 1)
    assert.Equal(t, "filepuff", res.MCP[0].Name)
    assert.Equal(t, "/bin/filepuff", res.MCP[0].Command)
    assert.Equal(t, "# global", res.Instructions)
}
  • Step 2: Run test to verify it fails

Run: go test ./internal/adapters/claudecode/... Expected: FAIL with undefined: importFrom

  • Step 3: Implement Import

internal/adapters/claudecode/import.go:

package claudecode

import (
    "encoding/json"
    "os"
    "path/filepath"
    "strings"

    "github.com/goccy/go-yaml"

    "github.com/lukaszraczylo/harness-sync/internal/adapter"
    "github.com/lukaszraczylo/harness-sync/internal/canonical"
)

func importFrom(home string) (*adapter.ImportResult, error) {
    base := filepath.Join(home, ".claude")
    res := &adapter.ImportResult{}

    if skills, err := importMarkdownTree(filepath.Join(base, "skills"), "SKILL.md"); err != nil {
        return nil, err
    } else {
        for _, s := range skills {
            res.Skills = append(res.Skills, canonical.Skill(s))
        }
    }
    if agents, err := importMarkdownTree(filepath.Join(base, "agents"), ""); err != nil {
        return nil, err
    } else {
        for _, a := range agents {
            res.Agents = append(res.Agents, canonical.Agent(a))
        }
    }
    if body, err := readIfExists(filepath.Join(base, "CLAUDE.md")); err != nil {
        return nil, err
    } else {
        res.Instructions = body
    }
    if servers, err := importMCPFromSettings(filepath.Join(base, "settings.json")); err != nil {
        return nil, err
    } else {
        res.MCP = servers
    }
    return res, nil
}

type doc struct {
    Name        string
    Description string
    Body        string
    Path        string
}

func importMarkdownTree(dir, requiredFilename string) ([]doc, error) {
    var docs []doc
    if !dirExists(dir) {
        return docs, nil
    }
    return docs, filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
        if err != nil {
            return err
        }
        if info.IsDir() {
            return nil
        }
        if requiredFilename != "" && filepath.Base(p) != requiredFilename {
            return nil
        }
        if requiredFilename == "" && !strings.HasSuffix(p, ".md") {
            return nil
        }
        body, err := os.ReadFile(p)
        if err != nil {
            return err
        }
        name, desc := parseFrontmatter(body)
        if name == "" {
            name = strings.TrimSuffix(filepath.Base(p), ".md")
        }
        rel, _ := filepath.Rel(dir, p)
        docs = append(docs, doc{
            Name:        name,
            Description: desc,
            Body:        string(body),
            Path:        rel,
        })
        return nil
    })
}

func importMCPFromSettings(path string) ([]canonical.MCPServer, error) {
    body, err := os.ReadFile(path)
    if os.IsNotExist(err) {
        return nil, nil
    }
    if err != nil {
        return nil, err
    }
    var doc struct {
        MCPServers map[string]struct {
            Command   string            `json:"command"`
            Args      []string          `json:"args"`
            URL       string            `json:"url"`
            Transport string            `json:"transport"`
            Env       map[string]string `json:"env"`
        } `json:"mcpServers"`
    }
    if err := json.Unmarshal(body, &doc); err != nil {
        return nil, err
    }
    out := make([]canonical.MCPServer, 0, len(doc.MCPServers))
    for name, v := range doc.MCPServers {
        out = append(out, canonical.MCPServer{
            Name: name, Command: v.Command, Args: v.Args, URL: v.URL,
            Transport: v.Transport, Env: v.Env,
        })
    }
    return out, nil
}

func parseFrontmatter(b []byte) (name, description string) {
    s := string(b)
    if !strings.HasPrefix(s, "---\n") {
        return "", ""
    }
    end := strings.Index(s[4:], "\n---")
    if end == -1 {
        return "", ""
    }
    var meta struct {
        Name        string `yaml:"name"`
        Description string `yaml:"description"`
    }
    _ = yaml.Unmarshal([]byte(s[4:4+end]), &meta)
    return meta.Name, meta.Description
}

func readIfExists(path string) (string, error) {
    b, err := os.ReadFile(path)
    if os.IsNotExist(err) {
        return "", nil
    }
    if err != nil {
        return "", err
    }
    return string(b), nil
}

func dirExists(path string) bool {
    info, err := os.Stat(path)
    return err == nil && info.IsDir()
}
  • Step 4: Run tests to verify they pass

Run: go test ./internal/adapters/claudecode/... Expected: PASS

  • Step 5: Commit
git add internal/adapters/claudecode
git commit -m "feat(claudecode): Import reads existing ~/.claude into canonical model"

Phase 5: Remaining adapters

Each remaining adapter follows the same shape as claudecode but with its own native schema. Before implementing, the engineer must inspect the harness's actual config files on this machine. The harness's schema is the source of truth — match its keys.

For every adapter task below, the sub-steps are identical structure (TDD). The pattern:

  1. Inspect ~/.config/<harness>/ and any official docs to determine schema
  2. Add the adapter package and minimum failing test
  3. Implement Render
  4. Implement Import
  5. Register in cmd/harness-sync/main.go's adapter registry init block
  6. Commit

The native schema specifics are NOT prescribed by this plan; the engineer must read the harness's actual files in ~/.config/<harness>/ and any official docs and faithfully reproduce them. If a harness has no concept of an asset (e.g. no skill support), the adapter simply omits that Target.

Task 13: crush adapter

Files:

  • Create: internal/adapters/crush/crush.go

  • Create: internal/adapters/crush/import.go

  • Create: internal/adapters/crush/crush_test.go

  • Create: internal/adapters/crush/testdata/

  • Step 1: Inspect crush's actual layout

ls -la ~/.config/crush/
test -f ~/.config/crush/crush.json && head -c 4000 ~/.config/crush/crush.json
test -d ~/.config/crush/skills && ls ~/.config/crush/skills/

Document the schema you observe in a comment at the top of crush.go. Crush stores its config in crush.json and has a prompts/ (or skills/) directory. Symlink the canonical skills tree if shape matches; otherwise render a flattened version.

  • Step 2: Write the failing test mirroring TestRenderProducesExpectedTargets from claudecode but with crush paths and crush.json structure

Use the same test pattern: build a Bundle, call Render, walk the FileSet, assert each Target. Adapt destination paths to ~/.config/crush/....

  • Step 3: Run test to verify it fails

Run: go test ./internal/adapters/crush/... Expected: FAIL with undefined: New

  • Step 4: Implement Render following the claudecode pattern. The crush LLM config block (model providers + gateway URL) must be derived from b.Profile. See the canonical Profile type at internal/canonical/types.go for fields. Use internal/render.JSON for crush.json output.

  • Step 5: Implement Import by reading ~/.config/crush/crush.json and (if present) ~/.config/crush/skills/ into canonical.MCPServer, canonical.Profile, and skill entries.

  • Step 6: Run tests to verify they pass

Run: go test ./internal/adapters/crush/... Expected: PASS

  • Step 7: Commit
git add internal/adapters/crush
git commit -m "feat(crush): adapter with crush.json render + skills symlink"

Task 14: kilo adapter

Files:

  • Create: internal/adapters/kilo/kilo.go

  • Create: internal/adapters/kilo/import.go

  • Create: internal/adapters/kilo/kilo_test.go

  • Step 1: Inspect kilo's actual layout

ls -la ~/.config/kilo/
test -f ~/.config/kilo/kilo.json && head -c 4000 ~/.config/kilo/kilo.json
test -f ~/.config/kilo/opencode.jsonc && head -c 4000 ~/.config/kilo/opencode.jsonc
test -d ~/.config/kilo/agent && ls ~/.config/kilo/agent/
  • Step 2: Write the failing test

Mirror claudecode TestRender pattern with kilo destinations.

  • Step 3: Run test to verify it fails

Run: go test ./internal/adapters/kilo/... Expected: FAIL with undefined: New

  • Step 4: Implement Render using internal/render.JSON. kilo supports an agent directory; symlink agents/ from canonical if shape matches, otherwise render.

  • Step 5: Implement Import from kilo.json (and opencode.jsonc if kilo also keeps an opencode config).

  • Step 6: Run tests to verify they pass

Run: go test ./internal/adapters/kilo/... Expected: PASS

  • Step 7: Commit
git add internal/adapters/kilo
git commit -m "feat(kilo): adapter with kilo.json render + agent symlink"

Task 15: opencode adapter

Files:

  • Create: internal/adapters/opencode/opencode.go

  • Create: internal/adapters/opencode/import.go

  • Create: internal/adapters/opencode/opencode_test.go

  • Step 1: Inspect opencode's actual layout

ls -la ~/.config/opencode/
test -f ~/.config/opencode/opencode.jsonc && head -c 4000 ~/.config/opencode/opencode.jsonc

opencode uses JSONC. For Render, output plain JSON (JSONC parsers accept JSON). For Import, strip comments before unmarshal — use a simple line-by-line stripper for // comments and a regex for /* */ blocks; do not pull in a JSONC dependency.

  • Step 2: Write the failing test mirroring claudecode pattern with opencode destinations.

  • Step 3: Run test to verify it fails

Run: go test ./internal/adapters/opencode/... Expected: FAIL with undefined: New

  • Step 4: Implement Render using internal/render.JSON. opencode supports AGENTS.md for global instructions; route b.Instructions.Global there.

  • Step 5: Implement Import from opencode.jsonc and any existing AGENTS.md.

  • Step 6: Run tests to verify they pass

Run: go test ./internal/adapters/opencode/... Expected: PASS

  • Step 7: Commit
git add internal/adapters/opencode
git commit -m "feat(opencode): adapter with opencode.jsonc render + AGENTS.md"

Task 16: goose adapter

Files:

  • Create: internal/adapters/goose/goose.go

  • Create: internal/adapters/goose/import.go

  • Create: internal/adapters/goose/goose_test.go

  • Step 1: Inspect goose's actual layout

ls -la ~/.config/goose/
test -f ~/.config/goose/config.yaml && head -c 4000 ~/.config/goose/config.yaml
test -d ~/.config/goose/custom_providers && ls ~/.config/goose/custom_providers/

goose uses YAML. Use internal/render.YAML for output.

  • Step 2: Write the failing test mirroring claudecode pattern.

  • Step 3: Run test to verify it fails

Run: go test ./internal/adapters/goose/... Expected: FAIL with undefined: New

  • Step 4: Implement Render for config.yaml + custom provider definitions derived from b.Profile.

  • Step 5: Implement Import from config.yaml.

  • Step 6: Run tests to verify they pass

Run: go test ./internal/adapters/goose/... Expected: PASS

  • Step 7: Commit
git add internal/adapters/goose
git commit -m "feat(goose): adapter with config.yaml render and providers from Profile"

Task 17: cagent adapter

Files:

  • Create: internal/adapters/cagent/cagent.go

  • Create: internal/adapters/cagent/import.go

  • Create: internal/adapters/cagent/cagent_test.go

  • Step 1: Inspect cagent's actual layout

ls -la ~/.config/cagent/
find ~/.config/cagent -type f -maxdepth 3 | head -30

cagent is Docker's CLI agent. Determine its config format. If .cagent_first_run is the only file, no config has been generated yet — write a minimal adapter that writes the expected canonical config to its expected path; consult docker docs via WebFetch if needed: https://github.com/docker/cagent.

  • Step 2: Write the failing test asserting basic Render output.

  • Step 3: Run test to verify it fails

Run: go test ./internal/adapters/cagent/... Expected: FAIL with undefined: New

  • Step 4: Implement Render based on observed/documented schema.

  • Step 5: Implement Import (may be a no-op stub if cagent has no persistent config to read).

  • Step 6: Run tests to verify they pass

Run: go test ./internal/adapters/cagent/... Expected: PASS

  • Step 7: Commit
git add internal/adapters/cagent
git commit -m "feat(cagent): adapter scaffold matching docker cagent layout"

Task 18: zed adapter

Files:

  • Create: internal/adapters/zed/zed.go

  • Create: internal/adapters/zed/import.go

  • Create: internal/adapters/zed/zed_test.go

  • Step 1: Inspect zed's actual layout

ls -la ~/.config/zed/
test -f ~/.config/zed/settings.json && head -c 4000 ~/.config/zed/settings.json

zed is an editor; harness-sync targets its assistant / language_models settings only. Do not touch other zed settings. Use a partial-merge approach: read existing settings.json, replace only assistant.providers and language_models keys, write back. Implement this carefully because zed settings are user-edited.

  • Step 2: Write the failing test asserting that unrelated keys are preserved.

  • Step 3: Run test to verify it fails

Run: go test ./internal/adapters/zed/... Expected: FAIL with undefined: New

  • Step 4: Implement Render that loads existing settings.json (or empty object if missing), overwrites only LLM-related keys derived from b.Profile, marshals back via internal/render.JSON.

  • Step 5: Implement Import of those same keys.

  • Step 6: Run tests to verify they pass

Run: go test ./internal/adapters/zed/... Expected: PASS

  • Step 7: Commit
git add internal/adapters/zed
git commit -m "feat(zed): adapter that updates only language_models keys in settings.json"

Phase 6: CLI commands

Task 19: Wire registry + detect command

Files:

  • Modify: cmd/harness-sync/main.go

  • Modify: internal/cli/root.go

  • Create: internal/cli/detect.go

  • Create: internal/cli/detect_test.go

  • Step 1: Wire all adapters into the default registry in main

Replace cmd/harness-sync/main.go with:

package main

import (
    "fmt"
    "os"

    "github.com/lukaszraczylo/harness-sync/internal/adapter"
    "github.com/lukaszraczylo/harness-sync/internal/adapters/cagent"
    "github.com/lukaszraczylo/harness-sync/internal/adapters/claudecode"
    "github.com/lukaszraczylo/harness-sync/internal/adapters/crush"
    "github.com/lukaszraczylo/harness-sync/internal/adapters/goose"
    "github.com/lukaszraczylo/harness-sync/internal/adapters/kilo"
    "github.com/lukaszraczylo/harness-sync/internal/adapters/opencode"
    "github.com/lukaszraczylo/harness-sync/internal/adapters/zed"
    "github.com/lukaszraczylo/harness-sync/internal/cli"
)

var version = "dev"

func main() {
    reg := adapter.NewRegistry()
    reg.Register(claudecode.New())
    reg.Register(crush.New())
    reg.Register(kilo.New())
    reg.Register(opencode.New())
    reg.Register(goose.New())
    reg.Register(cagent.New())
    reg.Register(zed.New())

    root := cli.NewRoot(version)
    root.AddCommand(cli.NewDetect(reg))

    if err := root.Execute(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

(If any cagent.New() / zed.New() etc. need a constructor signature different from this, update the adapter package — keep New() zero-arg.)

  • Step 2: Write the failing test

internal/cli/detect_test.go:

package cli

import (
    "bytes"
    "testing"

    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"

    "github.com/lukaszraczylo/harness-sync/internal/adapter"
    "github.com/lukaszraczylo/harness-sync/internal/canonical"
)

type detectableAdapter struct {
    name   string
    detect bool
}

func (d *detectableAdapter) Name() string                                  { return d.name }
func (d *detectableAdapter) Detect() bool                                  { return d.detect }
func (d *detectableAdapter) Render(_ *canonical.Bundle) (*adapter.FileSet, error) { return adapter.NewFileSet(), nil }
func (d *detectableAdapter) Import(_ string) (*adapter.ImportResult, error)       { return &adapter.ImportResult{}, nil }

func TestDetectCommandPrintsList(t *testing.T) {
    reg := adapter.NewRegistry()
    reg.Register(&detectableAdapter{name: "yes", detect: true})
    reg.Register(&detectableAdapter{name: "no", detect: false})

    var buf bytes.Buffer
    cmd := NewDetect(reg)
    cmd.SetOut(&buf)
    require.NoError(t, cmd.Execute())

    out := buf.String()
    assert.Contains(t, out, "yes")
    assert.Contains(t, out, "no")
    assert.Contains(t, out, "detected")
    assert.Contains(t, out, "not detected")
}
  • Step 3: Run test to verify it fails

Run: go test ./internal/cli/... Expected: FAIL with undefined: NewDetect

  • Step 4: Implement detect command

internal/cli/detect.go:

package cli

import (
    "fmt"

    "github.com/spf13/cobra"

    "github.com/lukaszraczylo/harness-sync/internal/adapter"
)

func NewDetect(reg *adapter.Registry) *cobra.Command {
    return &cobra.Command{
        Use:   "detect",
        Short: "List adapters and whether each harness is present on this machine",
        RunE: func(cmd *cobra.Command, _ []string) error {
            for _, a := range reg.All() {
                status := "not detected"
                if a.Detect() {
                    status = "detected"
                }
                fmt.Fprintf(cmd.OutOrStdout(), "%-14s %s\n", a.Name(), status)
            }
            return nil
        },
    }
}
  • Step 5: Run tests to verify they pass

Run: go test ./internal/cli/... && go build ./... && ./harness-sync detect Expected: PASS; binary lists adapters with detection status

  • Step 6: Commit
git add cmd internal/cli
git commit -m "feat(cli): wire adapters into registry and add detect subcommand"

Task 20: apply command (single + all harnesses)

Files:

  • Create: internal/cli/apply.go

  • Create: internal/cli/apply_test.go

  • Step 1: Write the failing test

internal/cli/apply_test.go:

package cli

import (
    "bytes"
    "os"
    "path/filepath"
    "testing"

    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"

    "github.com/lukaszraczylo/harness-sync/internal/adapter"
)

func TestApplyDryRun(t *testing.T) {
    root := t.TempDir()
    require.NoError(t, os.WriteFile(filepath.Join(root, "config.yaml"), []byte("active_profile: home\n"), 0o644))
    require.NoError(t, os.MkdirAll(filepath.Join(root, "profiles"), 0o755))
    require.NoError(t, os.WriteFile(filepath.Join(root, "profiles", "home.yaml"),
        []byte("name: home\ngateway:\n  url: u\n  token: t\n  default_model: m\nmodels:\n  - {id: m}\n"), 0o644))

    reg := adapter.NewRegistry()
    reg.Register(&detectableAdapter{name: "stub", detect: true})

    var buf bytes.Buffer
    cmd := NewApply(reg)
    cmd.SetOut(&buf)
    cmd.SetArgs([]string{"--dry-run", "--root", root})
    require.NoError(t, cmd.Execute())

    assert.Contains(t, buf.String(), "dry-run")
}
  • Step 2: Run test to verify it fails

Run: go test ./internal/cli/... Expected: FAIL with undefined: NewApply

  • Step 3: Implement apply command

internal/cli/apply.go:

package cli

import (
    "fmt"
    "os"
    "path/filepath"

    "github.com/spf13/cobra"

    "github.com/lukaszraczylo/harness-sync/internal/adapter"
    "github.com/lukaszraczylo/harness-sync/internal/apply"
    "github.com/lukaszraczylo/harness-sync/internal/canonical"
    "github.com/lukaszraczylo/harness-sync/internal/gitx"
)

func NewApply(reg *adapter.Registry) *cobra.Command {
    var (
        dryRun bool
        force  bool
        root   string
    )
    cmd := &cobra.Command{
        Use:   "apply [harness...]",
        Short: "Render canonical and propagate to harnesses",
        RunE: func(cmd *cobra.Command, args []string) error {
            if root == "" {
                h, err := os.UserHomeDir()
                if err != nil {
                    return err
                }
                root = filepath.Join(h, ".config", "harness-sync")
            }
            b, err := canonical.Load(root)
            if err != nil {
                return err
            }

            var selected []adapter.Adapter
            if len(args) == 0 {
                for _, a := range reg.All() {
                    if a.Detect() {
                        selected = append(selected, a)
                    }
                }
            } else {
                for _, name := range args {
                    a, ok := reg.Get(name)
                    if !ok {
                        return fmt.Errorf("unknown adapter %q", name)
                    }
                    selected = append(selected, a)
                }
            }

            opt := apply.Options{
                Bundle:   b,
                Adapters: selected,
                Repo:     gitx.New(root),
                DryRun:   dryRun,
                Force:    force,
            }
            rep, err := apply.Run(opt)
            if err != nil {
                return err
            }
            mode := "applied"
            if dryRun {
                mode = "dry-run"
            }
            fmt.Fprintf(cmd.OutOrStdout(), "%s: %d written, %d skipped, %d conflicts\n",
                mode, rep.Written, rep.Skipped, rep.Conflicts)
            for _, a := range rep.Actions {
                fmt.Fprintf(cmd.OutOrStdout(), "  %-10s %-12s %s %s\n", a.Adapter, a.Kind, a.Dest, a.Note)
            }
            if rep.Conflicts > 0 {
                return fmt.Errorf("%d conflicts; resolve .rej files", rep.Conflicts)
            }
            return nil
        },
    }
    cmd.Flags().BoolVar(&dryRun, "dry-run", false, "print actions without writing")
    cmd.Flags().BoolVar(&force, "force", false, "overwrite without 3-way merge")
    cmd.Flags().StringVar(&root, "root", "", "canonical root (default ~/.config/harness-sync)")
    return cmd
}
  • Step 4: Wire into main

Modify cmd/harness-sync/main.go to add root.AddCommand(cli.NewApply(reg)) after NewDetect.

  • Step 5: Run tests + build

Run: go test ./... && go build ./... Expected: PASS

  • Step 6: Commit
git add internal/cli cmd
git commit -m "feat(cli): apply subcommand with dry-run, force, conflict reporting"

Task 21: diff command

Files:

  • Create: internal/cli/diff.go

  • Create: internal/cli/diff_test.go

  • Step 1: Write the failing test

internal/cli/diff_test.go:

package cli

import (
    "bytes"
    "os"
    "path/filepath"
    "testing"

    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"

    "github.com/lukaszraczylo/harness-sync/internal/adapter"
)

func TestDiffPrintsActions(t *testing.T) {
    root := t.TempDir()
    require.NoError(t, os.WriteFile(filepath.Join(root, "config.yaml"), []byte("active_profile: home\n"), 0o644))
    require.NoError(t, os.MkdirAll(filepath.Join(root, "profiles"), 0o755))
    require.NoError(t, os.WriteFile(filepath.Join(root, "profiles", "home.yaml"),
        []byte("name: home\ngateway:\n  url: u\n  token: t\n  default_model: m\nmodels:\n  - {id: m}\n"), 0o644))

    reg := adapter.NewRegistry()
    reg.Register(&detectableAdapter{name: "stub", detect: true})

    var buf bytes.Buffer
    cmd := NewDiff(reg)
    cmd.SetOut(&buf)
    cmd.SetArgs([]string{"--root", root})
    require.NoError(t, cmd.Execute())
    assert.Contains(t, buf.String(), "dry-run")
}
  • Step 2: Run test to verify it fails

Run: go test ./internal/cli/... Expected: FAIL with undefined: NewDiff

  • Step 3: Implement diff command

internal/cli/diff.go:

package cli

import (
    "github.com/spf13/cobra"

    "github.com/lukaszraczylo/harness-sync/internal/adapter"
)

func NewDiff(reg *adapter.Registry) *cobra.Command {
    apply := NewApply(reg)
    cmd := &cobra.Command{
        Use:   "diff [harness...]",
        Short: "Show pending changes (alias for `apply --dry-run`)",
        RunE: func(cmd *cobra.Command, args []string) error {
            apply.SetArgs(append([]string{"--dry-run"}, args...))
            apply.SetOut(cmd.OutOrStdout())
            apply.SetErr(cmd.ErrOrStderr())
            return apply.Execute()
        },
    }
    return cmd
}
  • Step 4: Wire into main + run tests

Add root.AddCommand(cli.NewDiff(reg)).

Run: go test ./internal/cli/... && go build ./... Expected: PASS

  • Step 5: Commit
git add internal/cli cmd
git commit -m "feat(cli): diff subcommand as apply --dry-run shorthand"

Task 22: profile command

Files:

  • Create: internal/cli/profile.go

  • Create: internal/cli/profile_test.go

  • Step 1: Write the failing tests

internal/cli/profile_test.go:

package cli

import (
    "bytes"
    "os"
    "path/filepath"
    "testing"

    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func TestProfileList(t *testing.T) {
    root := t.TempDir()
    require.NoError(t, os.MkdirAll(filepath.Join(root, "profiles"), 0o755))
    require.NoError(t, os.WriteFile(filepath.Join(root, "profiles", "home.yaml"), []byte("name: home\n"), 0o644))
    require.NoError(t, os.WriteFile(filepath.Join(root, "profiles", "work.yaml"), []byte("name: work\n"), 0o644))

    var buf bytes.Buffer
    cmd := NewProfile()
    cmd.SetArgs([]string{"list", "--root", root})
    cmd.SetOut(&buf)
    require.NoError(t, cmd.Execute())

    out := buf.String()
    assert.Contains(t, out, "home")
    assert.Contains(t, out, "work")
}

func TestProfileUse(t *testing.T) {
    root := t.TempDir()
    require.NoError(t, os.MkdirAll(filepath.Join(root, "profiles"), 0o755))
    require.NoError(t, os.WriteFile(filepath.Join(root, "profiles", "work.yaml"), []byte("name: work\n"), 0o644))
    require.NoError(t, os.WriteFile(filepath.Join(root, "config.yaml"), []byte("active_profile: home\n"), 0o644))

    cmd := NewProfile()
    cmd.SetArgs([]string{"use", "work", "--root", root})
    require.NoError(t, cmd.Execute())

    body, err := os.ReadFile(filepath.Join(root, "config.yaml"))
    require.NoError(t, err)
    assert.Contains(t, string(body), "active_profile: work")
}
  • Step 2: Run tests to verify they fail

Run: go test ./internal/cli/... Expected: FAIL with undefined: NewProfile

  • Step 3: Implement profile command

internal/cli/profile.go:

package cli

import (
    "fmt"
    "os"
    "path/filepath"
    "sort"
    "strings"

    "github.com/spf13/cobra"
)

func NewProfile() *cobra.Command {
    var root string
    rootFlag := func(c *cobra.Command) {
        c.Flags().StringVar(&root, "root", "", "canonical root (default ~/.config/harness-sync)")
    }

    list := &cobra.Command{
        Use:   "list",
        Short: "List available profiles",
        RunE: func(cmd *cobra.Command, _ []string) error {
            r, err := resolveRoot(root)
            if err != nil {
                return err
            }
            entries, err := os.ReadDir(filepath.Join(r, "profiles"))
            if err != nil {
                return err
            }
            names := make([]string, 0, len(entries))
            for _, e := range entries {
                if strings.HasSuffix(e.Name(), ".yaml") {
                    names = append(names, strings.TrimSuffix(e.Name(), ".yaml"))
                }
            }
            sort.Strings(names)
            for _, n := range names {
                fmt.Fprintln(cmd.OutOrStdout(), n)
            }
            return nil
        },
    }
    rootFlag(list)

    use := &cobra.Command{
        Use:   "use <name>",
        Short: "Set active profile (rewrites config.yaml)",
        Args:  cobra.ExactArgs(1),
        RunE: func(cmd *cobra.Command, args []string) error {
            r, err := resolveRoot(root)
            if err != nil {
                return err
            }
            target := args[0]
            profPath := filepath.Join(r, "profiles", target+".yaml")
            if _, err := os.Stat(profPath); err != nil {
                return fmt.Errorf("profile %q not found at %s", target, profPath)
            }
            configPath := filepath.Join(r, "config.yaml")
            existing, _ := os.ReadFile(configPath)
            updated := setActiveProfile(string(existing), target)
            return os.WriteFile(configPath, []byte(updated), 0o644)
        },
    }
    rootFlag(use)

    cmd := &cobra.Command{Use: "profile", Short: "Manage profiles"}
    cmd.AddCommand(list, use)
    return cmd
}

func resolveRoot(flag string) (string, error) {
    if flag != "" {
        return flag, nil
    }
    h, err := os.UserHomeDir()
    if err != nil {
        return "", err
    }
    return filepath.Join(h, ".config", "harness-sync"), nil
}

func setActiveProfile(existing, name string) string {
    lines := strings.Split(existing, "\n")
    for i, l := range lines {
        if strings.HasPrefix(l, "active_profile:") {
            lines[i] = "active_profile: " + name
            return strings.Join(lines, "\n")
        }
    }
    return strings.TrimRight(existing, "\n") + "\nactive_profile: " + name + "\n"
}
  • Step 4: Wire into main + run tests

Add root.AddCommand(cli.NewProfile()).

Run: go test ./internal/cli/... Expected: PASS

  • Step 5: Commit
git add internal/cli cmd
git commit -m "feat(cli): profile list and profile use subcommands"

Task 23: rollback + adapter list commands

Files:

  • Create: internal/cli/rollback.go

  • Create: internal/cli/adapter.go

  • Step 1: Implement rollback command

internal/cli/rollback.go:

package cli

import (
    "strconv"

    "github.com/spf13/cobra"

    "github.com/lukaszraczylo/harness-sync/internal/gitx"
)

func NewRollback() *cobra.Command {
    var root string
    cmd := &cobra.Command{
        Use:   "rollback [n]",
        Short: "Revert the last N apply commits in the canonical repo",
        Args:  cobra.MaximumNArgs(1),
        RunE: func(cmd *cobra.Command, args []string) error {
            r, err := resolveRoot(root)
            if err != nil {
                return err
            }
            n := 1
            if len(args) == 1 {
                v, err := strconv.Atoi(args[0])
                if err != nil || v < 1 {
                    return cmd.Help()
                }
                n = v
            }
            return gitx.New(r).Revert(n)
        },
    }
    cmd.Flags().StringVar(&root, "root", "", "canonical root")
    return cmd
}
  • Step 2: Implement adapter list command

internal/cli/adapter.go:

package cli

import (
    "fmt"

    "github.com/spf13/cobra"

    "github.com/lukaszraczylo/harness-sync/internal/adapter"
)

func NewAdapter(reg *adapter.Registry) *cobra.Command {
    list := &cobra.Command{
        Use:   "list",
        Short: "List registered adapters",
        RunE: func(cmd *cobra.Command, _ []string) error {
            for _, a := range reg.All() {
                fmt.Fprintln(cmd.OutOrStdout(), a.Name())
            }
            return nil
        },
    }
    cmd := &cobra.Command{Use: "adapter", Short: "Adapter introspection"}
    cmd.AddCommand(list)
    return cmd
}
  • Step 3: Wire both into main

Add to cmd/harness-sync/main.go:

root.AddCommand(cli.NewRollback())
root.AddCommand(cli.NewAdapter(reg))
  • Step 4: Run tests + build

Run: go test ./... && go build ./... Expected: PASS

  • Step 5: Commit
git add internal/cli cmd
git commit -m "feat(cli): rollback and adapter list subcommands"

Task 24: init command (import flow)

Files:

  • Create: internal/cli/init.go

  • Create: internal/cli/init_test.go

  • Create: internal/ui/prompts.go

  • Create: internal/ui/prompts_test.go

  • Step 1: Write the failing UI tests

internal/ui/prompts_test.go:

package ui

import (
    "testing"

    "github.com/stretchr/testify/assert"
)

func TestMultiSelectNonInteractive(t *testing.T) {
    sel, err := MultiSelect("pick", []string{"a", "b", "c"}, WithNonInteractive([]string{"a", "c"}))
    assert.NoError(t, err)
    assert.Equal(t, []string{"a", "c"}, sel)
}

func TestMultiSelectNonInteractiveInvalidChoice(t *testing.T) {
    _, err := MultiSelect("pick", []string{"a", "b"}, WithNonInteractive([]string{"z"}))
    assert.Error(t, err)
}
  • Step 2: Run test to verify it fails

Run: go test ./internal/ui/... Expected: FAIL with undefined: MultiSelect

  • Step 3: Implement prompts with a non-interactive override for tests

internal/ui/prompts.go:

package ui

import (
    "fmt"

    "github.com/charmbracelet/huh"
)

type msOpts struct {
    nonInteractive []string
    interactive    bool
}

type Option func(*msOpts)

func WithNonInteractive(choices []string) Option {
    return func(o *msOpts) {
        o.nonInteractive = choices
        o.interactive = false
    }
}

func MultiSelect(title string, choices []string, opts ...Option) ([]string, error) {
    o := &msOpts{interactive: true}
    for _, fn := range opts {
        fn(o)
    }
    if !o.interactive {
        valid := map[string]bool{}
        for _, c := range choices {
            valid[c] = true
        }
        for _, c := range o.nonInteractive {
            if !valid[c] {
                return nil, fmt.Errorf("choice %q not in %v", c, choices)
            }
        }
        return o.nonInteractive, nil
    }
    var picked []string
    opts2 := make([]huh.Option[string], 0, len(choices))
    for _, c := range choices {
        opts2 = append(opts2, huh.NewOption(c, c))
    }
    form := huh.NewForm(huh.NewGroup(
        huh.NewMultiSelect[string]().
            Title(title).
            Options(opts2...).
            Value(&picked),
    ))
    if err := form.Run(); err != nil {
        return nil, err
    }
    return picked, nil
}
  • Step 4: Run tests to verify they pass

Run: go test ./internal/ui/... Expected: PASS

  • Step 5: Write failing init test

internal/cli/init_test.go:

package cli

import (
    "os"
    "path/filepath"
    "testing"

    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"

    "github.com/lukaszraczylo/harness-sync/internal/adapter"
    "github.com/lukaszraczylo/harness-sync/internal/canonical"
)

type importableAdapter struct {
    name string
    res  *adapter.ImportResult
}

func (i *importableAdapter) Name() string                                  { return i.name }
func (i *importableAdapter) Detect() bool                                  { return true }
func (i *importableAdapter) Render(_ *canonical.Bundle) (*adapter.FileSet, error) { return adapter.NewFileSet(), nil }
func (i *importableAdapter) Import(_ string) (*adapter.ImportResult, error)        { return i.res, nil }

func TestInitImportWritesCanonical(t *testing.T) {
    root := t.TempDir()
    home := t.TempDir()

    reg := adapter.NewRegistry()
    reg.Register(&importableAdapter{
        name: "stub",
        res: &adapter.ImportResult{
            Skills:       []canonical.Skill{{Name: "hi", Body: "---\nname: hi\n---\nhi body", Path: "hi/SKILL.md"}},
            Instructions: "# global",
            MCP:          []canonical.MCPServer{{Name: "filepuff", Command: "/bin/x"}},
        },
    })

    cmd := NewInit(reg)
    cmd.SetArgs([]string{"--root", root, "--home", home, "--from", "stub", "--no-prompt"})
    require.NoError(t, cmd.Execute())

    assert.FileExists(t, filepath.Join(root, "config.yaml"))
    assert.FileExists(t, filepath.Join(root, "instructions", "global.md"))
    assert.FileExists(t, filepath.Join(root, "mcp.yaml"))
    assert.FileExists(t, filepath.Join(root, "skills", "hi", "SKILL.md"))
    _, err := os.Stat(filepath.Join(root, ".git"))
    require.NoError(t, err)
}
  • Step 6: Run test to verify it fails

Run: go test ./internal/cli/... Expected: FAIL with undefined: NewInit

  • Step 7: Implement init command

internal/cli/init.go:

package cli

import (
    "fmt"
    "os"
    "path/filepath"
    "strings"

    "github.com/spf13/cobra"

    "github.com/lukaszraczylo/harness-sync/internal/adapter"
    "github.com/lukaszraczylo/harness-sync/internal/canonical"
    "github.com/lukaszraczylo/harness-sync/internal/gitx"
    "github.com/lukaszraczylo/harness-sync/internal/render"
    "github.com/lukaszraczylo/harness-sync/internal/ui"
)

func NewInit(reg *adapter.Registry) *cobra.Command {
    var (
        root      string
        home      string
        from      []string
        noPrompt  bool
    )
    cmd := &cobra.Command{
        Use:   "init",
        Short: "Initialise canonical config from existing harnesses",
        RunE: func(cmd *cobra.Command, _ []string) error {
            r, err := resolveRoot(root)
            if err != nil {
                return err
            }
            if home == "" {
                home, err = os.UserHomeDir()
                if err != nil {
                    return err
                }
            }
            if err := os.MkdirAll(r, 0o755); err != nil {
                return err
            }
            candidates := reg.DetectedNames()
            if len(candidates) == 0 {
                return fmt.Errorf("no harnesses detected under %s", home)
            }
            var picked []string
            if len(from) > 0 {
                picked = from
            } else if noPrompt {
                picked = candidates
            } else {
                picked, err = ui.MultiSelect("Import from which harnesses?", candidates)
                if err != nil {
                    return err
                }
            }
            return runImport(cmd, reg, r, home, picked)
        },
    }
    cmd.Flags().StringVar(&root, "root", "", "canonical root (default ~/.config/harness-sync)")
    cmd.Flags().StringVar(&home, "home", "", "home dir (default $HOME)")
    cmd.Flags().StringSliceVar(&from, "from", nil, "import from specific adapter(s), skip prompt")
    cmd.Flags().BoolVar(&noPrompt, "no-prompt", false, "import from all detected adapters without prompting")
    return cmd
}

func runImport(cmd *cobra.Command, reg *adapter.Registry, root, home string, picked []string) error {
    bundle := &canonical.Bundle{
        Root:   root,
        Config: canonical.Config{ActiveProfile: "imported"},
    }
    seenSkills := map[string]bool{}
    seenAgents := map[string]bool{}
    seenMCP := map[string]bool{}
    var instructions []string

    for _, name := range picked {
        ad, ok := reg.Get(name)
        if !ok {
            return fmt.Errorf("unknown adapter %q", name)
        }
        res, err := ad.Import(home)
        if err != nil {
            return fmt.Errorf("import %s: %w", name, err)
        }
        for _, s := range res.Skills {
            key := s.Name + "|" + s.Body
            if seenSkills[key] {
                continue
            }
            seenSkills[key] = true
            bundle.Skills = append(bundle.Skills, s)
        }
        for _, a := range res.Agents {
            key := a.Name + "|" + a.Body
            if seenAgents[key] {
                continue
            }
            seenAgents[key] = true
            bundle.Agents = append(bundle.Agents, a)
        }
        for _, m := range res.MCP {
            if seenMCP[m.Name] {
                continue
            }
            seenMCP[m.Name] = true
            bundle.MCP.Servers = append(bundle.MCP.Servers, m)
        }
        if res.Instructions != "" {
            instructions = append(instructions, fmt.Sprintf("<!-- from %s -->\n%s", name, res.Instructions))
        }
    }
    bundle.Instructions.Global = strings.Join(instructions, "\n\n")

    if err := writeCanonical(root, bundle, picked); err != nil {
        return err
    }
    repo := gitx.New(root)
    if !repo.IsRepo() {
        if err := repo.Init(); err != nil {
            return err
        }
        if err := repo.Configure("harness-sync", "harness-sync@localhost"); err != nil {
            return err
        }
    }
    if err := repo.AddAll(); err != nil {
        return err
    }
    if err := repo.Commit(fmt.Sprintf("import from %s", strings.Join(picked, ", "))); err != nil {
        return err
    }
    fmt.Fprintf(cmd.OutOrStdout(), "imported from %v into %s\n", picked, root)
    return nil
}

func writeCanonical(root string, b *canonical.Bundle, picked []string) error {
    if err := os.MkdirAll(filepath.Join(root, "profiles"), 0o755); err != nil {
        return err
    }
    if err := os.MkdirAll(filepath.Join(root, "skills"), 0o755); err != nil {
        return err
    }
    if err := os.MkdirAll(filepath.Join(root, "agents"), 0o755); err != nil {
        return err
    }
    if err := os.MkdirAll(filepath.Join(root, "instructions"), 0o755); err != nil {
        return err
    }

    cfgBody, err := render.YAML(map[string]any{
        "enabled_harnesses": picked,
        "active_profile":    "imported",
    })
    if err != nil {
        return err
    }
    if err := os.WriteFile(filepath.Join(root, "config.yaml"), cfgBody, 0o644); err != nil {
        return err
    }

    profBody, err := render.YAML(canonical.Profile{
        Name: "imported",
        Gateway: canonical.Gateway{
            URL:          "https://gateway.local",
            Token:        "dummy",
            DefaultModel: "claude-sonnet-4-6",
        },
        Models: []canonical.Model{{ID: "claude-sonnet-4-6", Alias: "sonnet"}},
    })
    if err != nil {
        return err
    }
    if err := os.WriteFile(filepath.Join(root, "profiles", "imported.yaml"), profBody, 0o644); err != nil {
        return err
    }

    if len(b.MCP.Servers) > 0 {
        mcpBody, err := render.YAML(b.MCP)
        if err != nil {
            return err
        }
        if err := os.WriteFile(filepath.Join(root, "mcp.yaml"), mcpBody, 0o644); err != nil {
            return err
        }
    }

    for _, s := range b.Skills {
        path := filepath.Join(root, "skills", s.Name, "SKILL.md")
        if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
            return err
        }
        if err := os.WriteFile(path, []byte(s.Body), 0o644); err != nil {
            return err
        }
    }
    for _, a := range b.Agents {
        path := filepath.Join(root, "agents", a.Name+".md")
        if err := os.WriteFile(path, []byte(a.Body), 0o644); err != nil {
            return err
        }
    }
    if b.Instructions.Global != "" {
        if err := os.WriteFile(filepath.Join(root, "instructions", "global.md"),
            []byte(b.Instructions.Global), 0o644); err != nil {
            return err
        }
    }
    return nil
}
  • Step 8: Wire into main + run all tests

Add root.AddCommand(cli.NewInit(reg)).

Run: go test ./... && go build ./... Expected: PASS

  • Step 9: Commit
git add internal/cli internal/ui cmd
git commit -m "feat(cli): init subcommand imports detected harnesses with dedupe + git commit"

Phase 7: Integration test + polish

Task 25: End-to-end integration test

Files:

  • Create: tests/e2e/e2e_test.go

  • Step 1: Write the integration test

tests/e2e/e2e_test.go:

package e2e

import (
    "os"
    "os/exec"
    "path/filepath"
    "testing"

    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func TestInitApplyDiffCycle(t *testing.T) {
    bin := buildBinary(t)
    home := t.TempDir()
    root := filepath.Join(home, ".config", "harness-sync")

    // Fake a claude-code install
    require.NoError(t, os.MkdirAll(filepath.Join(home, ".claude", "skills", "hi"), 0o755))
    require.NoError(t, os.WriteFile(
        filepath.Join(home, ".claude", "skills", "hi", "SKILL.md"),
        []byte("---\nname: hi\n---\nbody"), 0o644))

    run := func(args ...string) ([]byte, error) {
        cmd := exec.Command(bin, args...)
        cmd.Env = append(os.Environ(), "HOME="+home)
        return cmd.CombinedOutput()
    }

    out, err := run("init", "--no-prompt")
    require.NoError(t, err, string(out))
    assert.FileExists(t, filepath.Join(root, "config.yaml"))
    assert.FileExists(t, filepath.Join(root, "skills", "hi", "SKILL.md"))

    out, err = run("apply")
    require.NoError(t, err, string(out))

    out, err = run("diff")
    require.NoError(t, err, string(out))
    assert.NotContains(t, string(out), "conflict")
}

func buildBinary(t *testing.T) string {
    t.Helper()
    bin := filepath.Join(t.TempDir(), "harness-sync")
    cmd := exec.Command("go", "build", "-o", bin, "./cmd/harness-sync")
    cmd.Dir = "../.."
    out, err := cmd.CombinedOutput()
    require.NoError(t, err, string(out))
    return bin
}
  • Step 2: Run the test

Run: go test ./tests/e2e/... -count=1 Expected: PASS

  • Step 3: Commit
git add tests/e2e
git commit -m "test(e2e): init-apply-diff cycle through real binary"

Task 26: README and final polish

Files:

  • Modify: README.md

  • Step 1: Write user-facing README

Replace README.md with:

# harness-sync

Sync skills, agents, MCP, and LLM endpoints across multiple LLM harnesses.

Canonical config lives in `~/.config/harness-sync/`, tracked in git.
On `apply`, harness-sync renders the canonical config into each detected
harness's native layout (symlinks where shapes match, transformed files
where they differ) and uses `git merge-file` to resolve conflicts.

## Supported harnesses

- claude-code   (`~/.claude/`)
- crush         (`~/.config/crush/`)
- kilo          (`~/.config/kilo/`)
- opencode      (`~/.config/opencode/`)
- goose         (`~/.config/goose/`)
- cagent        (`~/.config/cagent/`)
- zed           (`~/.config/zed/`)

Add new harnesses by writing a Go package under `internal/adapters/`
and registering it in `cmd/harness-sync/main.go`.

## Install

    make build && make install

## First run

    harness-sync detect          # see what's installed
    harness-sync init            # pick which to import from; canonical tree created
    harness-sync apply           # propagate to all detected harnesses

## Day-to-day

    harness-sync diff            # show pending changes
    harness-sync apply           # apply them
    harness-sync profile use work
    harness-sync rollback 1      # revert last apply

## Conflict resolution

If you've edited a file harness-sync manages and `apply` cannot merge
cleanly, the merged-with-markers result is written next to the target
as `<file>.rej`. Resolve, copy back, run `apply` again.

## Design

See [`docs/superpowers/specs/2026-05-23-harness-sync-design.md`](docs/superpowers/specs/2026-05-23-harness-sync-design.md).
  • Step 2: Final build and test

Run: go test ./... && go build ./... && ./harness-sync --help Expected: PASS; help output lists init/detect/diff/apply/rollback/profile/adapter

  • Step 3: Commit
git add README.md
git commit -m "docs: user-facing README with install, first-run, and conflict guidance"

Self-review checklist (for the implementer at the end)

  • Every spec section maps to at least one task: canonical layout (Tasks 2–3), profile model (Task 3 fixtures + Task 24), adapter interface (Tasks 5–6), sync mechanics per asset (Tasks 11–18), commands (Tasks 19–24), conflict handling (Task 10), import flow (Task 24), error handling (apply pipeline in Task 10), testing strategy (per-task tests + Task 25), module layout (matches Tasks 1–26).
  • No TBD, no add appropriate error handling placeholders. All adapter-specific schema steps include the inspection command and constrain the engineer to "read the actual config files; match the keys."
  • Type names consistent across tasks: Bundle, Profile, MCPRegistry, MCPServer, Adapter, FileSet, File, Kind, Registry, ImportResult, apply.Options, apply.Report, apply.Action, gitx.Repo, merge.Inputs, merge.Result.
  • Each task includes failing test → implementation → passing test → commit.

Acceptance criteria (mapped from spec)

  1. harness-sync init on a fresh machine populates ~/.config/harness-sync/ from selected harnesses (Task 24, Task 25).
  2. harness-sync apply renders correct native files; subsequent harness-sync diff reports zero changes (Tasks 20, 21, 25).
  3. After hand-editing a synced file, the next apply 3-way-merges or writes .rej (Task 10 conflict test + Task 25).
  4. harness-sync profile use work switches profile (Task 22); next apply re-renders LLM configs.
  5. Adding a new harness: new package + one registry line in cmd/harness-sync/main.go (Tasks 13–18 demonstrate the pattern).
  6. Every adapter has golden-file tests; integration test in Task 25 covers init → apply → diff.