From be6eafaba2606e5f0d0befc299b58e8ec562e6b0 Mon Sep 17 00:00:00 2001 From: Harshita Daddala Date: Tue, 23 Jun 2026 11:26:04 -0400 Subject: [PATCH 1/2] feat: emit Claude Code plugin hint for the official Pinecone plugin Add support for the Claude Code plugin-hint protocol (https://code.claude.com/docs/en/plugin-hints). When `pc` runs inside a Claude Code session, it writes a one-line `` marker to stderr recommending the official `pinecone@claude-plugins-official` plugin. Claude Code strips the marker before it reaches the model and shows the user a one-time install prompt. - New `internal/pkg/utils/pluginhint` package gated on the CLAUDECODE and CLAUDE_CODE_CHILD_SESSION env vars, emitting at most once per process. - Wired into root Execute() so it fires for every invocation (including --help and unknown-command errors) regardless of subcommand PreRun hooks. - Unit tests for emission, no-op-outside-Claude-Code, emit-once, and protocol conformance. Co-Authored-By: Claude Opus 4.8 --- internal/pkg/cli/command/root/root.go | 9 ++ internal/pkg/utils/pluginhint/pluginhint.go | 66 ++++++++++++++ .../pkg/utils/pluginhint/pluginhint_test.go | 90 +++++++++++++++++++ 3 files changed, 165 insertions(+) create mode 100644 internal/pkg/utils/pluginhint/pluginhint.go create mode 100644 internal/pkg/utils/pluginhint/pluginhint_test.go diff --git a/internal/pkg/cli/command/root/root.go b/internal/pkg/cli/command/root/root.go index ecce3ca..74c7942 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,14 @@ type GlobalOptions struct { } func Execute() { + // If running inside Claude Code, emit a one-line marker prompting the user + // to install the official Pinecone plugin. Placed here (rather than in a + // PersistentPreRun) so it fires for every invocation — including --help and + // unknown-command errors — regardless of any subcommand PersistentPreRun + // overrides. Claude Code strips the marker from the output before it reaches + // the model, so it never appears in the conversation. + 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..96fbaab --- /dev/null +++ b/internal/pkg/utils/pluginhint/pluginhint.go @@ -0,0 +1,66 @@ +// Package pluginhint emits a one-line marker that prompts Claude Code users to +// install the official Pinecone plugin. +// +// When the CLI detects it is running inside Claude Code (via the CLAUDECODE or +// CLAUDE_CODE_CHILD_SESSION environment variables), it writes a self-closing +// tag to stderr on its own line. Claude Code reads the +// marker, strips it from the command output before it reaches the model (so it +// never appears in the conversation or counts toward tokens), and shows the +// user a one-time prompt to install the plugin. +// +// The protocol is documented at https://code.claude.com/docs/en/plugin-hints. +package pluginhint + +import ( + "io" + "os" + "sync" +) + +// hintLine is the marker Claude Code looks for. It must occupy its own line and +// reference a plugin in an official Anthropic-controlled marketplace, otherwise +// Claude Code drops it. +// +// v — protocol version (1 is the only supported value) +// type — hint kind (plugin is the only supported value) +// value — plugin identifier in name@marketplace form +const hintLine = `` + +// once guards against emitting the hint more than once per process. Claude Code +// deduplicates by plugin, so a single emission per invocation is all that's +// needed even though Emit may be reachable from multiple code paths. +var once sync.Once + +// inClaudeCode reports whether the CLI appears to be running inside a Claude +// Code session. +// +// - CLAUDECODE is set on every Claude Code version, giving the widest reach. +// - CLAUDE_CODE_CHILD_SESSION is set (v2.1.172+) only in subprocesses Claude +// Code itself spawns, such as Bash tool calls. +// +// Gating on either keeps the marker out of normal human-run invocations. +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 +// safe to call from any number of code paths and on every invocation; the hint +// is written at most once per process and is a no-op outside Claude Code. +// +// stderr keeps the tag out of shell pipelines (e.g. `pc index list | jq`), +// though Claude Code scans both streams. +func Emit() { + emitTo(os.Stderr) +} + +// emitTo is the testable core of Emit. +func emitTo(w io.Writer) { + if !inClaudeCode() { + return + } + once.Do(func() { + // Written on its own line; the trailing newline is required so the tag + // occupies a line by itself. + _, _ = 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) + } + } +} From 6eb1bd9db415b567d11efc1f5fbacea238b07522 Mon Sep 17 00:00:00 2001 From: Harshita Daddala Date: Tue, 23 Jun 2026 11:49:09 -0400 Subject: [PATCH 2/2] refactor: trim plugin-hint comments to essentials Keep the protocol-rule comments a reader can't infer from the code (package doc, hintLine constraints, env-var semantics) and drop the per-symbol restatements. Co-Authored-By: Claude Opus 4.8 --- internal/pkg/cli/command/root/root.go | 9 ++-- internal/pkg/utils/pluginhint/pluginhint.go | 48 +++++---------------- 2 files changed, 14 insertions(+), 43 deletions(-) diff --git a/internal/pkg/cli/command/root/root.go b/internal/pkg/cli/command/root/root.go index 74c7942..c88d386 100644 --- a/internal/pkg/cli/command/root/root.go +++ b/internal/pkg/cli/command/root/root.go @@ -67,12 +67,9 @@ type GlobalOptions struct { } func Execute() { - // If running inside Claude Code, emit a one-line marker prompting the user - // to install the official Pinecone plugin. Placed here (rather than in a - // PersistentPreRun) so it fires for every invocation — including --help and - // unknown-command errors — regardless of any subcommand PersistentPreRun - // overrides. Claude Code strips the marker from the output before it reaches - // the model, so it never appears in the conversation. + // 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 diff --git a/internal/pkg/utils/pluginhint/pluginhint.go b/internal/pkg/utils/pluginhint/pluginhint.go index 96fbaab..0d5eea5 100644 --- a/internal/pkg/utils/pluginhint/pluginhint.go +++ b/internal/pkg/utils/pluginhint/pluginhint.go @@ -1,14 +1,8 @@ -// Package pluginhint emits a one-line marker that prompts Claude Code users to -// install the official Pinecone plugin. -// -// When the CLI detects it is running inside Claude Code (via the CLAUDECODE or -// CLAUDE_CODE_CHILD_SESSION environment variables), it writes a self-closing -// tag to stderr on its own line. Claude Code reads the -// marker, strips it from the command output before it reaches the model (so it -// never appears in the conversation or counts toward tokens), and shows the -// user a one-time prompt to install the plugin. -// -// The protocol is documented at https://code.claude.com/docs/en/plugin-hints. +// 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 ( @@ -17,50 +11,30 @@ import ( "sync" ) -// hintLine is the marker Claude Code looks for. It must occupy its own line and -// reference a plugin in an official Anthropic-controlled marketplace, otherwise -// Claude Code drops it. -// -// v — protocol version (1 is the only supported value) -// type — hint kind (plugin is the only supported value) -// value — plugin identifier in name@marketplace form +// hintLine must occupy its own line and reference a plugin in an official +// Anthropic marketplace; Claude Code drops hints that don't. const hintLine = `` -// once guards against emitting the hint more than once per process. Claude Code -// deduplicates by plugin, so a single emission per invocation is all that's -// needed even though Emit may be reachable from multiple code paths. var once sync.Once -// inClaudeCode reports whether the CLI appears to be running inside a Claude -// Code session. -// -// - CLAUDECODE is set on every Claude Code version, giving the widest reach. -// - CLAUDE_CODE_CHILD_SESSION is set (v2.1.172+) only in subprocesses Claude -// Code itself spawns, such as Bash tool calls. -// -// Gating on either keeps the marker out of normal human-run invocations. +// 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 -// safe to call from any number of code paths and on every invocation; the hint -// is written at most once per process and is a no-op outside Claude Code. -// -// stderr keeps the tag out of shell pipelines (e.g. `pc index list | jq`), -// though Claude Code scans both streams. +// 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) } -// emitTo is the testable core of Emit. func emitTo(w io.Writer) { if !inClaudeCode() { return } once.Do(func() { - // Written on its own line; the trailing newline is required so the tag - // occupies a line by itself. _, _ = io.WriteString(w, hintLine+"\n") }) }