Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
11 changes: 11 additions & 0 deletions docs/changes/unreleased/1691-skills-opencode-goose.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
kind: added
title: skills kept for OpenCode and Goose are read too, including their ~/.config folders
pr: 1691
surface: [chat, resident]
invalidates:
- "codeaf read skills from six folders (.codeaf, .agents, .claude, .codex, .cursor, .gemini under skills/). It also reads .opencode/skills and .goose/skills in the project and the home, and ~/.config/opencode/skills and ~/.config/goose/skills in the home only."
---
OpenCode and Goose keep a person's global skills under ~/.config rather than in a
dot folder named after the tool, so those are read in the home scope, after every
shared folder, where a name kept by hand earlier still owns it.
17 changes: 10 additions & 7 deletions internal/manual/chat/skills-from-other-tools.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# Skills from Claude Code, Codex and other tools
# Skills from Claude Code, Codex, OpenCode, Goose and other tools

## Can you use my Claude Code and Codex skills
## Can you use my Claude Code, Codex, OpenCode or Goose skills

Yes, directly. Any skill you already installed for Claude Code, Codex, Cursor,
Gemini or another agentskills.io tool — a folder holding a `SKILL.md` whose
Gemini, OpenCode, Goose or another agentskills.io tool — a folder holding a `SKILL.md` whose
frontmatter has a `name` and a `description` — is read where it lives. Nothing
is copied or reinstalled. Each launch reads the folders again before the first
message, so a skill you add or edit shows up the next time you open codeaf.
Expand All @@ -20,10 +20,13 @@ off.
Under the project folder and under your home folder, in this order:

- `.codeaf/skills`, `.agents/skills`, `.claude/skills`, `.codex/skills`,
`.cursor/skills`, `.gemini/skills` — each direct child folder holding a
`SKILL.md` is one skill. A child that is a link to a folder counts too, which
is how installers that keep one copy and link it into every tool's folder
reach codeaf.
`.cursor/skills`, `.gemini/skills`, `.opencode/skills`, `.goose/skills` —
each direct child folder holding a `SKILL.md` is one skill. A child that is a
link to a folder counts too, which is how installers that keep one copy and
link it into every tool's folder reach codeaf.
- In your home folder only, after those: `~/.config/opencode/skills` and
`~/.config/goose/skills`, which is where OpenCode and Goose keep the skills
you installed for yourself rather than for one project.
- The skills of every Claude Code plugin that is installed and enabled.
- Codex's own bundled skills, in `.codex/skills/.system`.

