Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion control-plane/internal/skillkit/skillkit_edge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -788,7 +788,7 @@ func TestTargetSpecificEdgeCases(t *testing.T) {
// holding nothing but the user's own text.
{name: "codex", target: codexTarget{}, dir: filepath.Join(home, ".codex"), path: filepath.Join(home, ".codex", "AGENTS.override.md")},
{name: "gemini", target: geminiTarget{}, dir: filepath.Join(home, ".gemini"), path: filepath.Join(home, ".gemini", "GEMINI.md")},
{name: "opencode", target: opencodeTarget{}, dir: filepath.Join(home, ".config", "opencode"), path: filepath.Join(home, ".config", "opencode", "AGENTS.md")},
{name: "opencode", target: opencodeTarget{}, dir: filepath.Join(home, ".config", "opencode", "skills"), path: filepath.Join(home, ".config", "opencode", "skills", "agentfield")},
{name: "windsurf", target: windsurfTarget{}, dir: filepath.Join(home, ".codeium", "windsurf", "memories"), path: filepath.Join(home, ".codeium", "windsurf", "memories", "global_rules.md")},
}

Expand Down
4 changes: 2 additions & 2 deletions control-plane/internal/skillkit/skillkit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -463,13 +463,13 @@ func TestHelpersAndTargets(t *testing.T) {
t.Fatalf("mkdir opencode dir: %v", err)
}
opencode := opencodeTarget{}
if opencode.DisplayName() != "OpenCode" || opencode.Method() != "marker-block" {
if opencode.DisplayName() != "OpenCode" || opencode.Method() != "symlink" {
t.Fatalf("unexpected opencode metadata: %q %q", opencode.DisplayName(), opencode.Method())
}
if !opencode.Detected() {
t.Fatal("opencode target should be detected")
}
if _, err := opencode.Install(skill, filepath.Join(home, "canonical", "current")); err != nil {
if _, err := opencode.Install(skill, filepath.Join(home, "canonical", skill.Version)); err != nil {
t.Fatalf("opencode install: %v", err)
}
if installed, version, err := opencode.Status(); err != nil || !installed || version != skill.Version {
Expand Down
86 changes: 71 additions & 15 deletions control-plane/internal/skillkit/target_opencode.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,21 @@ package skillkit

import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)

// opencodeTarget installs into OpenCode by appending a marker block to
// ~/.config/opencode/AGENTS.md.
// opencodeTarget installs skills where OpenCode discovers them natively.
type opencodeTarget struct{}

func init() { RegisterTarget(opencodeTarget{}) }

func (opencodeTarget) Name() string { return "opencode" }
func (opencodeTarget) DisplayName() string { return "OpenCode" }
func (opencodeTarget) Method() string { return "marker-block" }
func (opencodeTarget) Method() string { return "symlink" }

func (opencodeTarget) Detected() bool {
return commandAvailable("opencode") || dirExists(filepath.Join(homeDir(), ".config", "opencode"))
Expand All @@ -24,43 +27,96 @@ func (opencodeTarget) TargetPath() (string, error) {
if h == "" {
return "", errors.New("could not resolve home directory")
}
return filepath.Join(h, ".config", "opencode", "AGENTS.md"), nil
return filepath.Join(h, ".config", "opencode", "skills"), nil
}

func (t opencodeTarget) skillLink(skill Skill) (string, error) {
root, err := t.TargetPath()
if err != nil {
return "", err
}
return filepath.Join(root, skill.Name), nil
}

func (t opencodeTarget) Install(skill Skill, canonicalCurrentDir string) (InstalledTarget, error) {
path, err := t.TargetPath()
root, err := t.TargetPath()
if err != nil {
return InstalledTarget{}, err
}
inst, err := installMarkerBlock(skill, canonicalCurrentDir, path)
if err := os.MkdirAll(root, 0o755); err != nil {
return InstalledTarget{}, fmt.Errorf("create %s: %w", root, err)
}
link, err := t.skillLink(skill)
if err != nil {
return InstalledTarget{}, err
}
inst.TargetName = t.Name()
return inst, nil
if info, err := os.Lstat(link); err == nil {
if info.Mode()&os.ModeSymlink != 0 || info.IsDir() || info.Mode().IsRegular() {
if err := os.RemoveAll(link); err != nil {
return InstalledTarget{}, fmt.Errorf("remove existing %s: %w", link, err)
}
}
} else if !os.IsNotExist(err) {
return InstalledTarget{}, fmt.Errorf("inspect %s: %w", link, err)
}
if err := os.Symlink(canonicalCurrentDir, link); err != nil {
return InstalledTarget{}, fmt.Errorf("symlink %s -> %s: %w", link, canonicalCurrentDir, err)
}
return InstalledTarget{TargetName: t.Name(), Method: t.Method(), Path: link, Version: skill.Version, InstalledAt: time.Now().UTC()}, nil
}

func (t opencodeTarget) Uninstall() error {
path, err := t.TargetPath()
if err != nil {
// Resolve the target root up front so failures (for example, an
// unavailable home directory) are reported to the caller instead of
// being silently ignored while iterating over the catalog.
if _, err := t.TargetPath(); err != nil {
return err
}
for _, s := range Catalog {
if err := uninstallMarkerBlock(s, path); err != nil {
link, err := t.skillLink(s)
if err != nil {
return err
}
if info, err := os.Lstat(link); err == nil && (info.Mode()&os.ModeSymlink != 0 || info.IsDir() || info.Mode().IsRegular()) {
if err := os.RemoveAll(link); err != nil {
return fmt.Errorf("remove %s: %w", link, err)
}
}
}
return nil
}

func (t opencodeTarget) Status() (bool, string, error) {
path, err := t.TargetPath()
link, err := t.skillLink(Catalog[0])
if err != nil {
return false, "", err
}
v := readMarkerVersion(Catalog[0], path)
if v == "" {
info, err := os.Lstat(link)
if os.IsNotExist(err) {
return false, "", nil
}
return true, v, nil
if err != nil {
return false, "", err
}
if info.Mode()&os.ModeSymlink == 0 {
return true, "manual", nil
}
dest, err := os.Readlink(link)
if err != nil {
return false, "", err
}
if !filepath.IsAbs(dest) {
dest = filepath.Join(filepath.Dir(link), dest)
}
base := filepath.Base(dest)
// Older installations link directly to a version directory, which may
// have been removed temporarily. Preserve that version from the link name.
if base != "current" && strings.Count(base, ".") >= 2 && len(base) > 0 && base[0] >= '0' && base[0] <= '9' {
return true, base, nil
}
resolved, err := filepath.EvalSymlinks(dest)
if err != nil {
return false, "", err
}
return true, filepath.Base(resolved), nil
}
188 changes: 188 additions & 0 deletions control-plane/internal/skillkit/target_opencode_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
package skillkit

import (
"os"
"path/filepath"
"testing"
)

func TestOpenCodeTargetInstallsSkillSymlink(t *testing.T) {
home := withTempHome(t)
t.Setenv("USERPROFILE", home)
canonical := filepath.Join(home, ".agentfield", "skills", "agentfield", "1.2.3")
if err := os.MkdirAll(canonical, 0o755); err != nil {
t.Fatal(err)
}
target := opencodeTarget{}
installed, err := target.Install(Skill{Name: "agentfield", Version: "1.2.3"}, canonical)
if err != nil {
t.Fatal(err)
}
want := filepath.Join(home, ".config", "opencode", "skills", "agentfield")
if installed.Method != "symlink" || installed.Path != want {
t.Fatalf("installed target = %#v", installed)
}
got, err := os.Readlink(want)
if err != nil || got != canonical {
t.Fatalf("OpenCode link = %q, %v; want %q", got, err, canonical)
}
if installed, version, err := target.Status(); err != nil || !installed || version != "1.2.3" {
t.Fatalf("OpenCode status = %v %q %v", installed, version, err)
}
if err := target.Uninstall(); err != nil {
t.Fatal(err)
}
if _, err := os.Lstat(want); !os.IsNotExist(err) {
t.Fatalf("skill link still exists after uninstall: %v", err)
}
}

func TestOpenCodeTargetReplacesExistingEntryAndReportsManualEntry(t *testing.T) {
home := withTempHome(t)
target := opencodeTarget{}
root, err := target.TargetPath()
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(root, 0o755); err != nil {
t.Fatal(err)
}
link := filepath.Join(root, Catalog[0].Name)
if err := os.WriteFile(link, []byte("manual"), 0o644); err != nil {
t.Fatal(err)
}
if installed, version, err := target.Status(); err != nil || !installed || version != "manual" {
t.Fatalf("manual Status = %v %q %v", installed, version, err)
}
canonical := filepath.Join(home, ".agentfield", "skills", Catalog[0].Name, Catalog[0].Version)
if err := os.MkdirAll(canonical, 0o755); err != nil {
t.Fatal(err)
}
if _, err := target.Install(Catalog[0], canonical); err != nil {
t.Fatal(err)
}
if _, err := os.Readlink(link); err != nil {
t.Fatalf("replacement is not a symlink: %v", err)
}
}

func TestOpenCodeTargetInstallReportsRootCreationFailure(t *testing.T) {
home := withTempHome(t)
if err := os.WriteFile(filepath.Join(home, ".config"), []byte("not a directory"), 0o644); err != nil {
t.Fatal(err)
}

_, err := (opencodeTarget{}).Install(Catalog[0], filepath.Join(home, "canonical"))
if err == nil {
t.Fatal("Install should report a failure creating the OpenCode skills directory")
}
}

func TestOpenCodeTargetUninstallReportsMissingHome(t *testing.T) {
t.Setenv("HOME", "")
t.Setenv("USERPROFILE", "")
t.Setenv("AGENTFIELD_HOME", "")
if err := (opencodeTarget{}).Uninstall(); err == nil {
t.Fatal("Uninstall should report an unavailable home directory")
}
}

func TestOpenCodeTargetStatusHandlesMissingAndBrokenLinks(t *testing.T) {
home := withTempHome(t)
target := opencodeTarget{}

installed, version, err := target.Status()
if err != nil || installed || version != "" {
t.Fatalf("missing Status = %v %q %v", installed, version, err)
}

link, err := target.skillLink(Catalog[0])
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(link), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join(home, "missing"), link); err != nil {
t.Fatal(err)
}
if installed, version, err := target.Status(); err == nil || installed || version != "" {
t.Fatalf("broken-link Status = %v %q %v", installed, version, err)
}
}

func TestOpenCodeTargetStatusResolvesCurrentLink(t *testing.T) {
home := withTempHome(t)
target := opencodeTarget{}
link, err := target.skillLink(Catalog[0])
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(link), 0o755); err != nil {
t.Fatal(err)
}
versionDir := filepath.Join(home, "canonical", "1.2.3")
if err := os.MkdirAll(versionDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(versionDir, filepath.Join(filepath.Dir(link), "current")); err != nil {
t.Fatal(err)
}
if err := os.Symlink("current", link); err != nil {
t.Fatal(err)
}
if installed, version, err := target.Status(); err != nil || !installed || version != "1.2.3" {
t.Fatalf("current-link Status = %v %q %v", installed, version, err)
}
}

func TestOpenCodeTargetStatusPreservesVersionFromRemovedDirectLink(t *testing.T) {
home := withTempHome(t)
target := opencodeTarget{}
link, err := target.skillLink(Catalog[0])
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(link), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join(home, "canonical", "1.2.3"), link); err != nil {
t.Fatal(err)
}

if installed, version, err := target.Status(); err != nil || !installed || version != "1.2.3" {
t.Fatalf("removed-direct-link Status = %v %q %v", installed, version, err)
}
}

func TestOpenCodeTargetUninstallRemovesCatalogEntries(t *testing.T) {
target := opencodeTarget{}
root, err := target.TargetPath()
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(root, 0o755); err != nil {
t.Fatal(err)
}
for _, skill := range Catalog {
path, err := target.skillLink(skill)
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(path, 0o755); err != nil {
t.Fatal(err)
}
}
if err := target.Uninstall(); err != nil {
t.Fatal(err)
}
for _, skill := range Catalog {
path, err := target.skillLink(skill)
if err != nil {
t.Fatal(err)
}
if _, err := os.Lstat(path); !os.IsNotExist(err) {
t.Fatalf("catalog entry %q remains after uninstall: %v", skill.Name, err)
}
}
}
Loading