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).
273 lines
7.8 KiB
Go
273 lines
7.8 KiB
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"
|
|
)
|
|
|
|
// NewInit returns the `init` subcommand: scan for detected harnesses, import
|
|
// their content into a canonical tree, and produce an initial git commit.
|
|
func NewInit(reg *adapter.Registry) *cobra.Command {
|
|
var (
|
|
root string
|
|
home string
|
|
from []string
|
|
noPrompt bool
|
|
force bool
|
|
)
|
|
cmd := &cobra.Command{
|
|
Use: "init",
|
|
Short: "Initialise canonical config from existing harnesses",
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
var err 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, 0o750); err != nil {
|
|
return err
|
|
}
|
|
// Issue 1: refuse overwrite without --force
|
|
if _, statErr := os.Stat(filepath.Join(r, "config.yaml")); statErr == nil && !force {
|
|
return fmt.Errorf("canonical root %s already initialised (use --force to re-import; existing skills/agents/mcp will be merged)", r)
|
|
}
|
|
candidates := reg.DetectedNames()
|
|
if len(candidates) == 0 {
|
|
return fmt.Errorf("no harnesses detected under %s", home)
|
|
}
|
|
var picked []string
|
|
switch {
|
|
case len(from) > 0:
|
|
for _, f := range from {
|
|
if f != "" {
|
|
picked = append(picked, f)
|
|
}
|
|
}
|
|
case noPrompt:
|
|
picked = candidates
|
|
default:
|
|
picked, err = ui.MultiSelect("Import from which harnesses?", candidates)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if len(picked) == 0 {
|
|
return fmt.Errorf("no harnesses selected; rerun with --no-prompt or --from <name,name>")
|
|
}
|
|
for _, name := range picked {
|
|
if _, ok := reg.Get(name); !ok {
|
|
return fmt.Errorf("unknown adapter %q (try `harness-sync adapter list`)", name)
|
|
}
|
|
}
|
|
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")
|
|
cmd.Flags().BoolVar(&force, "force", false, "re-import even if canonical root already initialised")
|
|
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{}
|
|
seenRules := 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 _, r := range res.Rules {
|
|
key := r.Name + "|" + r.Body
|
|
if seenRules[key] {
|
|
continue
|
|
}
|
|
seenRules[key] = true
|
|
bundle.Rules = append(bundle.Rules, r)
|
|
}
|
|
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 skills: %d\n agents: %d\n rules: %d\n mcp servers: %d\n instructions: %d source(s)\n",
|
|
picked, root, len(bundle.Skills), len(bundle.Agents), len(bundle.Rules), len(bundle.MCP.Servers), len(instructions),
|
|
)
|
|
return nil
|
|
}
|
|
|
|
// importedProfileBody is the placeholder written to profiles/imported.yaml on first init.
|
|
// Written using escape sequences to avoid literal newlines in string literals.
|
|
const importedProfileBody = "# harness-sync profile \xe2\x80\x94 generated by init\n" +
|
|
"# Edit the gateway block below to point at your LLM gateway before running\n" +
|
|
"# `harness-sync apply`. The dummy token is fine for most local gateways.\n" +
|
|
"name: imported\n" +
|
|
"gateway:\n" +
|
|
" url: \"\" # \xe2\x86\x90 set this\n" +
|
|
" token: \"dummy\"\n" +
|
|
" default_model: \"\" # \xe2\x86\x90 set this\n" +
|
|
"models: []\n"
|
|
|
|
func writeCanonical(root string, b *canonical.Bundle, picked []string) error {
|
|
for _, sub := range []string{"profiles", "skills", "agents", "rules", "instructions"} {
|
|
if err := os.MkdirAll(filepath.Join(root, sub), 0o750); 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, 0o600); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Issue 2: write placeholder profile only if it doesn't exist yet (preserve user edits on re-init).
|
|
profPath := filepath.Join(root, "profiles", "imported.yaml")
|
|
if _, statErr := os.Stat(profPath); os.IsNotExist(statErr) {
|
|
if err = os.WriteFile(profPath, []byte(importedProfileBody), 0o600); 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, 0o600); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
for _, s := range b.Skills {
|
|
rel := s.Path
|
|
if rel == "" {
|
|
rel = filepath.Join(safeFilename(s.Name), "SKILL.md")
|
|
}
|
|
path := filepath.Join(root, "skills", rel)
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(path, []byte(s.Body), 0o600); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, a := range b.Agents {
|
|
rel := a.Path
|
|
if rel == "" {
|
|
rel = safeFilename(a.Name) + ".md"
|
|
}
|
|
path := filepath.Join(root, "agents", rel)
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(path, []byte(a.Body), 0o600); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, r := range b.Rules {
|
|
rel := r.Path
|
|
if rel == "" {
|
|
rel = safeFilename(r.Name) + ".md"
|
|
}
|
|
path := filepath.Join(root, "rules", rel)
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(path, []byte(r.Body), 0o600); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if b.Instructions.Global != "" {
|
|
if err := os.WriteFile(filepath.Join(root, "instructions", "global.md"),
|
|
[]byte(b.Instructions.Global), 0o600); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// safeFilename strips path separators and other characters that would break
|
|
// when used as a basename.
|
|
func safeFilename(s string) string {
|
|
r := strings.NewReplacer("/", "-", "\\", "-", ":", "-", "\x00", "")
|
|
return r.Replace(s)
|
|
}
|