diff --git a/docs/changes/unreleased/1691-skills-opencode-goose.md b/docs/changes/unreleased/1691-skills-opencode-goose.md new file mode 100644 index 0000000000..b5c4f16d7f --- /dev/null +++ b/docs/changes/unreleased/1691-skills-opencode-goose.md @@ -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. diff --git a/internal/manual/chat/skills-from-other-tools.md b/internal/manual/chat/skills-from-other-tools.md index 5718ea191d..e8d595bfc2 100644 --- a/internal/manual/chat/skills-from-other-tools.md +++ b/internal/manual/chat/skills-from-other-tools.md @@ -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. @@ -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`. diff --git a/internal/skills/plugins.go b/internal/skills/plugins.go index 317bd2d608..b69bbb6f7c 100644 --- a/internal/skills/plugins.go +++ b/internal/skills/plugins.go @@ -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. diff --git a/internal/skills/skills.go b/internal/skills/skills.go index 5588b594f1..d6d9e1efff 100644 --- a/internal/skills/skills.go +++ b/internal/skills/skills.go @@ -57,12 +57,12 @@ 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", @@ -70,10 +70,24 @@ var skillRoots = []string{ ".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 @@ -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 @@ -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 { diff --git a/internal/skills/skills_test.go b/internal/skills/skills_test.go index e95ebf4cc3..f15bddbc66 100644 --- a/internal/skills/skills_test.go +++ b/internal/skills/skills_test.go @@ -1,6 +1,7 @@ package skills import ( + "os" "path/filepath" "strings" "testing" @@ -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) + } +}