mirror of
https://github.com/lukaszraczylo/harness-sync.git
synced 2026-10-12 00:45:22 +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).
137 lines
4.2 KiB
Go
137 lines
4.2 KiB
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 {
|
|
res *adapter.ImportResult
|
|
name string
|
|
}
|
|
|
|
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 (i *importableAdapter) Capabilities() adapter.HarnessCapabilities {
|
|
return adapter.HarnessCapabilities{ManagesMCP: true}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
func TestInitImportsRules(t *testing.T) {
|
|
root := t.TempDir()
|
|
home := t.TempDir()
|
|
|
|
reg := adapter.NewRegistry()
|
|
reg.Register(&importableAdapter{
|
|
name: "stub",
|
|
res: &adapter.ImportResult{
|
|
Rules: []canonical.Rule{
|
|
{Name: "go", Body: "# Go\n\ngofmt", Path: "go.md"},
|
|
{Name: "py", Body: "# Py\n\ntypes", Path: "py.md"},
|
|
},
|
|
},
|
|
})
|
|
|
|
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, "rules", "go.md"))
|
|
assert.FileExists(t, filepath.Join(root, "rules", "py.md"))
|
|
|
|
// Round-trips back through the loader.
|
|
b, err := canonical.Load(root)
|
|
require.NoError(t, err)
|
|
require.Len(t, b.Rules, 2)
|
|
}
|
|
|
|
func makeStubReg() *adapter.Registry {
|
|
reg := adapter.NewRegistry()
|
|
reg.Register(&importableAdapter{
|
|
name: "stub",
|
|
res: &adapter.ImportResult{},
|
|
})
|
|
return reg
|
|
}
|
|
|
|
// TestInitRefusesOverwriteWithoutForce: second init without --force must error.
|
|
func TestInitRefusesOverwriteWithoutForce(t *testing.T) {
|
|
root := t.TempDir()
|
|
home := t.TempDir()
|
|
|
|
// First init — should succeed.
|
|
cmd := NewInit(makeStubReg())
|
|
cmd.SetArgs([]string{"--root", root, "--home", home, "--from", "stub", "--no-prompt"})
|
|
require.NoError(t, cmd.Execute())
|
|
|
|
// Second init — must fail without --force.
|
|
cmd2 := NewInit(makeStubReg())
|
|
cmd2.SetArgs([]string{"--root", root, "--home", home, "--from", "stub", "--no-prompt"})
|
|
err := cmd2.Execute()
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "already initialised")
|
|
assert.Contains(t, err.Error(), "--force")
|
|
}
|
|
|
|
// TestInitForcePreservesExistingProfile: --force re-imports but does NOT overwrite imported.yaml.
|
|
func TestInitForcePreservesExistingProfile(t *testing.T) {
|
|
root := t.TempDir()
|
|
home := t.TempDir()
|
|
|
|
// First init writes placeholder profile.
|
|
cmd := NewInit(makeStubReg())
|
|
cmd.SetArgs([]string{"--root", root, "--home", home, "--from", "stub", "--no-prompt"})
|
|
require.NoError(t, cmd.Execute())
|
|
|
|
// User edits the profile.
|
|
profPath := filepath.Join(root, "profiles", "imported.yaml")
|
|
userEdit := []byte("name: imported\ngateway:\n url: \"https://my.gateway\"\n token: \"tok\"\n default_model: \"m\"\nmodels: []\n")
|
|
require.NoError(t, os.WriteFile(profPath, userEdit, 0o600))
|
|
|
|
// Re-init with --force.
|
|
cmd2 := NewInit(makeStubReg())
|
|
cmd2.SetArgs([]string{"--root", root, "--home", home, "--from", "stub", "--no-prompt", "--force"})
|
|
require.NoError(t, cmd2.Execute())
|
|
|
|
// User edits must be preserved.
|
|
got, err := os.ReadFile(profPath)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, string(userEdit), string(got))
|
|
}
|