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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions internal/agentplugin/agentplugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
21 changes: 21 additions & 0 deletions internal/agentplugin/crush.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}

Expand Down Expand Up @@ -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
Expand Down
39 changes: 32 additions & 7 deletions internal/agentplugin/files_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"io"
"maps"
"net/http"
"net/http/httptest"
"os"
Expand All @@ -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)
Expand Down Expand Up @@ -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 }

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}

Expand Down
5 changes: 5 additions & 0 deletions internal/agentplugin/kiro.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
3 changes: 3 additions & 0 deletions internal/agentplugin/omp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions internal/agentplugin/opencode.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
53 changes: 53 additions & 0 deletions internal/agentplugin/skill.go
Original file line number Diff line number Diff line change
@@ -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
}
119 changes: 119 additions & 0 deletions internal/agentplugin/skill_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading