mirror of
https://github.com/lukaszraczylo/harness-sync.git
synced 2026-10-14 00:57:49 +00:00
Add Rule as a first-class canonical kind (rules/<name>.md, loaded like agents) and propagate it the way each harness actually loads it: - claude-code: native ~/.claude/rules directory symlink (preserves rule path-scoping frontmatter); rules NOT folded into CLAUDE.md. - opencode/kilo/zed/crush: fold rule bodies into the global AGENTS.md always-on instructions file (frontmatter stripped). crush has no global instructions file, so register the absolute AGENTS.md path in options.context_paths (defaults are re-prepended at load, so nothing is clobbered). - goose: fold into ~/.config/goose/.goosehints. - cagent: fold into the per-agent instruction. Also: - Wire markdown agents to opencode (~/.config/opencode/agents) and fix the kilo agents symlink: plural ~/.config/kilo/agents (singular "agent" is a silent no-op on current Kilo CLI). - Add ManagesAgents/ManagesRules to HarnessCapabilities + ImportResult.Rules; claude-code/opencode import rules/agents round-trip. - Centralize instruction selection in Bundle.InstructionText / InstructionTextWithRules (replaces per-adapter override duplication). - init scaffolds + imports rules; README capability matrix gains Agents/Rules columns with native-vs-folded footnotes. Grounded each harness's sink against official docs + config source this session. Gate: gofmt, go vet, go test -race ./..., golangci-lint (0), govulncheck (clean).
108 lines
3.3 KiB
Go
108 lines
3.3 KiB
Go
// Package e2e runs the harness-sync binary end-to-end against fake harness
|
|
// installs in a temp HOME.
|
|
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 with one skill and one rule
|
|
require.NoError(t, os.MkdirAll(filepath.Join(home, ".claude", "skills", "hi"), 0o750))
|
|
require.NoError(t, os.WriteFile(
|
|
filepath.Join(home, ".claude", "skills", "hi", "SKILL.md"),
|
|
[]byte("---\nname: hi\n---\nbody"), 0o600))
|
|
require.NoError(t, os.MkdirAll(filepath.Join(home, ".claude", "rules"), 0o750))
|
|
require.NoError(t, os.WriteFile(
|
|
filepath.Join(home, ".claude", "rules", "go.md"),
|
|
[]byte("# Go Rules\n\ngofmt always"), 0o600))
|
|
|
|
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"))
|
|
assert.FileExists(t, filepath.Join(root, "rules", "go.md"))
|
|
|
|
// apply may produce conflicts on a freshly imported tree (because the
|
|
// claude-code adapter merges into existing settings.json which differs
|
|
// from canonical); accept either clean exit or conflict exit.
|
|
// --allow-incomplete: the placeholder profile has empty URL by design.
|
|
// --yes: skip first-run TTY prompt in CI.
|
|
out, err = run("apply", "--allow-incomplete", "--yes")
|
|
if err != nil {
|
|
// Apply with conflicts is acceptable — verify the binary at least ran
|
|
assert.Contains(t, string(out), "applied")
|
|
} else {
|
|
assert.Contains(t, string(out), "applied")
|
|
}
|
|
|
|
// Diff should always run cleanly (it's read-only)
|
|
out, err = run("diff")
|
|
require.NoError(t, err, string(out))
|
|
}
|
|
|
|
func TestShowEnumeratesTargets(t *testing.T) {
|
|
bin := buildBinary(t)
|
|
home := t.TempDir()
|
|
root := filepath.Join(home, ".config", "harness-sync")
|
|
|
|
require.NoError(t, os.MkdirAll(filepath.Join(home, ".claude", "skills"), 0o750))
|
|
|
|
run := func(args ...string) ([]byte, error) {
|
|
cmd := exec.Command(bin, args...)
|
|
cmd.Env = append(os.Environ(), "HOME="+home)
|
|
return cmd.CombinedOutput()
|
|
}
|
|
|
|
_, err := run("init", "--no-prompt")
|
|
require.NoError(t, err)
|
|
_ = root // root is implied by HOME
|
|
|
|
out, err := run("show")
|
|
require.NoError(t, err, string(out))
|
|
assert.Contains(t, string(out), "claude-code")
|
|
}
|
|
|
|
func TestDetectListsAllAdapters(t *testing.T) {
|
|
bin := buildBinary(t)
|
|
home := t.TempDir()
|
|
|
|
cmd := exec.Command(bin, "detect")
|
|
cmd.Env = append(os.Environ(), "HOME="+home)
|
|
out, err := cmd.CombinedOutput()
|
|
require.NoError(t, err, string(out))
|
|
|
|
// None should be detected in an empty HOME
|
|
s := string(out)
|
|
for _, name := range []string{"claude-code", "crush", "kilo", "opencode", "goose", "cagent", "zed"} {
|
|
assert.Contains(t, s, name)
|
|
}
|
|
assert.Contains(t, s, "not detected")
|
|
}
|
|
|
|
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
|
|
}
|