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
30 changes: 24 additions & 6 deletions internal/agents/claudecode_plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,11 @@ func EnsureClaudeCodePlugin() (ClaudePluginResult, error) {
return res, nil
}

if _, err := claudeExec("plugin", "marketplace", "add", claudePluginMarketplaceRef); err != nil {
return res, fmt.Errorf("claude plugin marketplace add %s: %w", claudePluginMarketplaceRef, err)
if out, err := claudeExec("plugin", "marketplace", "add", claudePluginMarketplaceRef); err != nil {
return res, fmt.Errorf("claude plugin marketplace add %s: %w%s", claudePluginMarketplaceRef, err, outputTail(out))
}
if _, err := claudeExec("plugin", "install", claudePluginSpec()); err != nil {
return res, fmt.Errorf("claude plugin install %s: %w", claudePluginSpec(), err)
if out, err := claudeExec("plugin", "install", claudePluginSpec()); err != nil {
return res, fmt.Errorf("claude plugin install %s: %w%s", claudePluginSpec(), err, outputTail(out))
}
res.Installed = true
return res, nil
Expand All @@ -127,13 +127,31 @@ func RemoveClaudeCodePlugin() (ClaudePluginResult, error) {
return res, nil
}

if _, err := claudeExec("plugin", "uninstall", claudePluginSpec()); err != nil {
return res, fmt.Errorf("claude plugin uninstall %s: %w", claudePluginSpec(), err)
if out, err := claudeExec("plugin", "uninstall", claudePluginSpec()); err != nil {
return res, fmt.Errorf("claude plugin uninstall %s: %w%s", claudePluginSpec(), err, outputTail(out))
}
res.Removed = true
return res, nil
}

// outputTail formats a command's combined output for inclusion in an
// error — a ": " separator + the trimmed tail (capped), or "" when
// the command said nothing. Without this the install verb only
// surfaced "exit status 1"; claude's actual reason (auth, network,
// unknown plugin) lives in the output and is what makes the failure
// actionable.
func outputTail(out string) string {
out = strings.TrimSpace(out)
if out == "" {
return ""
}
const max = 600
if len(out) > max {
out = "…" + out[len(out)-max:]
}
return ": " + out
}

// ClaudeCodePluginStatus reports whether the plugin is currently
// installed. ClaudeFound=false (with installed=false) when the CLI
// isn't on PATH.
Expand Down
34 changes: 34 additions & 0 deletions internal/agents/claudecode_plugin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package agents

import (
"errors"
"strings"
"testing"
)

Expand Down Expand Up @@ -126,6 +127,39 @@ func TestEnsureClaudeCodePlugin_InstallFails(t *testing.T) {
}
}

// TestEnsureClaudeCodePlugin_InstallError_IncludesOutput guards the
// diagnostics fix: when claude exits non-zero, its combined output
// (the real reason — auth, unknown plugin, etc.) must appear in the
// error rather than being swallowed into a bare "exit status 1".
func TestEnsureClaudeCodePlugin_InstallError_IncludesOutput(t *testing.T) {
stubClaude(t, map[string]stubResp{
"plugin list": {out: "none\n"},
"plugin marketplace": {out: "ok\n"},
"plugin install": {out: "Error: marketplace clawtool-marketplace not found", err: errors.New("exit status 1")},
})
_, err := EnsureClaudeCodePlugin()
if err == nil {
t.Fatalf("expected error")
}
if !strings.Contains(err.Error(), "marketplace clawtool-marketplace not found") {
t.Errorf("error did not surface claude's output: %v", err)
}
}

func TestOutputTail(t *testing.T) {
if got := outputTail(" "); got != "" {
t.Errorf("blank output should yield empty, got %q", got)
}
if got := outputTail("boom"); got != ": boom" {
t.Errorf("got %q, want ': boom'", got)
}
long := strings.Repeat("x", 800)
got := outputTail(long)
if len(got) > 610 || !strings.HasPrefix(got, ": …") {
t.Errorf("long output not capped/elided: len=%d", len(got))
}
}

func TestRemoveClaudeCodePlugin_NotInstalled(t *testing.T) {
calls := stubClaude(t, map[string]stubResp{
"plugin list": {out: "no plugins\n"},
Expand Down