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)
+ }
+ }
+}