mirror of
https://github.com/lukaszraczylo/harness-sync.git
synced 2026-10-12 00:45:22 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bd70bfaeb |
@@ -5,6 +5,7 @@ go 1.25
|
||||
require (
|
||||
github.com/charmbracelet/huh v1.0.0
|
||||
github.com/pelletier/go-toml/v2 v2.2.3
|
||||
github.com/spf13/afero v1.15.0
|
||||
github.com/spf13/cobra v1.8.1
|
||||
github.com/stretchr/testify v1.9.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
@@ -38,7 +39,7 @@ require (
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sync v0.15.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
)
|
||||
|
||||
@@ -71,6 +71,8 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
||||
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
@@ -81,14 +83,14 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -6,12 +6,15 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
|
||||
"github.com/lukaszraczylo/harness-sync/internal/canonical"
|
||||
"github.com/lukaszraczylo/harness-sync/internal/fsx"
|
||||
)
|
||||
|
||||
// ReadIfExists returns the file body or empty string when missing.
|
||||
func ReadIfExists(path string) (string, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
// ReadIfExistsFS returns the file body or empty string when missing, using fs.
|
||||
func ReadIfExistsFS(fs fsx.FS, path string) (string, error) {
|
||||
b, err := afero.ReadFile(fs, path)
|
||||
if os.IsNotExist(err) {
|
||||
return "", nil
|
||||
}
|
||||
@@ -21,10 +24,20 @@ func ReadIfExists(path string) (string, error) {
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// ReadIfExists returns the file body or empty string when missing.
|
||||
func ReadIfExists(path string) (string, error) {
|
||||
return ReadIfExistsFS(fsx.OS(), path)
|
||||
}
|
||||
|
||||
// DirExistsFS reports whether path is an existing directory on fs.
|
||||
func DirExistsFS(fs fsx.FS, path string) bool {
|
||||
info, err := fs.Stat(path)
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
|
||||
// DirExists reports whether path is an existing directory.
|
||||
func DirExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && info.IsDir()
|
||||
return DirExistsFS(fsx.OS(), path)
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -41,11 +54,10 @@ func StripJSONComments(s string) string {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// ImportMCPFromJSONFile reads a JSON file and extracts an MCP server map
|
||||
// nested under the given top-level key (e.g. "mcpServers", "mcp").
|
||||
// Returns nil with no error when the file is missing.
|
||||
func ImportMCPFromJSONFile(path, key string) ([]canonical.MCPServer, error) {
|
||||
body, err := os.ReadFile(path)
|
||||
// ImportMCPFromJSONFileFS reads a JSON file from fs and extracts an MCP server
|
||||
// map nested under the given top-level key. Returns nil with no error when missing.
|
||||
func ImportMCPFromJSONFileFS(fs fsx.FS, path, key string) ([]canonical.MCPServer, error) {
|
||||
body, err := afero.ReadFile(fs, path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -55,6 +67,13 @@ func ImportMCPFromJSONFile(path, key string) ([]canonical.MCPServer, error) {
|
||||
return ParseMCPFromJSON(body, key)
|
||||
}
|
||||
|
||||
// ImportMCPFromJSONFile reads a JSON file and extracts an MCP server map
|
||||
// nested under the given top-level key (e.g. "mcpServers", "mcp").
|
||||
// Returns nil with no error when the file is missing.
|
||||
func ImportMCPFromJSONFile(path, key string) ([]canonical.MCPServer, error) {
|
||||
return ImportMCPFromJSONFileFS(fsx.OS(), path, key)
|
||||
}
|
||||
|
||||
// ParseMCPFromJSON extracts MCP servers from a JSON body. The body may be
|
||||
// raw JSON or JSONC (comments are stripped first). Tolerates the dialect
|
||||
// differences across harnesses:
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package common_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/lukaszraczylo/harness-sync/internal/adapter/common"
|
||||
"github.com/lukaszraczylo/harness-sync/internal/fsx"
|
||||
)
|
||||
|
||||
func TestReadIfExistsFSMissingReturnsEmpty(t *testing.T) {
|
||||
mem := fsx.Mem()
|
||||
got, err := common.ReadIfExistsFS(mem, "/no-such-file.txt")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "", got)
|
||||
}
|
||||
|
||||
func TestReadIfExistsFSReturnsContent(t *testing.T) {
|
||||
mem := fsx.Mem()
|
||||
want := "hello from memfs"
|
||||
require.NoError(t, afero.WriteFile(mem, "/test.txt", []byte(want), 0o644))
|
||||
|
||||
got, err := common.ReadIfExistsFS(mem, "/test.txt")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, want, got)
|
||||
}
|
||||
|
||||
func TestImportMCPFromJSONFileFSHandlesMissingFile(t *testing.T) {
|
||||
mem := fsx.Mem()
|
||||
servers, err := common.ImportMCPFromJSONFileFS(mem, "/not-here.json", "mcpServers")
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, servers)
|
||||
}
|
||||
|
||||
func TestImportMCPFromJSONFileFSExtractsServers(t *testing.T) {
|
||||
mem := fsx.Mem()
|
||||
body := []byte(`{
|
||||
"mcpServers": {
|
||||
"myserver": {
|
||||
"command": "/usr/bin/mytool",
|
||||
"args": ["--flag"],
|
||||
"type": "stdio"
|
||||
}
|
||||
}
|
||||
}`)
|
||||
require.NoError(t, afero.WriteFile(mem, "/claude.json", body, 0o644))
|
||||
|
||||
servers, err := common.ImportMCPFromJSONFileFS(mem, "/claude.json", "mcpServers")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, servers, 1)
|
||||
assert.Equal(t, "myserver", servers[0].Name)
|
||||
assert.Equal(t, "/usr/bin/mytool", servers[0].Command)
|
||||
assert.Equal(t, []string{"--flag"}, servers[0].Args)
|
||||
assert.Equal(t, "stdio", servers[0].Transport)
|
||||
}
|
||||
|
||||
func TestImportMCPFromJSONFileFSReadOnlyFS(t *testing.T) {
|
||||
// Populate a mem fs then wrap it read-only — reads should still work.
|
||||
mem := fsx.Mem()
|
||||
body := []byte(`{"mcpServers": {"srv": {"command": "bin"}}}`)
|
||||
require.NoError(t, afero.WriteFile(mem, "/config.json", body, 0o644))
|
||||
|
||||
roFS := afero.NewReadOnlyFs(mem)
|
||||
servers, err := common.ImportMCPFromJSONFileFS(roFS, "/config.json", "mcpServers")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, servers, 1)
|
||||
assert.Equal(t, "srv", servers[0].Name)
|
||||
}
|
||||
@@ -5,7 +5,10 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/lukaszraczylo/harness-sync/internal/fsx"
|
||||
)
|
||||
|
||||
// Doc mirrors canonical.Skill / canonical.Agent without importing them.
|
||||
@@ -35,15 +38,15 @@ func ParseFrontmatter(b []byte) (name, description string) {
|
||||
return meta.Name, meta.Description
|
||||
}
|
||||
|
||||
// ImportMarkdownTree walks dir and returns one Doc per markdown file. When
|
||||
// requiredFilename is non-empty (e.g. "SKILL.md"), only files with that exact
|
||||
// basename are included; otherwise any *.md.
|
||||
func ImportMarkdownTree(dir, requiredFilename string) ([]Doc, error) {
|
||||
// ImportMarkdownTreeFS walks dir on fs and returns one Doc per matching file.
|
||||
// When requiredFilename is non-empty (e.g. "SKILL.md"), only files with that
|
||||
// exact basename are included; otherwise any *.md.
|
||||
func ImportMarkdownTreeFS(fs fsx.FS, dir, requiredFilename string) ([]Doc, error) {
|
||||
var docs []Doc
|
||||
if !DirExists(dir) {
|
||||
if !DirExistsFS(fs, dir) {
|
||||
return docs, nil
|
||||
}
|
||||
err := filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
|
||||
err := afero.Walk(fs, dir, func(p string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -56,7 +59,7 @@ func ImportMarkdownTree(dir, requiredFilename string) ([]Doc, error) {
|
||||
if requiredFilename == "" && !strings.HasSuffix(p, ".md") {
|
||||
return nil
|
||||
}
|
||||
body, err := os.ReadFile(p)
|
||||
body, err := afero.ReadFile(fs, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -75,3 +78,10 @@ func ImportMarkdownTree(dir, requiredFilename string) ([]Doc, error) {
|
||||
})
|
||||
return docs, err
|
||||
}
|
||||
|
||||
// ImportMarkdownTree walks dir and returns one Doc per markdown file. When
|
||||
// requiredFilename is non-empty (e.g. "SKILL.md"), only files with that exact
|
||||
// basename are included; otherwise any *.md.
|
||||
func ImportMarkdownTree(dir, requiredFilename string) ([]Doc, error) {
|
||||
return ImportMarkdownTreeFS(fsx.OS(), dir, requiredFilename)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package common_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/lukaszraczylo/harness-sync/internal/adapter/common"
|
||||
"github.com/lukaszraczylo/harness-sync/internal/fsx"
|
||||
)
|
||||
|
||||
func TestImportMarkdownTreeFSPicksUpSkillsFromMemFS(t *testing.T) {
|
||||
mem := fsx.Mem()
|
||||
|
||||
body := []byte("---\nname: cool-skill\ndescription: does stuff\n---\n# Cool Skill\n")
|
||||
require.NoError(t, mem.MkdirAll("/skills/cool-skill", 0o755))
|
||||
require.NoError(t, afero.WriteFile(mem, "/skills/cool-skill/SKILL.md", body, 0o644))
|
||||
|
||||
docs, err := common.ImportMarkdownTreeFS(mem, "/skills", "SKILL.md")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, docs, 1)
|
||||
assert.Equal(t, "cool-skill", docs[0].Name)
|
||||
assert.Equal(t, "does stuff", docs[0].Description)
|
||||
assert.Contains(t, docs[0].Body, "# Cool Skill")
|
||||
}
|
||||
|
||||
func TestImportMarkdownTreeFSHandlesMissingDir(t *testing.T) {
|
||||
mem := fsx.Mem()
|
||||
|
||||
docs, err := common.ImportMarkdownTreeFS(mem, "/no-such-dir", "SKILL.md")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, docs)
|
||||
}
|
||||
|
||||
func TestImportMarkdownTreeFSSkipsNonMatching(t *testing.T) {
|
||||
mem := fsx.Mem()
|
||||
|
||||
require.NoError(t, mem.MkdirAll("/skills/a", 0o755))
|
||||
require.NoError(t, mem.MkdirAll("/skills/b", 0o755))
|
||||
require.NoError(t, afero.WriteFile(mem, "/skills/a/SKILL.md", []byte("---\nname: a\n---\n"), 0o644))
|
||||
require.NoError(t, afero.WriteFile(mem, "/skills/b/OTHER.md", []byte("# other"), 0o644))
|
||||
require.NoError(t, afero.WriteFile(mem, "/skills/b/notes.txt", []byte("text"), 0o644))
|
||||
|
||||
// requiredFilename = "SKILL.md" → only picks up a/SKILL.md
|
||||
docs, err := common.ImportMarkdownTreeFS(mem, "/skills", "SKILL.md")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, docs, 1)
|
||||
assert.Equal(t, "a", docs[0].Name)
|
||||
}
|
||||
|
||||
func TestImportMarkdownTreeFSAnyMDWhenNoRequired(t *testing.T) {
|
||||
mem := fsx.Mem()
|
||||
|
||||
require.NoError(t, mem.MkdirAll("/agents", 0o755))
|
||||
require.NoError(t, afero.WriteFile(mem, "/agents/alpha.md", []byte("# Alpha\n"), 0o644))
|
||||
require.NoError(t, afero.WriteFile(mem, "/agents/beta.md", []byte("# Beta\n"), 0o644))
|
||||
require.NoError(t, afero.WriteFile(mem, "/agents/skip.txt", []byte("not md"), 0o644))
|
||||
|
||||
docs, err := common.ImportMarkdownTreeFS(mem, "/agents", "")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, docs, 2)
|
||||
}
|
||||
+47
-24
@@ -6,11 +6,22 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/lukaszraczylo/harness-sync/internal/fsx"
|
||||
)
|
||||
|
||||
// Load loads a canonical Bundle from the given root directory using the OS
|
||||
// filesystem. It is a thin wrapper around LoadFS.
|
||||
func Load(root string) (*Bundle, error) {
|
||||
info, err := os.Stat(root)
|
||||
return LoadFS(fsx.OS(), root)
|
||||
}
|
||||
|
||||
// LoadFS loads a canonical Bundle from root using the provided filesystem.
|
||||
// Use fsx.Mem() in tests to avoid touching real disk.
|
||||
func LoadFS(fs fsx.FS, root string) (*Bundle, error) {
|
||||
info, err := fs.Stat(root)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("canonical root %s: %w", root, err)
|
||||
}
|
||||
@@ -20,7 +31,7 @@ func Load(root string) (*Bundle, error) {
|
||||
|
||||
b := &Bundle{Root: root}
|
||||
|
||||
err = loadYAML(filepath.Join(root, "config.yaml"), &b.Config)
|
||||
err = loadYAMLFS(fs, filepath.Join(root, "config.yaml"), &b.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -29,7 +40,7 @@ func Load(root string) (*Bundle, error) {
|
||||
}
|
||||
|
||||
profPath := filepath.Join(root, "profiles", b.Config.ActiveProfile+".yaml")
|
||||
err = loadYAML(profPath, &b.Profile)
|
||||
err = loadYAMLFS(fs, profPath, &b.Profile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("profile %q: %w", b.Config.ActiveProfile, err)
|
||||
}
|
||||
@@ -38,14 +49,14 @@ func Load(root string) (*Bundle, error) {
|
||||
}
|
||||
|
||||
mcpPath := filepath.Join(root, "mcp.yaml")
|
||||
if fileExists(mcpPath) {
|
||||
err = loadYAML(mcpPath, &b.MCP)
|
||||
if fileExistsFS(fs, mcpPath) {
|
||||
err = loadYAMLFS(fs, mcpPath, &b.MCP)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
skills, err := loadMarkdownDir(filepath.Join(root, "skills"), "SKILL.md")
|
||||
skills, err := loadMarkdownDirFS(fs, filepath.Join(root, "skills"), "SKILL.md")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -53,7 +64,7 @@ func Load(root string) (*Bundle, error) {
|
||||
b.Skills = append(b.Skills, Skill(m))
|
||||
}
|
||||
|
||||
agents, err := loadMarkdownDir(filepath.Join(root, "agents"), "")
|
||||
agents, err := loadMarkdownDirFS(fs, filepath.Join(root, "agents"), "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -62,24 +73,35 @@ func Load(root string) (*Bundle, error) {
|
||||
}
|
||||
|
||||
b.Instructions.PerHarness = map[string]string{}
|
||||
body, err := readFileIfExists(filepath.Join(root, "instructions", "global.md"))
|
||||
body, err := readFileIfExistsFS(fs, 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 {
|
||||
if dirExistsFS(fs, perHarnessDir) {
|
||||
dir, err := fs.Open(perHarnessDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries, err := dir.Readdir(-1)
|
||||
if closeErr := dir.Close(); closeErr != nil && err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
|
||||
continue
|
||||
}
|
||||
body, err := os.ReadFile(filepath.Join(perHarnessDir, e.Name()))
|
||||
entryBody, err := afero.ReadFile(fs, filepath.Join(perHarnessDir, e.Name()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
harness := strings.TrimSuffix(e.Name(), ".md")
|
||||
b.Instructions.PerHarness[harness] = string(body)
|
||||
b.Instructions.PerHarness[harness] = string(entryBody)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,12 +115,13 @@ type markdownDoc struct {
|
||||
Path string
|
||||
}
|
||||
|
||||
func loadMarkdownDir(dir, requiredFilename string) ([]markdownDoc, error) {
|
||||
// loadMarkdownDirFS walks dir on fs and returns one markdownDoc per matching file.
|
||||
func loadMarkdownDirFS(fs fsx.FS, dir, requiredFilename string) ([]markdownDoc, error) {
|
||||
var docs []markdownDoc
|
||||
if !dirExists(dir) {
|
||||
if !dirExistsFS(fs, dir) {
|
||||
return docs, nil
|
||||
}
|
||||
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||
err := afero.Walk(fs, dir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -111,7 +134,7 @@ func loadMarkdownDir(dir, requiredFilename string) ([]markdownDoc, error) {
|
||||
if requiredFilename == "" && !strings.HasSuffix(path, ".md") {
|
||||
return nil
|
||||
}
|
||||
body, err := os.ReadFile(path)
|
||||
body, err := afero.ReadFile(fs, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -149,8 +172,8 @@ func parseFrontmatter(b []byte) (name, description string) {
|
||||
return meta.Name, meta.Description
|
||||
}
|
||||
|
||||
func loadYAML(path string, out interface{}) error {
|
||||
b, err := os.ReadFile(path)
|
||||
func loadYAMLFS(fs fsx.FS, path string, out interface{}) error {
|
||||
b, err := afero.ReadFile(fs, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
@@ -160,9 +183,9 @@ func loadYAML(path string, out interface{}) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func readFileIfExists(path string) (string, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
func readFileIfExistsFS(fs fsx.FS, path string) (string, error) {
|
||||
b, err := afero.ReadFile(fs, path)
|
||||
if err != nil && os.IsNotExist(err) {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
@@ -171,12 +194,12 @@ func readFileIfExists(path string) (string, error) {
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
func fileExistsFS(fs fsx.FS, path string) bool {
|
||||
info, err := fs.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
func dirExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
func dirExistsFS(fs fsx.FS, path string) bool {
|
||||
info, err := fs.Stat(path)
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package canonical_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/lukaszraczylo/harness-sync/internal/canonical"
|
||||
"github.com/lukaszraczylo/harness-sync/internal/fsx"
|
||||
)
|
||||
|
||||
// writeFile is a convenience helper: writes bytes to path on fs, creating any
|
||||
// parent directories first.
|
||||
func writeFile(t *testing.T, fs fsx.FS, path string, content []byte) {
|
||||
t.Helper()
|
||||
require.NoError(t, fs.MkdirAll(parentDir(path), 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, path, content, 0o644))
|
||||
}
|
||||
|
||||
func parentDir(path string) string {
|
||||
for i := len(path) - 1; i >= 0; i-- {
|
||||
if path[i] == '/' {
|
||||
return path[:i]
|
||||
}
|
||||
}
|
||||
return "/"
|
||||
}
|
||||
|
||||
func TestLoadFSFromMemFS(t *testing.T) {
|
||||
mem := fsx.Mem()
|
||||
|
||||
configYAML := []byte("active_profile: test\n")
|
||||
profileYAML := []byte("name: Test Profile\ngateway:\n url: https://example.com\n token: tok\n default_model: gpt4\n")
|
||||
skillBody := []byte("---\nname: my-skill\ndescription: A test skill\n---\n# My Skill\nsome content\n")
|
||||
|
||||
writeFile(t, mem, "/root/config.yaml", configYAML)
|
||||
writeFile(t, mem, "/root/profiles/test.yaml", profileYAML)
|
||||
writeFile(t, mem, "/root/skills/my-skill/SKILL.md", skillBody)
|
||||
|
||||
bundle, err := canonical.LoadFS(mem, "/root")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, bundle)
|
||||
|
||||
assert.Equal(t, "test", bundle.Config.ActiveProfile)
|
||||
assert.Equal(t, "Test Profile", bundle.Profile.Name)
|
||||
require.Len(t, bundle.Skills, 1)
|
||||
assert.Equal(t, "my-skill", bundle.Skills[0].Name)
|
||||
assert.Equal(t, "A test skill", bundle.Skills[0].Description)
|
||||
}
|
||||
|
||||
func TestLoadFSEmptyProfile(t *testing.T) {
|
||||
mem := fsx.Mem()
|
||||
|
||||
// config.yaml present but profile file is missing
|
||||
writeFile(t, mem, "/root/config.yaml", []byte("active_profile: missing\n"))
|
||||
|
||||
_, err := canonical.LoadFS(mem, "/root")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "missing")
|
||||
}
|
||||
|
||||
func TestLoadFSMissingRoot(t *testing.T) {
|
||||
mem := fsx.Mem()
|
||||
|
||||
_, err := canonical.LoadFS(mem, "/does-not-exist")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestLoadFSActiveProfileRequired(t *testing.T) {
|
||||
mem := fsx.Mem()
|
||||
|
||||
writeFile(t, mem, "/root/config.yaml", []byte("enabled_harnesses: []\n"))
|
||||
|
||||
_, err := canonical.LoadFS(mem, "/root")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "active_profile is required")
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Package fsx provides a small filesystem abstraction so file-touching code
|
||||
// can be tested against an in-memory filesystem.
|
||||
package fsx
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
// FS is the harness-sync filesystem interface — alias for afero.Fs so the
|
||||
// dependency stays in one place.
|
||||
type FS = afero.Fs
|
||||
|
||||
// OS returns the production OS-backed filesystem.
|
||||
func OS() FS { return afero.NewOsFs() }
|
||||
|
||||
// Mem returns an empty in-memory filesystem for tests.
|
||||
func Mem() FS { return afero.NewMemMapFs() }
|
||||
|
||||
// WriteFile writes data to path on fs.
|
||||
func WriteFile(fs FS, path string, data []byte, perm os.FileMode) error {
|
||||
return afero.WriteFile(fs, path, data, perm)
|
||||
}
|
||||
|
||||
// ReadFile reads the named file from fs and returns its contents.
|
||||
func ReadFile(fs FS, path string) ([]byte, error) {
|
||||
return afero.ReadFile(fs, path)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package fsx_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/lukaszraczylo/harness-sync/internal/fsx"
|
||||
)
|
||||
|
||||
func TestOSReturnsAferoOsFs(t *testing.T) {
|
||||
fs := fsx.OS()
|
||||
require.NotNil(t, fs)
|
||||
_, ok := fs.(*afero.OsFs)
|
||||
assert.True(t, ok, "OS() should return *afero.OsFs")
|
||||
}
|
||||
|
||||
func TestMemReturnsAferoMemFs(t *testing.T) {
|
||||
fs := fsx.Mem()
|
||||
require.NotNil(t, fs)
|
||||
_, ok := fs.(*afero.MemMapFs)
|
||||
assert.True(t, ok, "Mem() should return *afero.MemMapFs")
|
||||
}
|
||||
|
||||
func TestWriteReadRoundtrip(t *testing.T) {
|
||||
fs := fsx.Mem()
|
||||
data := []byte("hello world")
|
||||
require.NoError(t, fsx.WriteFile(fs, "/test.txt", data, 0o644))
|
||||
got, err := fsx.ReadFile(fs, "/test.txt")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, data, got)
|
||||
}
|
||||
Reference in New Issue
Block a user