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
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ lives in the code, `docs/` and `CHANGELOG.md` — never restate any of it here.
- An RPC method is declared once in `api` and exposed with `rpc.Handle` in `internal/routes`: the backend,
`lich` and `native/backend` compile against that one declaration, and `api/testdata/contract.golden` records
its shape (`go test ./api -update` after a change meant to move it). Its options are one struct, never
positional arguments. An `/events` event is declared there too, emitted with `events.Send` and read with
positional arguments. The service method it routes has the method's own shape,
`func(context.Context, Options) (Result, error)`, and the route hands it over as `s.Method`, no closure; one
that cannot fail keeps its body in an unexported method its own package calls. An `/events` event is declared there too, emitted with `events.Send` and read with
`backend.Decode`. There is no codegen.
- A fact more than one place shows (git status, a pull request, plan quota, a session's profile or status) has
one store per window, fed by one reader, and every screen reads that store. A change to the fact reaches the
Expand Down
33 changes: 19 additions & 14 deletions internal/agentplugin/agentplugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,12 +137,17 @@ func New(bins BinResolver) *Service {

type Status = api.PluginStatus

// Status reports, for every provider that can run the plugin, whether its CLI
// Status answers status over RPC.
func (s *Service) Status(_ context.Context, _ api.None) ([]Status, error) {
return s.status(), nil
}

// status reports, for every provider that can run the plugin, whether its CLI
// is present, whether the plugin is installed there and whether a newer release
// exists. The release lookup is one call for the whole list — both harnesses
// install the same repository — and a failed one leaves LatestVersion empty and
// never reports an update: this runs at startup and must not block it.
func (s *Service) Status() []Status {
func (s *Service) status() []Status {
latest := s.latestVersion()
out := make([]Status, 0, len(supported))
for _, id := range supported {
Expand Down Expand Up @@ -246,24 +251,24 @@ func computeStatus(id string, available, installed bool, installedVer, latestVer

// Install puts the plugin into a provider: through its plugin CLI where there
// is one, by writing the released files where there is not.
func (s *Service) Install(provider string) error {
switch provider {
func (s *Service) Install(_ context.Context, opts api.PluginProviderOptions) (api.None, error) {
switch opts.Provider {
case providers.Claude, providers.Codex:
return s.installAndFollow(provider)
return api.None{}, s.installAndFollow(opts.Provider)
case providers.Antigravity:
return s.antigravityInstall()
return api.None{}, s.antigravityInstall()
case providers.OpenCode:
return s.opencodeInstall()
return api.None{}, s.opencodeInstall()
case providers.OMP:
return s.ompInstall()
return api.None{}, s.ompInstall()
case providers.Crush:
return s.crushInstall()
return api.None{}, s.crushInstall()
case providers.Cursor:
return s.cursorInstall()
return api.None{}, s.cursorInstall()
case providers.Kiro:
return s.kiroInstall()
return api.None{}, s.kiroInstall()
}
return fmt.Errorf("no lich plugin for provider %q", provider)
return api.None{}, fmt.Errorf("no lich plugin for provider %q", opts.Provider)
}

// installAndFollow installs into Personal, then into every other profile of
Expand All @@ -287,8 +292,8 @@ func (s *Service) installAndFollow(provider string) error {
// their files wholesale, and Claude Code and Codex re-pin the marketplace
// (claudePinMarketplace). Every one applies it on the next session (a restart
// is required, which the UI signals).
func (s *Service) Update(provider string) error {
return s.Install(provider)
func (s *Service) Update(ctx context.Context, opts api.PluginProviderOptions) (api.None, error) {
return s.Install(ctx, opts)
}

// installedVersion reads the plugin's installed version from a provider's own
Expand Down
26 changes: 14 additions & 12 deletions internal/agentplugin/agentplugin_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package agentplugin

import (
"context"
"errors"
"fmt"
"io"
Expand All @@ -12,6 +13,7 @@ import (
"strings"
"testing"

"github.com/lichdotdev/lich/api"
"github.com/lichdotdev/lich/internal/providers"
)

Expand Down Expand Up @@ -315,7 +317,7 @@ func TestStatus(t *testing.T) {
Installed: true, InstalledVersion: "0.13.1-rc.3", LatestVersion: "0.13.1", UpdateAvailable: true,
Compatible: true,
}
if got := statusOf(t, s.Status(), providers.Claude); got != want {
if got := statusOf(t, s.status(), providers.Claude); got != want {
t.Fatalf("Status() = %+v, want %+v", got, want)
}
}
Expand All @@ -329,7 +331,7 @@ func TestStatusListsEveryHarness(t *testing.T) {
s.lookPath = func(string) (string, error) { return "", errors.New("not found") }

var got []string
for _, entry := range s.Status() {
for _, entry := range s.status() {
got = append(got, entry.Provider)
}
want := []string{
Expand All @@ -350,7 +352,7 @@ func TestStatusWithoutTheCLI(t *testing.T) {
s := serveBody(t, http.StatusOK, `[{"tag_name":"v0.5.0"}]`)
s.lookPath = func(string) (string, error) { return "", errors.New("not found") }

got := statusOf(t, s.Status(), providers.Claude)
got := statusOf(t, s.status(), providers.Claude)
if got.Available || got.Installed || got.InstalledVersion != "" {
t.Fatalf("Status() = %+v, want an unavailable provider with no install", got)
}
Expand All @@ -360,7 +362,7 @@ func TestStatusNotInstalled(t *testing.T) {
t.Setenv("CLAUDE_CONFIG_DIR", t.TempDir())
s := serveBody(t, http.StatusOK, `[{"tag_name":"v0.13.1"}]`)

got := statusOf(t, s.Status(), providers.Claude)
got := statusOf(t, s.status(), providers.Claude)
if got.Installed || got.UpdateAvailable {
t.Fatalf("Status() = %+v, want not installed and no update", got)
}
Expand All @@ -374,8 +376,8 @@ func TestStatusNotInstalled(t *testing.T) {
// to write into, so spawning it with the plugin's arguments would be nonsense.
func TestUnknownProviderIsRefused(t *testing.T) {
s, calls := fakeCLI(t, "")
for _, run := range []func(string) error{s.Install, s.Update} {
if err := run("shell"); err == nil {
for _, run := range []func(context.Context, api.PluginProviderOptions) (api.None, error){s.Install, s.Update} {
if _, err := run(t.Context(), api.PluginProviderOptions{Provider: "shell"}); err == nil {
t.Error("want an error for a kind with no plugin, got nil")
}
}
Expand Down Expand Up @@ -515,13 +517,13 @@ var pinnedCalls = map[string][]string{
// harness's own refresh can move past the contract this lich speaks.
func TestInstallPinsTheMarketplace(t *testing.T) {
for provider, want := range pinnedCalls {
for name, call := range map[string]func(*Service, string) error{
for name, call := range map[string]func(*Service, context.Context, api.PluginProviderOptions) (api.None, error){
"install": (*Service).Install,
"update": (*Service).Update,
} {
t.Run(provider+" "+name, func(t *testing.T) {
s, calls := fakeCLI(t, "")
if err := call(s, provider); err != nil {
if _, err := call(s, t.Context(), api.PluginProviderOptions{Provider: provider}); err != nil {
t.Fatalf("%s: %v", name, err)
}
if got := calls(); !slices.Equal(got, want) {
Expand All @@ -538,7 +540,7 @@ func TestInstallSurvivesANothingToRemove(t *testing.T) {
for provider, want := range pinnedCalls {
t.Run(provider, func(t *testing.T) {
s, calls := fakeCLI(t, "marketplace remove")
if err := s.Install(provider); err != nil {
if _, err := s.Install(t.Context(), api.PluginProviderOptions{Provider: provider}); err != nil {
t.Fatalf("Install with nothing to remove: %v", err)
}
if got := calls(); !slices.Equal(got, want) {
Expand All @@ -554,7 +556,7 @@ func TestInstallSurvivesANothingToRemove(t *testing.T) {
func TestInstallReportsTheInstallFailure(t *testing.T) {
s, _ := fakeCLI(t, "plugin install")

err := s.Install(providers.Claude)
_, err := s.Install(t.Context(), api.PluginProviderOptions{Provider: providers.Claude})
if err == nil {
t.Fatal("Install: want an error, got nil")
}
Expand All @@ -574,7 +576,7 @@ func TestMarketplaceAddFailureStopsTheRun(t *testing.T) {
t.Run(provider, func(t *testing.T) {
s, calls := fakeCLI(t, "marketplace add")

err := s.Install(provider)
_, err := s.Install(t.Context(), api.PluginProviderOptions{Provider: provider})
if err == nil || !strings.Contains(err.Error(), "plugin marketplace add") {
t.Fatalf("Install = %v, want the error to name the marketplace add", err)
}
Expand All @@ -595,7 +597,7 @@ func TestPinnedInstallNeedsARelease(t *testing.T) {
t.Run(provider, func(t *testing.T) {
s, calls := fakeCLI(t, "")
s.releasesURL = "http://127.0.0.1:0/unreachable"
if err := s.Install(provider); err == nil {
if _, err := s.Install(t.Context(), api.PluginProviderOptions{Provider: provider}); err == nil {
t.Fatal("Install: want an error without a release, got nil")
}
if got := calls(); len(got) != 0 {
Expand Down
Loading
Loading