diff --git a/internal/agents/claudecode_plugin.go b/internal/agents/claudecode_plugin.go index ab10a26..c6169c5 100644 --- a/internal/agents/claudecode_plugin.go +++ b/internal/agents/claudecode_plugin.go @@ -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 @@ -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. diff --git a/internal/agents/claudecode_plugin_test.go b/internal/agents/claudecode_plugin_test.go index 9b55a6d..588ba9e 100644 --- a/internal/agents/claudecode_plugin_test.go +++ b/internal/agents/claudecode_plugin_test.go @@ -2,6 +2,7 @@ package agents import ( "errors" + "strings" "testing" ) @@ -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"},