From a9a0c688b83b82855daea5d14dae19e0b986d4b4 Mon Sep 17 00:00:00 2001 From: omartelo Date: Sun, 1 Nov 2026 14:54:43 -0300 Subject: [PATCH] feat(agentplugin): install the theme skill into opencode, oh-my-pi, Crush and Kiro These four read no skills out of the plugin, so the file-shipped install now copies skills/theme from the release into each one's own skills directory, renamed lich-theme so it cannot collide with a user's skill once it loses the plugin's namespace. opencode turns it into the /lich-theme command. The skill is written before the version marker, so a release missing part of it leaves nothing reported as installed. Tests: the release fixture now serves skills/theme, and the opencode and oh-my-pi fetch assertions expect the skill beside the module; the contract changed, since the install fetches it. --- CHANGELOG.md | 1 + internal/agentplugin/agentplugin.go | 9 ++- internal/agentplugin/crush.go | 21 +++++ internal/agentplugin/files_test.go | 39 +++++++-- internal/agentplugin/kiro.go | 5 ++ internal/agentplugin/omp.go | 3 + internal/agentplugin/opencode.go | 3 + internal/agentplugin/skill.go | 53 +++++++++++++ internal/agentplugin/skill_test.go | 119 ++++++++++++++++++++++++++++ 9 files changed, 242 insertions(+), 11 deletions(-) create mode 100644 internal/agentplugin/skill.go create mode 100644 internal/agentplugin/skill_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index f7ea1f882..7b750ae55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The Conversation tab of a pull request on the Pulls screen shows each author's GitHub picture beside their reviews and comments, in a column down the left, the way GitHub does. Comments inside a review thread, on the Conversation tab and on the Files tab, are separated by a line. Until a picture loads, or for an account that has none, the author's initial stands in its place. - Task lists in pull request descriptions and comments show a checkbox, ticked or not, in place of a literal `[x]` or `[ ]`. - Top-level headings in pull request descriptions and comments are underlined across the text's width, as on GitHub. +- The lich plugin's theme skill, which writes, ports or fixes a lich color theme, now reaches opencode, oh-my-pi, Crush and Kiro CLI as `lich-theme`; it was only in Claude Code, Codex, Antigravity and Cursor CLI. In opencode it is the `/lich-theme` command. It arrives with the next install or update of the plugin from Settings › Providers. - The Description heading on the Pulls screen's Overview has a ⋯ menu, as GitHub's comments do: Copy link copies the pull request's address, Copy Markdown its description as written, and Edit opens the description for editing, which the Edit button beside the heading did before. ### Changed diff --git a/internal/agentplugin/agentplugin.go b/internal/agentplugin/agentplugin.go index dc11758e1..a1c6a88b5 100644 --- a/internal/agentplugin/agentplugin.go +++ b/internal/agentplugin/agentplugin.go @@ -14,10 +14,11 @@ // release published: a customization directory under Antigravity's global // `config/plugins/`, a module into opencode's plugin directory, another into // oh-my-pi's extensions directory, and hook scripts plus a delimited block -// of `hook add` lines into Crush's crushrc. Each carries the version it -// installed — a marker line, or a field in a manifest lich owns outright — -// which is the only record of what is installed where the harness keeps -// none. Antigravity does have a plugin CLI; antigravity.go says why it is +// of `hook add` lines into Crush's crushrc. opencode, oh-my-pi, Crush and +// Kiro also get the plugin's theme skill in their skills directory +// (skill.go). Each carries the version it installed — a marker line, or a +// field in a manifest lich owns outright — which is the only record of what +// is installed where the harness keeps none. Antigravity does have a plugin CLI; antigravity.go says why it is // not the install. package agentplugin diff --git a/internal/agentplugin/crush.go b/internal/agentplugin/crush.go index 0637d4309..7cab820d0 100644 --- a/internal/agentplugin/crush.go +++ b/internal/agentplugin/crush.go @@ -105,6 +105,13 @@ func (s *Service) crushInstall() error { return err } } + skills, err := s.crushSkillsDir() + if err != nil { + return err + } + if err := s.installThemeSkill(version, skills); err != nil { + return err + } return s.writeCrushrc(version, dir, s.lichBin()) } @@ -334,6 +341,20 @@ func (s *Service) crushrcPath() (string, error) { return filepath.Join(dir, "crushrc"), nil } +// crushSkillsDir is the global skills directory Crush reads: $CRUSH_SKILLS_DIR +// when set, since Crush then reads that one alone, else `skills` beside the +// crushrc (Crush's GlobalSkillsDirs, v0.98.1). +func (s *Service) crushSkillsDir() (string, error) { + if dir := os.Getenv("CRUSH_SKILLS_DIR"); dir != "" { + return dir, nil + } + rc, err := s.crushrcPath() + if err != nil { + return "", err + } + return filepath.Join(filepath.Dir(rc), "skills"), nil +} + // pluginScriptDir is where lich keeps the hook scripts it installs for one // file-shipped harness: its own config directory, not the harness's. They are // lich's copy of another repository's files, and a harness that never installed diff --git a/internal/agentplugin/files_test.go b/internal/agentplugin/files_test.go index 46365367a..4d03110dd 100644 --- a/internal/agentplugin/files_test.go +++ b/internal/agentplugin/files_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "io" + "maps" "net/http" "net/http/httptest" "os" @@ -28,9 +29,13 @@ const testVersion = "0.13.0" // fileServer serves the plugin repository's files at a tag and records the // paths asked for. A path with no body 404s, which is how a test says "this -// release does not carry that file". +// release does not carry that file". Every release carries the theme skill +// (themeSkillRelease) unless the test serves its own copy of a file. func fileServer(t *testing.T, files map[string]string) (*Service, func() []string) { t.Helper() + served := themeSkillRelease() + maps.Copy(served, files) + files = served var asked []string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { asked = append(asked, r.URL.Path) @@ -58,6 +63,29 @@ func fileServer(t *testing.T, files map[string]string) (*Service, func() []strin }, func() []string { return asked } } +// themeSkillRelease is the theme skill as a release serves it. +func themeSkillRelease() map[string]string { + files := map[string]string{} + for _, name := range themeSkillFiles { + files[tagged(themeSkillSource+"/"+name)] = name + " body\n" + } + files[tagged(themeSkillSource+"/"+skillManifest)] = "---\nname: theme\ndescription: Write a lich theme.\n---\n\n# lich themes\n" + return files +} + +// assertFetchedWithSkill checks an install fetched its module and the theme +// skill, and nothing else. +func assertFetchedWithSkill(t *testing.T, got []string, module string) { + t.Helper() + want := []string{tagged(module)} + for _, name := range themeSkillFiles { + want = append(want, tagged(themeSkillSource+"/"+name)) + } + if !slices.Equal(slices.Sorted(slices.Values(got)), slices.Sorted(slices.Values(want))) { + t.Errorf("fetched %v, want the module and the theme skill, %v", got, want) + } +} + // tagged is the URL path a release file is served at. func tagged(path string) string { return "/v" + testVersion + "/" + path } @@ -116,9 +144,7 @@ func TestOpencodeInstallWritesTheModule(t *testing.T) { if !strings.HasPrefix(body, jsComment+" "+markerName+" v"+testVersion+" ") { t.Errorf("installed module has no version marker on its first line:\n%s", body) } - if got := asked(); len(got) != 1 || got[0] != tagged(opencodeSource) { - t.Errorf("fetched %v, want just %q", got, tagged(opencodeSource)) - } + assertFetchedWithSkill(t, asked(), opencodeSource) } // The version has to survive the round trip: Status reads back what Install @@ -283,9 +309,7 @@ func TestOMPInstallWritesTheExtensionAndRegistersTheServer(t *testing.T) { if !strings.HasPrefix(body, jsComment+" "+markerName+" v"+testVersion+" ") { t.Errorf("installed extension has no version marker on its first line:\n%s", body) } - if got := asked(); len(got) != 1 || got[0] != tagged(ompSource) { - t.Errorf("fetched %v, want just %q", got, tagged(ompSource)) - } + assertFetchedWithSkill(t, asked(), ompSource) // omp takes no --mcp-config flag, so this document is the only way a session // of its own reaches the other sessions. @@ -415,6 +439,7 @@ func crushCLI(t *testing.T, s *Service, configDir, version string) { t.Setenv(fakeCLIGuard, "1") t.Setenv(fakeCLIDirs, configDir) t.Setenv(fakeCLIVersion, version) + t.Setenv("CRUSH_SKILLS_DIR", "") s.bins = stubBin(self) } diff --git a/internal/agentplugin/kiro.go b/internal/agentplugin/kiro.go index f2d1c97af..7211c3072 100644 --- a/internal/agentplugin/kiro.go +++ b/internal/agentplugin/kiro.go @@ -105,6 +105,11 @@ func (s *Service) kiroInstall() error { if err != nil { return err } + // The skills directory is the agents directory's sibling under KiroHome, + // the one the agent's resources point at (kiroAgentFile). + if err := s.installThemeSkill(version, filepath.Join(filepath.Dir(filepath.Dir(path)), "skills")); err != nil { + return err + } if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return fmt.Errorf("create %s: %w", filepath.Dir(path), err) } diff --git a/internal/agentplugin/omp.go b/internal/agentplugin/omp.go index 3b6d1ded0..79f956843 100644 --- a/internal/agentplugin/omp.go +++ b/internal/agentplugin/omp.go @@ -53,6 +53,9 @@ func (s *Service) ompInstall() error { if err != nil { return err } + if err := s.installThemeSkill(version, filepath.Join(dir, "skills")); err != nil { + return err + } extensions := filepath.Join(dir, ompExtensionsDir) if err := os.MkdirAll(extensions, 0o755); err != nil { return fmt.Errorf("create %s: %w", extensions, err) diff --git a/internal/agentplugin/opencode.go b/internal/agentplugin/opencode.go index 1dede05dc..b383ea954 100644 --- a/internal/agentplugin/opencode.go +++ b/internal/agentplugin/opencode.go @@ -39,6 +39,9 @@ func (s *Service) opencodeInstall() error { if err != nil { return err } + if err := s.installThemeSkill(version, filepath.Join(filepath.Dir(dir), "skills")); err != nil { + return err + } if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("create %s: %w", dir, err) } diff --git a/internal/agentplugin/skill.go b/internal/agentplugin/skill.go new file mode 100644 index 000000000..deae5ec78 --- /dev/null +++ b/internal/agentplugin/skill.go @@ -0,0 +1,53 @@ +package agentplugin + +import ( + "bytes" + "fmt" + "os" + "path/filepath" +) + +// The plugin's theme skill, for the harnesses that do not load it from the +// plugin itself. Claude Code, Codex and Antigravity read skills/ out of the +// installed plugin, and Cursor loads Claude Code's installed plugins whole; +// opencode, oh-my-pi, Crush and Kiro only read a skills directory of their own, +// so their install copies the skill there. + +const ( + themeSkillSource = "skills/theme" + // themeSkillName is the skill's name and directory once copied out of the + // plugin. It loses the plugin's namespace there, and a bare `theme` would sit + // beside the user's own skills under a name one of them may already hold. + themeSkillName = "lich-theme" + skillManifest = "SKILL.md" +) + +// themeSkillFiles is every file the skill ships: its SKILL.md tells the agent +// to copy the template and run the validator from the skill's own directory. +var themeSkillFiles = []string{skillManifest, "template.json", "validate.mjs"} + +// installThemeSkill writes the release's theme skill into skillsDir, renamed to +// themeSkillName in its frontmatter too: the Agent Skills format wants the name +// to match the directory, and opencode makes that name the slash command. +func (s *Service) installThemeSkill(version, skillsDir string) error { + files := make(map[string][]byte, len(themeSkillFiles)) + for _, name := range themeSkillFiles { + data, err := s.fetchFile(version, themeSkillSource+"/"+name) + if err != nil { + return err + } + files[name] = data + } + files[skillManifest] = bytes.Replace(files[skillManifest], + []byte("\nname: theme\n"), []byte("\nname: "+themeSkillName+"\n"), 1) + dir := filepath.Join(skillsDir, themeSkillName) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create %s: %w", dir, err) + } + for name, data := range files { + if err := writeFile(filepath.Join(dir, name), data, 0o644); err != nil { + return err + } + } + return nil +} diff --git a/internal/agentplugin/skill_test.go b/internal/agentplugin/skill_test.go new file mode 100644 index 000000000..432cdf52a --- /dev/null +++ b/internal/agentplugin/skill_test.go @@ -0,0 +1,119 @@ +package agentplugin + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/internal/providers" +) + +// The harnesses that read no skills out of the plugin get the theme skill +// copied into a skills directory of their own, where they look for it. + +// assertThemeSkill checks the skill sits whole in skillsDir, renamed. +func assertThemeSkill(t *testing.T, skillsDir string) { + t.Helper() + dir := filepath.Join(skillsDir, themeSkillName) + manifest, err := os.ReadFile(filepath.Join(dir, skillManifest)) + if err != nil { + t.Fatalf("read the installed skill: %v", err) + } + if !strings.HasPrefix(string(manifest), "---\nname: "+themeSkillName+"\n") { + t.Errorf("SKILL.md is not named %q, the directory it sits in:\n%s", themeSkillName, manifest) + } + if !strings.Contains(string(manifest), "# lich themes") { + t.Errorf("SKILL.md lost the release's body:\n%s", manifest) + } + for _, name := range themeSkillFiles[1:] { + if _, err := os.Stat(filepath.Join(dir, name)); err != nil { + t.Errorf("the skill's %s is missing: %v", name, err) + } + } +} + +func TestOpencodeInstallWritesTheThemeSkill(t *testing.T) { + config := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", config) + s, _ := fileServer(t, map[string]string{tagged(opencodeSource): "export const P = async () => ({})\n"}) + + if _, err := s.Install(t.Context(), api.PluginProviderOptions{Provider: providers.OpenCode}); err != nil { + t.Fatalf("Install: %v", err) + } + assertThemeSkill(t, filepath.Join(config, "opencode", "skills")) +} + +func TestOMPInstallWritesTheThemeSkill(t *testing.T) { + agent := ompHome(t) + s, _ := fileServer(t, map[string]string{tagged(ompSource): "export default function lichPlugin(pi) {}\n"}) + + if _, err := s.Install(t.Context(), api.PluginProviderOptions{Provider: providers.OMP}); err != nil { + t.Fatalf("Install: %v", err) + } + assertThemeSkill(t, filepath.Join(agent, "skills")) +} + +func TestCrushInstallWritesTheThemeSkill(t *testing.T) { + tests := []struct { + name string + // skillsDir is $CRUSH_SKILLS_DIR, "" for unset. + skillsDir string + }{ + {name: "beside the crushrc"}, + {name: "in CRUSH_SKILLS_DIR, the one directory Crush then reads", skillsDir: t.TempDir()}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + s, _ := fileServer(t, crushFiles()) + configDir := t.TempDir() + lichConfigHome(t) + crushCLI(t, s, configDir, "0.88.1") + t.Setenv("CRUSH_SKILLS_DIR", tc.skillsDir) + + if _, err := s.Install(t.Context(), api.PluginProviderOptions{Provider: providers.Crush}); err != nil { + t.Fatalf("Install: %v", err) + } + want := tc.skillsDir + if want == "" { + want = filepath.Join(configDir, "skills") + } + assertThemeSkill(t, want) + }) + } +} + +func TestKiroInstallWritesTheThemeSkill(t *testing.T) { + s, _ := fileServer(t, kiroFiles()) + self, err := os.Executable() + if err != nil { + t.Fatalf("resolve test binary: %v", err) + } + t.Setenv(fakeCLIGuard, "1") + t.Setenv(fakeCLILog, filepath.Join(t.TempDir(), "cli.log")) + s.bins = stubBin(self) + _, home := kiroTestHome(t) + + if _, err := s.Install(t.Context(), api.PluginProviderOptions{Provider: providers.Kiro}); err != nil { + t.Fatalf("Install: %v", err) + } + assertThemeSkill(t, filepath.Join(providers.KiroHome(home), "skills")) +} + +// The skill is written before the version marker, so a release missing part of +// it fails the install without leaving a module that reports it installed. +func TestInstallWithoutTheThemeSkillLeavesNothingInstalled(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + s, _ := fileServer(t, map[string]string{ + tagged(opencodeSource): "export const P = async () => ({})\n", + tagged(themeSkillSource + "/" + "validate.mjs"): "", + }) + + if _, err := s.Install(t.Context(), api.PluginProviderOptions{Provider: providers.OpenCode}); err == nil { + t.Fatal("Install succeeded on a release without the whole skill") + } + if version, ok := s.installedVersion(providers.OpenCode); ok { + t.Errorf("installedVersion = %q after a failed install, want nothing installed", version) + } +}