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
6 changes: 6 additions & 0 deletions internal/pkg/cli/command/root/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)

Expand Down
40 changes: 40 additions & 0 deletions internal/pkg/utils/pluginhint/pluginhint.go
Original file line number Diff line number Diff line change
@@ -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 = `<claude-code-hint v="1" type="plugin" value="pinecone@claude-plugins-official" />`

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")
})
}
90 changes: 90 additions & 0 deletions internal/pkg/utils/pluginhint/pluginhint_test.go
Original file line number Diff line number Diff line change
@@ -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"`,
"<claude-code-hint",
"/>",
} {
if !strings.Contains(hintLine, want) {
t.Errorf("hintLine missing %q; got %q", want, hintLine)
}
}
}
Loading