diff --git a/internal/adapters/claudecode/claudecode.go b/internal/adapters/claudecode/claudecode.go new file mode 100644 index 0000000..55a136a --- /dev/null +++ b/internal/adapters/claudecode/claudecode.go @@ -0,0 +1,120 @@ +// Package claudecode is the claude-code harness adapter. +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" + +// Adapter implements adapter.Adapter for the claude-code harness. +type Adapter struct { + home string +} + +// Option configures an Adapter. +type Option func(*Adapter) + +// WithHome overrides the home directory (defaults to os.UserHomeDir). +func WithHome(h string) Option { return func(a *Adapter) { a.home = h } } + +// New returns a new Adapter with the given options applied. +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 +} + +// Name returns the harness identifier. +func (a *Adapter) Name() string { return name } + +// Detect returns true when ~/.claude exists. +func (a *Adapter) Detect() bool { + _, err := os.Stat(filepath.Join(a.home, ".claude")) + return err == nil +} + +// Render produces the FileSet that applies the canonical bundle to claude-code. +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, + }) +} + +// Import reads claude-code config from home and returns a canonical ImportResult. +func (a *Adapter) Import(home string) (*adapter.ImportResult, error) { + return importFrom(home) +} diff --git a/internal/adapters/claudecode/claudecode_test.go b/internal/adapters/claudecode/claudecode_test.go new file mode 100644 index 0000000..2b76c55 --- /dev/null +++ b/internal/adapters/claudecode/claudecode_test.go @@ -0,0 +1,91 @@ +package claudecode + +import ( + "encoding/json" + "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" +) + +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) + settingsBody := string(seen[settingsDest].Content) + assert.Contains(t, settingsBody, "filepuff") + assert.Contains(t, settingsBody, "/bin/filepuff") +} + +func TestImport(t *testing.T) { + home := t.TempDir() + base := filepath.Join(home, ".claude") + require.NoError(t, os.MkdirAll(filepath.Join(base, "skills", "hello"), 0o750)) + require.NoError(t, os.MkdirAll(filepath.Join(base, "agents"), 0o750)) + + require.NoError(t, os.WriteFile( + filepath.Join(base, "skills", "hello", "SKILL.md"), + []byte("---\nname: hello\ndescription: x\n---\nbody"), 0o600)) + require.NoError(t, os.WriteFile( + filepath.Join(base, "agents", "agentA.md"), + []byte("---\nname: agentA\ndescription: y\n---\nagent body"), 0o600)) + require.NoError(t, os.WriteFile( + filepath.Join(base, "CLAUDE.md"), + []byte("# global"), 0o600)) + + 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, 0o600)) + + 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) +} diff --git a/internal/adapters/claudecode/import.go b/internal/adapters/claudecode/import.go new file mode 100644 index 0000000..b76a3ac --- /dev/null +++ b/internal/adapters/claudecode/import.go @@ -0,0 +1,173 @@ +package claudecode + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" + + "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{} + + skills, err := importMarkdownTree(filepath.Join(base, "skills"), "SKILL.md") + if err != nil { + return nil, err + } + for _, s := range skills { + res.Skills = append(res.Skills, canonical.Skill{ + Name: s.name, + Description: s.description, + Body: s.body, + Path: s.path, + }) + } + + agents, err := importMarkdownTree(filepath.Join(base, "agents"), "") + if err != nil { + return nil, err + } + for _, a := range agents { + res.Agents = append(res.Agents, canonical.Agent{ + Name: a.name, + Description: a.description, + Body: a.body, + Path: a.path, + }) + } + + body, err := readIfExists(filepath.Join(base, "CLAUDE.md")) + if err != nil { + return nil, err + } + res.Instructions = body + + servers, err := importMCPFromSettings(filepath.Join(base, "settings.json")) + if err != nil { + return nil, err + } + res.MCP = servers + return res, nil +} + +type mdDoc struct { + name string + description string + body string + path string +} + +func importMarkdownTree(dir, requiredFilename string) ([]mdDoc, error) { + var docs []mdDoc + if !dirExists(dir) { + return docs, nil + } + err := 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 + } + n, desc := parseFrontmatter(body) + if n == "" { + n = strings.TrimSuffix(filepath.Base(p), ".md") + } + rel, _ := filepath.Rel(dir, p) + docs = append(docs, mdDoc{ + name: n, + description: desc, + body: string(body), + path: rel, + }) + return nil + }) + return docs, err +} + +type settingsServer struct { //nolint:govet // fieldalignment not achievable: map+slice+3 strings = 80 bytes regardless of order + Env map[string]string `json:"env"` + Args []string `json:"args"` + Command string `json:"command"` + URL string `json:"url"` + Transport string `json:"transport"` +} + +type settingsFile struct { + MCPServers map[string]settingsServer `json:"mcpServers"` +} + +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 d settingsFile + if err := json.Unmarshal(body, &d); err != nil { + return nil, err + } + out := make([]canonical.MCPServer, 0, len(d.MCPServers)) + for nm, v := range d.MCPServers { + out = append(out, canonical.MCPServer{ + Name: nm, + Command: v.Command, + Args: v.Args, + URL: v.URL, + Transport: v.Transport, + Env: v.Env, + }) + } + return out, nil +} + +func parseFrontmatter(b []byte) (n, 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() +}