diff --git a/internal/pkg/cli/command/root/root.go b/internal/pkg/cli/command/root/root.go index ecce3ca..c88d386 100644 --- a/internal/pkg/cli/command/root/root.go +++ b/internal/pkg/cli/command/root/root.go @@ -24,6 +24,7 @@ import ( "github.com/pinecone-io/cli/internal/pkg/utils/help" loginutil "github.com/pinecone-io/cli/internal/pkg/utils/login" "github.com/pinecone-io/cli/internal/pkg/utils/msg" + "github.com/pinecone-io/cli/internal/pkg/utils/pluginhint" "github.com/spf13/cobra" ) @@ -66,6 +67,11 @@ type GlobalOptions struct { } func Execute() { + // Recommend the Pinecone plugin when running inside Claude Code. Done here + // rather than in a PersistentPreRun so it fires for every invocation, + // including --help and unknown-command errors. + pluginhint.Emit() + // Base context: cancel on SIGINT / SIGTERM ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) diff --git a/internal/pkg/utils/pluginhint/pluginhint.go b/internal/pkg/utils/pluginhint/pluginhint.go new file mode 100644 index 0000000..0d5eea5 --- /dev/null +++ b/internal/pkg/utils/pluginhint/pluginhint.go @@ -0,0 +1,40 @@ +// Package pluginhint implements the Claude Code plugin-hint protocol: when the +// CLI runs inside Claude Code, it writes a marker to stderr that prompts the +// user to install the official Pinecone plugin. Claude Code strips the marker +// from the output before it reaches the model and shows a one-time install +// prompt. See https://code.claude.com/docs/en/plugin-hints. +package pluginhint + +import ( + "io" + "os" + "sync" +) + +// hintLine must occupy its own line and reference a plugin in an official +// Anthropic marketplace; Claude Code drops hints that don't. +const hintLine = `` + +var once sync.Once + +// CLAUDECODE is set on every Claude Code version; CLAUDE_CODE_CHILD_SESSION +// (v2.1.172+) only in subprocesses it spawns, such as Bash tool calls. +func inClaudeCode() bool { + return os.Getenv("CLAUDECODE") != "" || os.Getenv("CLAUDE_CODE_CHILD_SESSION") != "" +} + +// Emit writes the plugin hint to stderr when running inside Claude Code. It is +// a no-op otherwise, and emits at most once per process, so it is safe to call +// on every invocation and from multiple code paths. +func Emit() { + emitTo(os.Stderr) +} + +func emitTo(w io.Writer) { + if !inClaudeCode() { + return + } + once.Do(func() { + _, _ = io.WriteString(w, hintLine+"\n") + }) +} diff --git a/internal/pkg/utils/pluginhint/pluginhint_test.go b/internal/pkg/utils/pluginhint/pluginhint_test.go new file mode 100644 index 0000000..d384c18 --- /dev/null +++ b/internal/pkg/utils/pluginhint/pluginhint_test.go @@ -0,0 +1,90 @@ +package pluginhint + +import ( + "bytes" + "strings" + "sync" + "testing" +) + +// resetOnce restores the package-level once guard so each test starts fresh. +func resetOnce() { + once = sync.Once{} +} + +func TestEmitTo_WritesHintWhenInClaudeCode(t *testing.T) { + tests := []struct { + name string + env map[string]string + }{ + {name: "CLAUDECODE set", env: map[string]string{"CLAUDECODE": "1"}}, + {name: "CLAUDE_CODE_CHILD_SESSION set", env: map[string]string{"CLAUDE_CODE_CHILD_SESSION": "1"}}, + {name: "both set", env: map[string]string{"CLAUDECODE": "1", "CLAUDE_CODE_CHILD_SESSION": "1"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resetOnce() + t.Setenv("CLAUDECODE", "") + t.Setenv("CLAUDE_CODE_CHILD_SESSION", "") + for k, v := range tt.env { + t.Setenv(k, v) + } + + var buf bytes.Buffer + emitTo(&buf) + + got := buf.String() + if got != hintLine+"\n" { + t.Errorf("emitTo() = %q, want %q", got, hintLine+"\n") + } + }) + } +} + +func TestEmitTo_NoopOutsideClaudeCode(t *testing.T) { + resetOnce() + t.Setenv("CLAUDECODE", "") + t.Setenv("CLAUDE_CODE_CHILD_SESSION", "") + + var buf bytes.Buffer + emitTo(&buf) + + if buf.Len() != 0 { + t.Errorf("emitTo() wrote %q outside Claude Code, want nothing", buf.String()) + } +} + +func TestEmitTo_OnlyEmitsOnce(t *testing.T) { + resetOnce() + t.Setenv("CLAUDECODE", "1") + + var buf bytes.Buffer + emitTo(&buf) + emitTo(&buf) + emitTo(&buf) + + if got := strings.Count(buf.String(), "claude-code-hint"); got != 1 { + t.Errorf("emitTo() emitted hint %d times, want 1", got) + } +} + +func TestHintLine_MatchesProtocol(t *testing.T) { + // The tag must be self-closing with the three required attributes, target + // the official marketplace, and contain no newline (it must occupy a single + // line; emitTo appends the terminating newline). + if strings.Contains(hintLine, "\n") { + t.Errorf("hintLine must not contain a newline: %q", hintLine) + } + for _, want := range []string{ + `v="1"`, + `type="plugin"`, + `value="pinecone@claude-plugins-official"`, + "", + } { + if !strings.Contains(hintLine, want) { + t.Errorf("hintLine missing %q; got %q", want, hintLine) + } + } +}