Expand Down
2 changes: 1 addition & 1 deletion internal/skills/plugins.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ const RootClaudePlugins = ".claude/plugins"
// one identity every reader keys on (internal/store's Fact.SkillName), and
// the agentskills.io name has no colon in its alphabet. So a plugin skill
// keeps its bare name and settles a collision by rank instead: every skill in
// the six hand-kept folders of a scope owns the name over a plugin's, because
// the hand-kept folders of a scope owns the name over a plugin's, because
// a skill somebody placed by hand is the one they meant, and between two
// plugins the one whose key sorts first owns it. The loser stays in the result
// marked Shadowed, like every other loser.
Expand Down
36 changes: 29 additions & 7 deletions internal/skills/skills.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,23 +57,37 @@ const (
// folder are the common way a skill reaches several harnesses at once, and a
// scan that skipped links saw none of those.
//
// TWO MORE SOURCES FOLLOW THESE SIX WITHIN EACH SCOPE, and they come last on
// purpose (see [Discover]): the skills that arrived inside an installed and
// TWO MORE SOURCES FOLLOW THESE FOLDERS WITHIN EACH SCOPE, and they come last
// on purpose (see [Discover]): the skills that arrived inside an installed and
// enabled Claude Code plugin ([RootClaudePlugins], plugins.go), and the skills
// Codex ships with itself ([RootCodexSystem]). Nobody placed either of them
// by hand, so any skill a person did place by hand, in any of the six folders,
// owns the name over them.
// by hand, so any skill a person did place by hand, in any of the hand-kept
// folders, owns the name over them.
var skillRoots = []string{
".codeaf/skills",
".agents/skills",
".claude/skills",
".codex/skills",
".cursor/skills",
".gemini/skills",
".opencode/skills",
".goose/skills",
}

// homeSkillRoots are hand-kept folders that exist ONLY in the home directory,
// read after [skillRoots] in the user scope. OpenCode and Goose keep a
// person's own skills under ~/.config rather than in a dot folder named after
// the tool, so ~/.opencode/skills and ~/.goose/skills are empty on a machine
// that uses them and these are where their global skills actually live. They
// are never read under a project: a project's .config folder is somebody's
// application settings, not a place either tool looks for skills.
var homeSkillRoots = []string{
".config/opencode/skills",
".config/goose/skills",
}

// RootCodexSystem is the folder Codex installs its own bundled skills into.
// It sits INSIDE .codex/skills, where the six-root scan sees it as one child
// It sits INSIDE .codex/skills, where the folder scan sees it as one child
// with no SKILL.md and passes over it, so it is read as a root of its own.
//
// IT RANKS LAST IN ITS SCOPE, below the plugin skills too. A system skill is
Expand Down Expand Up @@ -123,7 +137,7 @@ type Skill struct {

// Discover scans the conventional skill folders under one project directory
// and one home directory, in issue #1277's order, and returns what it found:
// within each scope the six hand-kept folders, then the skills of every
// within each scope the hand-kept folders, then the skills of every
// installed and enabled Claude Code plugin, then Codex's bundled skills —
// every folder that holds a SKILL.md, winners first, losers marked Shadowed,
// and unreadable ones carried with a Warning rather than dropped. It never
Expand Down Expand Up @@ -202,11 +216,19 @@ func Discover(opts Options) ([]Skill, error) {
for _, root := range skillRoots {
collect(filepath.Join(base.dir, root), root, scope, "")
}
// The home-only folders follow the shared ones in whichever base IS
// the home directory: the user scope, or the project scope when codeaf
// was opened in the home directory itself and the two bases folded.
if homeDir != "" && base.dir == absolute(homeDir) {
for _, root := range homeSkillRoots {
collect(filepath.Join(base.dir, root), root, scope, "")
}
}
// THE PLUGIN SKILLS, after every hand-kept folder in the scope and
// before the harness's own bundled skills. A project scope carries
// the plugins installed for this project; when the home directory
// folded into the project base above, it carries the person's own
// plugins after them too, the same way it already carries their six
// plugins after them too, the same way it already carries their hand-kept
// home folders.
plugins := userPlugins
if scope == ScopeProject {
Expand Down
110 changes: 110 additions & 0 deletions internal/skills/skills_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package skills

import (
"os"
"path/filepath"
"strings"
"testing"
Expand Down Expand Up @@ -222,3 +223,112 @@ func TestDiscoverNeedsAtLeastOneBase(t *testing.T) {
t.Fatal("Discover with no directories at all did not error")
}
}

// TestDiscoverReadsOpenCodeAndGooseFolders pins the two folders #1277 adds to
// the day-one list after Gemini's, in both scopes, and their rank: a name kept
// in an earlier folder of the same scope owns it over theirs.
func TestDiscoverReadsOpenCodeAndGooseFolders(t *testing.T) {
project := t.TempDir()
home := t.TempDir()
writeSkill(t, filepath.Join(project, ".opencode", "skills", "open-helper"), "open-helper")
writeSkill(t, filepath.Join(home, ".goose", "skills", "goose-helper"), "goose-helper")
writeSkill(t, filepath.Join(home, ".gemini", "skills", "shared"), "shared")
writeSkill(t, filepath.Join(home, ".goose", "skills", "shared"), "shared")

found := discover(t, project, home)
open, ok := byDir(found, filepath.Join(".opencode", "skills", "open-helper"))
if !ok || open.Scope != ScopeProject || open.Shadowed || open.Root != ".opencode/skills" {
t.Fatalf("the .opencode project skill is missing or wrong: %+v", found)
}
goose, ok := byDir(found, filepath.Join(".goose", "skills", "goose-helper"))
if !ok || goose.Scope != ScopeUser || goose.Shadowed || goose.Root != ".goose/skills" {
t.Fatalf("the .goose user skill is missing or wrong: %+v", found)
}
loser, ok := byDir(found, filepath.Join(".goose", "skills", "shared"))
if !ok || !loser.Shadowed {
t.Fatalf("the .goose copy of a name .gemini holds is not shadowed: %+v", found)
}
}

// TestDiscoverReadsOpenCodeAndGooseHomeFolders pins where the two tools keep a
// person's own skills: under ~/.config, not in a dot folder in the home. Both
// are read in the user scope with their own root name, and neither is read
// under a project, whose .config folder is not a skills location.
func TestDiscoverReadsOpenCodeAndGooseHomeFolders(t *testing.T) {
project := t.TempDir()
home := t.TempDir()
writeSkill(t, filepath.Join(home, ".config", "opencode", "skills", "open-global"), "open-global")
writeSkill(t, filepath.Join(home, ".config", "goose", "skills", "goose-global"), "goose-global")
writeSkill(t, filepath.Join(project, ".config", "opencode", "skills", "not-a-skill-place"), "not-a-skill-place")

found := discover(t, project, home)
open, ok := byDir(found, filepath.Join(".config", "opencode", "skills", "open-global"))
if !ok || open.Scope != ScopeUser || open.Shadowed || open.Root != ".config/opencode/skills" {
t.Fatalf("OpenCode's global skill is missing or wrong: %+v", found)
}
goose, ok := byDir(found, filepath.Join(".config", "goose", "skills", "goose-global"))
if !ok || goose.Scope != ScopeUser || goose.Shadowed || goose.Root != ".config/goose/skills" {
t.Fatalf("Goose's global skill is missing or wrong: %+v", found)
}
if stray, ok := byDir(found, "not-a-skill-place"); ok {
t.Fatalf("a project's .config folder was read as a skills folder: %+v", stray)
}
}

// TestHomeOnlyFoldersRankAfterTheSharedOnes pins precedence: a name kept in a
// shared hand-kept folder of the same scope owns it over the ~/.config copy,
// and any project skill owns it over every home one.
func TestHomeOnlyFoldersRankAfterTheSharedOnes(t *testing.T) {
project := t.TempDir()
home := t.TempDir()
writeSkill(t, filepath.Join(home, ".claude", "skills", "shared"), "shared")
writeSkill(t, filepath.Join(home, ".config", "goose", "skills", "shared"), "shared")
writeSkill(t, filepath.Join(project, ".goose", "skills", "mine"), "mine")
writeSkill(t, filepath.Join(home, ".config", "opencode", "skills", "mine"), "mine")

found := discover(t, project, home)
if winner, ok := byDir(found, filepath.Join(".claude", "skills", "shared")); !ok || winner.Shadowed {
t.Fatalf("the ~/.claude copy lost its name to a ~/.config copy: %+v", found)
}
if loser, ok := byDir(found, filepath.Join(".config", "goose", "skills", "shared")); !ok || !loser.Shadowed {
t.Fatalf("the ~/.config/goose copy of a name ~/.claude holds is not shadowed: %+v", found)
}
if winner, ok := byDir(found, filepath.Join(".goose", "skills", "mine")); !ok || winner.Shadowed || winner.Scope != ScopeProject {
t.Fatalf("the project skill lost its name to a home one: %+v", found)
}
if loser, ok := byDir(found, filepath.Join(".config", "opencode", "skills", "mine")); !ok || !loser.Shadowed {
t.Fatalf("the ~/.config/opencode copy of a project skill's name is not shadowed: %+v", found)
}
}

// TestHomeOnlyFoldersWhenOpenedInTheHomeItself pins the folded case: codeaf
// opened in the home directory reads the ~/.config folders once, not twice.
func TestHomeOnlyFoldersWhenOpenedInTheHomeItself(t *testing.T) {
home := t.TempDir()
writeSkill(t, filepath.Join(home, ".config", "opencode", "skills", "open-global"), "open-global")

found := discover(t, home, home)
count := 0
for _, skill := range found {
if strings.HasSuffix(skill.Dir, filepath.Join(".config", "opencode", "skills", "open-global")) {
count++
if skill.Shadowed {
t.Fatalf("the only copy of a skill is marked shadowed: %+v", skill)
}
}
}
if count != 1 {
t.Fatalf("a ~/.config skill was reported %d times with codeaf opened in the home: %+v", count, found)
}
}

func writeSkill(t *testing.T, dir, name string) {
t.Helper()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
body := "---\nname: " + name + "\ndescription: A skill for the discovery test.\n---\nBody.\n"
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
Loading