diff --git a/go.mod b/go.mod index 3452581..d354bb0 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.26.3 require ( github.com/spf13/cobra v1.8.1 github.com/stretchr/testify v1.9.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -12,5 +13,4 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/pflag v1.0.5 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/internal/canonical/load.go b/internal/canonical/load.go new file mode 100644 index 0000000..ec596c6 --- /dev/null +++ b/internal/canonical/load.go @@ -0,0 +1,179 @@ +package canonical + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +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} + + err = loadYAML(filepath.Join(root, "config.yaml"), &b.Config) + if 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") + err = loadYAML(profPath, &b.Profile) + if err != nil { + return nil, err + } + + mcpPath := filepath.Join(root, "mcp.yaml") + if fileExists(mcpPath) { + err = loadYAML(mcpPath, &b.MCP) + if 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(m)) + } + + agents, err := loadMarkdownDir(filepath.Join(root, "agents"), "") + if err != nil { + return nil, err + } + for _, m := range agents { + b.Agents = append(b.Agents, Agent(m)) + } + + b.Instructions.PerHarness = map[string]string{} + body, err := readFileIfExists(filepath.Join(root, "instructions", "global.md")) + if err != nil { + return nil, err + } + 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(path), ".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() +} diff --git a/internal/canonical/load_test.go b/internal/canonical/load_test.go new file mode 100644 index 0000000..5ba1ab5 --- /dev/null +++ b/internal/canonical/load_test.go @@ -0,0 +1,35 @@ +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) +} diff --git a/internal/canonical/testdata/sample/agents/sample.md b/internal/canonical/testdata/sample/agents/sample.md new file mode 100644 index 0000000..7788dae --- /dev/null +++ b/internal/canonical/testdata/sample/agents/sample.md @@ -0,0 +1,5 @@ +--- +name: sample +description: sample agent +--- +sample body diff --git a/internal/canonical/testdata/sample/config.yaml b/internal/canonical/testdata/sample/config.yaml new file mode 100644 index 0000000..0d64042 --- /dev/null +++ b/internal/canonical/testdata/sample/config.yaml @@ -0,0 +1,2 @@ +enabled_harnesses: [claude-code, crush] +active_profile: home diff --git a/internal/canonical/testdata/sample/instructions/global.md b/internal/canonical/testdata/sample/instructions/global.md new file mode 100644 index 0000000..d0e0256 --- /dev/null +++ b/internal/canonical/testdata/sample/instructions/global.md @@ -0,0 +1,3 @@ +# Global instructions + +Be helpful. diff --git a/internal/canonical/testdata/sample/mcp.yaml b/internal/canonical/testdata/sample/mcp.yaml new file mode 100644 index 0000000..537d6e6 --- /dev/null +++ b/internal/canonical/testdata/sample/mcp.yaml @@ -0,0 +1,4 @@ +servers: + - name: filepuff + command: /usr/local/bin/filepuff + transport: stdio diff --git a/internal/canonical/testdata/sample/profiles/home.yaml b/internal/canonical/testdata/sample/profiles/home.yaml new file mode 100644 index 0000000..ffdb3ef --- /dev/null +++ b/internal/canonical/testdata/sample/profiles/home.yaml @@ -0,0 +1,8 @@ +name: home +gateway: + url: https://gw.lan + token: dummy + default_model: sonnet +models: + - id: claude-sonnet-4-6 + alias: sonnet diff --git a/internal/canonical/testdata/sample/skills/hello/SKILL.md b/internal/canonical/testdata/sample/skills/hello/SKILL.md new file mode 100644 index 0000000..fdde1ea --- /dev/null +++ b/internal/canonical/testdata/sample/skills/hello/SKILL.md @@ -0,0 +1,5 @@ +--- +name: hello +description: example skill +--- +hello body