diff --git a/cliv2/internal/persona/agent.go b/cliv2/internal/persona/agent.go new file mode 100644 index 0000000000..f192ed51d7 --- /dev/null +++ b/cliv2/internal/persona/agent.go @@ -0,0 +1,135 @@ +// This file is adapted from @vercel/detect-agent (v1.2.3) by Vercel, Inc. +// Source: https://github.com/vercel/vercel/blob/0d0b990edda112c5cc91e95e0d054878542fe3be/packages/detect-agent/src/index.ts +// +// Original work Copyright 2017 Vercel, Inc. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// This Go port has been modified from the original TypeScript source. + +package persona + +import ( + "os" + "strings" +) + +// Agent is the canonical name of a known AI coding agent / harness. +type Agent string + +const ( + AgentCursor Agent = "cursor" + AgentCursorCLI Agent = "cursor-cli" + AgentClaude Agent = "claude" + AgentCowork Agent = "cowork" + AgentDevin Agent = "devin" + AgentReplit Agent = "replit" + AgentGemini Agent = "gemini" + AgentCodex Agent = "codex" + AgentAntigravity Agent = "antigravity" + AgentAugmentCLI Agent = "augment-cli" + AgentOpenCode Agent = "opencode" + AgentGitHubCopilot Agent = "github-copilot" + AgentV0 Agent = "v0" +) + +// devinMarkerPath is a filesystem marker present inside the Devin sandbox. +const devinMarkerPath = "/opt/.devin" + +// lookup abstracts the environment so detection can be tested without mutating +// the real process environment. +type lookup struct { + getenv func(string) string + fileExists func(string) bool +} + +func osLookup() lookup { + return lookup{ + getenv: os.Getenv, + fileExists: func(path string) bool { + _, err := os.Stat(path) + return err == nil + }, + } +} + +// signature describes how a single agent is recognised from the environment. +type signature struct { + agent Agent + match func(l lookup) bool +} + +// anySet reports true if any of the given environment variables is set to a +// non-empty value. +func anySet(l lookup, keys ...string) bool { + for _, k := range keys { + if l.getenv(k) != "" { + return true + } + } + return false +} + +// signatures is the ordered list of agent detectors. Order matters: the first +// match wins, so more specific signatures must precede more generic ones. +var signatures = []signature{ + {AgentCursor, func(l lookup) bool { return anySet(l, "CURSOR_TRACE_ID") }}, + {AgentCursorCLI, func(l lookup) bool { + return anySet(l, "CURSOR_AGENT") || l.getenv("CURSOR_EXTENSION_HOST_ROLE") == "agent-exec" + }}, + {AgentGemini, func(l lookup) bool { return anySet(l, "GEMINI_CLI") }}, + {AgentCodex, func(l lookup) bool { return anySet(l, "CODEX_SANDBOX", "CODEX_CI", "CODEX_THREAD_ID") }}, + {AgentAntigravity, func(l lookup) bool { return anySet(l, "ANTIGRAVITY_AGENT") }}, + {AgentAugmentCLI, func(l lookup) bool { return anySet(l, "AUGMENT_AGENT") }}, + {AgentOpenCode, func(l lookup) bool { return anySet(l, "OPENCODE_CLIENT") }}, + // Claude Code: the "cowork" surface is a more specific variant and must be + // checked first. + {AgentCowork, func(l lookup) bool { + return anySet(l, "CLAUDECODE", "CLAUDE_CODE") && anySet(l, "CLAUDE_CODE_IS_COWORK") + }}, + {AgentClaude, func(l lookup) bool { return anySet(l, "CLAUDECODE", "CLAUDE_CODE") }}, + {AgentReplit, func(l lookup) bool { return anySet(l, "REPL_ID") }}, + {AgentGitHubCopilot, func(l lookup) bool { + return anySet(l, "COPILOT_MODEL", "COPILOT_ALLOW_ALL", "COPILOT_GITHUB_TOKEN") + }}, + {AgentDevin, func(l lookup) bool { return l.fileExists(devinMarkerPath) }}, +} + +// DetectAgent resolves the active AI agent, if any, from the current process +// environment. +func DetectAgent() (Agent, bool) { + return detectAgent(osLookup()) +} + +// detectAgent resolves the active AI agent, if any. +// +// The AI_AGENT environment variable is the explicit, highest-priority signal: +// when set it is trusted verbatim (with a couple of canonicalisations). When it +// is absent, detection falls back to per-agent environment / filesystem +// signatures. +func detectAgent(l lookup) (Agent, bool) { + if name := strings.TrimSpace(l.getenv("AI_AGENT")); name != "" { + return canonicalAgent(name), true + } + + for _, s := range signatures { + if s.match(l) { + return s.agent, true + } + } + + return "", false +} + +// canonicalAgent normalises an explicitly declared AI_AGENT value onto the set +// of canonical agent names. +func canonicalAgent(name string) Agent { + switch Agent(name) { + case "github-copilot-cli": + return AgentGitHubCopilot + default: + return Agent(name) + } +} diff --git a/cliv2/internal/persona/agent_test.go b/cliv2/internal/persona/agent_test.go new file mode 100644 index 0000000000..be3a4f1a01 --- /dev/null +++ b/cliv2/internal/persona/agent_test.go @@ -0,0 +1,51 @@ +package persona + +import "testing" + +func newLookup(env map[string]string, files map[string]bool) lookup { + return lookup{ + getenv: func(k string) string { return env[k] }, + fileExists: func(p string) bool { return files[p] }, + } +} + +func TestDetectAgent_Signatures(t *testing.T) { + tests := []struct { + name string + env map[string]string + files map[string]bool + want Agent + }{ + {name: "none", want: ""}, + {name: "explicit AI_AGENT", env: map[string]string{"AI_AGENT": " windsurf "}, want: "windsurf"}, + {name: "AI_AGENT overrides signatures", env: map[string]string{"AI_AGENT": "v0", "CLAUDECODE": "1"}, want: AgentV0}, + {name: "github-copilot-cli canonicalised", env: map[string]string{"AI_AGENT": "github-copilot-cli"}, want: AgentGitHubCopilot}, + {name: "cursor", env: map[string]string{"CURSOR_TRACE_ID": "x"}, want: AgentCursor}, + {name: "cursor-cli via CURSOR_AGENT", env: map[string]string{"CURSOR_AGENT": "1"}, want: AgentCursorCLI}, + {name: "cursor-cli via host role", env: map[string]string{"CURSOR_EXTENSION_HOST_ROLE": "agent-exec"}, want: AgentCursorCLI}, + {name: "cursor-cli host role mismatch", env: map[string]string{"CURSOR_EXTENSION_HOST_ROLE": "editor"}, want: ""}, + {name: "gemini", env: map[string]string{"GEMINI_CLI": "1"}, want: AgentGemini}, + {name: "codex", env: map[string]string{"CODEX_THREAD_ID": "abc"}, want: AgentCodex}, + {name: "antigravity", env: map[string]string{"ANTIGRAVITY_AGENT": "1"}, want: AgentAntigravity}, + {name: "augment", env: map[string]string{"AUGMENT_AGENT": "1"}, want: AgentAugmentCLI}, + {name: "opencode", env: map[string]string{"OPENCODE_CLIENT": "1"}, want: AgentOpenCode}, + {name: "claude", env: map[string]string{"CLAUDECODE": "1"}, want: AgentClaude}, + {name: "cowork", env: map[string]string{"CLAUDE_CODE": "1", "CLAUDE_CODE_IS_COWORK": "1"}, want: AgentCowork}, + {name: "replit", env: map[string]string{"REPL_ID": "1"}, want: AgentReplit}, + {name: "github copilot", env: map[string]string{"COPILOT_MODEL": "gpt"}, want: AgentGitHubCopilot}, + {name: "devin via marker file", files: map[string]bool{devinMarkerPath: true}, want: AgentDevin}, + {name: "empty AI_AGENT falls through", env: map[string]string{"AI_AGENT": " ", "GEMINI_CLI": "1"}, want: AgentGemini}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := detectAgent(newLookup(tt.env, tt.files)) + if (tt.want != "") != ok { + t.Fatalf("ok = %v, want %v (agent %q)", ok, tt.want != "", tt.want) + } + if got != tt.want { + t.Fatalf("agent = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/cliv2/internal/persona/interactive.go b/cliv2/internal/persona/interactive.go new file mode 100644 index 0000000000..d7cb9aee29 --- /dev/null +++ b/cliv2/internal/persona/interactive.go @@ -0,0 +1,54 @@ +package persona + +import ( + "os" + + "github.com/mattn/go-isatty" +) + +// InteractiveMode is a bitmask recording which of the standard streams are +// attached to a terminal. Each stream is one bit, so a value can encode any +// combination. +type InteractiveMode uint8 + +// Stream bits, each a distinct power of two so they can be OR'd together. +const ( + StdinTTY InteractiveMode = 1 << iota // stdin TTY flag (bit 0, value 1) + StdoutTTY // stdout TTY flag (bit 1, value 2) + StderrTTY // stderr TTY flag (bit 2, value 4) +) + +// Has reports whether all bits in mask are set in m, so a combined mask +// (e.g. StdoutTTY|StderrTTY) requires every named stream to be a terminal. +func (m InteractiveMode) Has(mask InteractiveMode) bool { + return m&mask == mask +} + +// isTerminal reports whether the given file is attached to a terminal. It +// covers both native terminals and cygwin/msys2 terminals (e.g. git-bash on +// Windows), which are detected by a separate code path in go-isatty. +func isTerminal(f *os.File) bool { + return isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd()) +} + +// getInteractiveMode builds an InteractiveMode bitmask using check to test +// each standard stream. +func getInteractiveMode(check func(*os.File) bool) InteractiveMode { + var m InteractiveMode + if check(os.Stdin) { + m |= StdinTTY + } + if check(os.Stdout) { + m |= StdoutTTY + } + if check(os.Stderr) { + m |= StderrTTY + } + return m +} + +// GetInteractiveMode probes the standard streams and returns a bitmask +// describing which of them are attached to a terminal. +func GetInteractiveMode() InteractiveMode { + return getInteractiveMode(isTerminal) +} diff --git a/cliv2/internal/persona/interactive_test.go b/cliv2/internal/persona/interactive_test.go new file mode 100644 index 0000000000..1a505185a1 --- /dev/null +++ b/cliv2/internal/persona/interactive_test.go @@ -0,0 +1,60 @@ +package persona + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_getInteractiveMode(t *testing.T) { + yes := func(*os.File) bool { return true } + no := func(*os.File) bool { return false } + only := func(target *os.File) func(*os.File) bool { + return func(f *os.File) bool { return f == target } + } + + testCases := []struct { + name string + check func(*os.File) bool + want InteractiveMode + }{ + {"all TTY", yes, StdinTTY | StdoutTTY | StderrTTY}, + {"no TTY", no, 0}, + {"only stdin", only(os.Stdin), StdinTTY}, + {"only stdout", only(os.Stdout), StdoutTTY}, + {"only stderr", only(os.Stderr), StderrTTY}, + {"stdin and stdout", func(f *os.File) bool { return f == os.Stdin || f == os.Stdout }, StdinTTY | StdoutTTY}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, getInteractiveMode(tc.check)) + }) + } +} + +func Test_isTerminal_pipeIsNotATerminal(t *testing.T) { + r, w, err := os.Pipe() + require.NoError(t, err) + defer r.Close() + defer w.Close() + + assert.False(t, isTerminal(r), "read end of a pipe is not a terminal") + assert.False(t, isTerminal(w), "write end of a pipe is not a terminal") +} + +func Test_isTerminal_regularFileIsNotATerminal(t *testing.T) { + f, err := os.CreateTemp(t.TempDir(), "interactive-test") + require.NoError(t, err) + defer f.Close() + + assert.False(t, isTerminal(f), "a regular file is not a terminal") +} + +func Test_GetInteractiveMode_underTestIsNonInteractive(t *testing.T) { + // The go test runner does not allocate a TTY for the standard streams, so + // no stream should be flagged as a terminal. + assert.Equal(t, InteractiveMode(0), GetInteractiveMode()) +} diff --git a/cliv2/internal/persona/persona.go b/cliv2/internal/persona/persona.go new file mode 100644 index 0000000000..202ad19a88 --- /dev/null +++ b/cliv2/internal/persona/persona.go @@ -0,0 +1,43 @@ +package persona + +const ( + // keyInteractive reports whether stdin is attached to a terminal. + keyInteractive = "persona.interactive" + // keyInteractiveMode is the bitmask of which standard streams are attached to + // a terminal (StdinTTY | StdoutTTY | StderrTTY). + keyInteractiveMode = "persona.interactive_mode" + // keyAgent reports the canonical name of the AI coding agent driving the + // invocation, when one is detected. + keyAgent = "persona.agent" +) + +// Analytics is the subset of the analytics sink that persona reporting needs. +type Analytics interface { + AddExtensionBoolValue(key string, value bool) + AddExtensionIntegerValue(key string, value int) + AddExtensionStringValue(key string, value string) + IsCiEnvironment() bool +} + +// Report adds the persona extension values to the given analytics sink. +func Report(a Analytics) { + report(a, GetInteractiveMode(), DetectAgent) +} + +func report(a Analytics, mode InteractiveMode, detect func() (Agent, bool)) { + // A terminal on stdin is our signal that a human is driving the session. + interactive := mode.Has(StdinTTY) + // CI runners can't be trusted to report stdin honestly: some allocate a + // terminal or otherwise manipulate the stream with no human present (e.g. + // CircleCI injects null bytes into every command's stdin, see + // https://github.com/CircleCI-Public/circleci-cli/issues/456). Treat any CI + // invocation as non-interactive regardless of the TTY signal. + if a.IsCiEnvironment() { + interactive = false + } + a.AddExtensionBoolValue(keyInteractive, interactive) + a.AddExtensionIntegerValue(keyInteractiveMode, int(mode)) + if agent, ok := detect(); ok { + a.AddExtensionStringValue(keyAgent, string(agent)) + } +} diff --git a/cliv2/internal/persona/persona_test.go b/cliv2/internal/persona/persona_test.go new file mode 100644 index 0000000000..399b1dd91c --- /dev/null +++ b/cliv2/internal/persona/persona_test.go @@ -0,0 +1,127 @@ +package persona + +import "testing" + +type fakeAnalytics struct { + bools map[string]bool + integers map[string]int + strings map[string]string + ci bool +} + +func newFakeAnalytics() *fakeAnalytics { + return &fakeAnalytics{ + bools: map[string]bool{}, + integers: map[string]int{}, + strings: map[string]string{}, + } +} + +func (f *fakeAnalytics) AddExtensionBoolValue(key string, value bool) { f.bools[key] = value } +func (f *fakeAnalytics) AddExtensionIntegerValue(key string, value int) { f.integers[key] = value } +func (f *fakeAnalytics) AddExtensionStringValue(key, value string) { f.strings[key] = value } +func (f *fakeAnalytics) IsCiEnvironment() bool { return f.ci } + +func TestReport(t *testing.T) { + noAgent := func() (Agent, bool) { return "", false } + + tests := []struct { + name string + mode InteractiveMode + ci bool + detect func() (Agent, bool) + wantInteractive bool + wantMode int + wantAgent string + wantAgentOK bool + }{ + { + name: "stdin TTY only", + mode: StdinTTY, + detect: noAgent, + wantInteractive: true, + wantMode: int(StdinTTY), + }, + { + name: "stdout TTY does not count as interactive", + mode: StdoutTTY | StderrTTY, + detect: noAgent, + wantInteractive: false, + wantMode: int(StdoutTTY | StderrTTY), + }, + { + name: "omits agent when absent", + mode: 0, + detect: noAgent, + wantInteractive: false, + wantMode: 0, + }, + { + name: "emits agent when detected", + mode: 0, + detect: func() (Agent, bool) { return AgentCursorCLI, true }, + wantInteractive: false, + wantMode: 0, + wantAgent: string(AgentCursorCLI), + wantAgentOK: true, + }, + { + name: "CI environment forces interactive false even with stdin TTY", + mode: StdinTTY, + ci: true, + detect: noAgent, + wantInteractive: false, + wantMode: int(StdinTTY), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := newFakeAnalytics() + a.ci = tt.ci + report(a, tt.mode, tt.detect) + + gotInteractive, ok := a.bools[keyInteractive] + if !ok { + t.Fatalf("expected %q to be reported", keyInteractive) + } + if gotInteractive != tt.wantInteractive { + t.Fatalf("%q = %v, want %v", keyInteractive, gotInteractive, tt.wantInteractive) + } + + gotMode, ok := a.integers[keyInteractiveMode] + if !ok { + t.Fatalf("expected %q to be reported", keyInteractiveMode) + } + if gotMode != tt.wantMode { + t.Fatalf("%q = %d, want %d", keyInteractiveMode, gotMode, tt.wantMode) + } + + gotAgent, ok := a.strings[keyAgent] + if tt.wantAgentOK { + if !ok { + t.Fatalf("expected %q to be reported", keyAgent) + } + if gotAgent != tt.wantAgent { + t.Fatalf("%q = %q, want %q", keyAgent, gotAgent, tt.wantAgent) + } + return + } + if ok { + t.Fatalf("expected %q to be omitted, got %q", keyAgent, gotAgent) + } + }) + } +} + +func TestReport_PublicEntrypoint(t *testing.T) { + a := newFakeAnalytics() + Report(a) + + if _, ok := a.bools[keyInteractive]; !ok { + t.Fatalf("expected %q to be reported", keyInteractive) + } + if _, ok := a.integers[keyInteractiveMode]; !ok { + t.Fatalf("expected %q to be reported", keyInteractiveMode) + } +} diff --git a/cliv2/internal/utils/interactive.go b/cliv2/internal/utils/interactive.go deleted file mode 100644 index 70b4c7e8c8..0000000000 --- a/cliv2/internal/utils/interactive.go +++ /dev/null @@ -1,20 +0,0 @@ -package utils - -import ( - "os" - - "github.com/mattn/go-isatty" -) - -// isTerminal reports whether the given file is attached to a terminal. It -// covers both native terminals and cygwin/msys2 terminals (e.g. git-bash on -// Windows), which are detected by a separate code path in go-isatty. -func isTerminal(f *os.File) bool { - return isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd()) -} - -// IsInteractive reports whether the CLI is running interactively, i.e. stdin -// is attached to a terminal. -func IsInteractive() bool { - return isTerminal(os.Stdin) -} diff --git a/cliv2/internal/utils/interactive_test.go b/cliv2/internal/utils/interactive_test.go deleted file mode 100644 index d308f2af0b..0000000000 --- a/cliv2/internal/utils/interactive_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package utils - -import ( - "os" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func Test_isTerminal_pipeIsNotATerminal(t *testing.T) { - r, w, err := os.Pipe() - require.NoError(t, err) - defer r.Close() - defer w.Close() - - assert.False(t, isTerminal(r), "read end of a pipe is not a terminal") - assert.False(t, isTerminal(w), "write end of a pipe is not a terminal") -} - -func Test_isTerminal_regularFileIsNotATerminal(t *testing.T) { - f, err := os.CreateTemp(t.TempDir(), "interactive-test") - require.NoError(t, err) - defer f.Close() - - assert.False(t, isTerminal(f), "a regular file is not a terminal") -} - -func Test_IsInteractive_underTestIsNonInteractive(t *testing.T) { - // The go test runner does not allocate a TTY for stdin, so the CLI must - // consider itself non-interactive in this environment. - assert.False(t, IsInteractive()) -} diff --git a/cliv2/pkg/core/main.go b/cliv2/pkg/core/main.go index dda97c1a2c..7cbc22ee1a 100644 --- a/cliv2/pkg/core/main.go +++ b/cliv2/pkg/core/main.go @@ -37,6 +37,7 @@ import ( "github.com/snyk/cli/cliv2/internal/constants" cliv2utils "github.com/snyk/cli/cliv2/internal/utils" + persona "github.com/snyk/cli/cliv2/internal/persona" localworkflows "github.com/snyk/go-application-framework/pkg/local_workflows" "github.com/snyk/go-application-framework/pkg/local_workflows/config_utils" @@ -648,7 +649,7 @@ func mainWithErrorCode(additionalExts []workflow.ExtensionInit) int { cliAnalytics.GetInstrumentation().SetCategory(instrumentation.DetermineCategory(os.Args, globalEngine)) cliAnalytics.GetInstrumentation().SetStage(instrumentation.DetermineStage(cliAnalytics.IsCiEnvironment())) cliAnalytics.GetInstrumentation().SetStatus(analytics.Success) - cliAnalytics.AddExtensionBoolValue("persona.interactive", cliv2utils.IsInteractive()) + persona.Report(cliAnalytics) setTimeout(globalConfiguration, func() { tearDownOnce.Do(func() {