Skip to content
Open
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
135 changes: 135 additions & 0 deletions cliv2/internal/persona/agent.go

@robertolopezlopez robertolopezlopez Jun 23, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are agent* and interactive* in the package persona? Without looking down below, I have the feeling that agent, interactive and persona have their own (complex?) internal logic.

Wouldn't it be cleaner to have them in separate packages and offer just the necessary resources, to isolate such domain specific logic? (i.e. avoiding tight coupling between them)

Original file line number Diff line number Diff line change
@@ -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) {
Comment thread
robertolopezlopez marked this conversation as resolved.
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 {
Comment thread
robertolopezlopez marked this conversation as resolved.
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)
}
}
51 changes: 51 additions & 0 deletions cliv2/internal/persona/agent_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
54 changes: 54 additions & 0 deletions cliv2/internal/persona/interactive.go
Original file line number Diff line number Diff line change
@@ -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)
)
Comment on lines +15 to +19

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure if I follow what is happening here, how is it that those constants (even those not initialised) are attached to a terminal? - do you mind to explain? :-)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤣 i will change the comments

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks


// 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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you please add a good godoc explanation of what is going on here? ;-)

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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: The PR description mentions this code is unchanged but the file seems different.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The persona.interactive key remains the same (true only if stdin is a TTY) here. The function simply generates a bitmask for all streams and records it in persona.interactive_mode.

I'm happy to adjust this based on your preference. I could:

  • Keep it as is
  • Rename it to be clearer
  • Drop it completely
  • Add an IsInteractive function (taking InteractiveMode and returning InteractiveMode & StdinTTY) rather than leaving it inline in persona.go.

Please let me know what you think is best 😅

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please update PR description accordingly

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)
}
60 changes: 60 additions & 0 deletions cliv2/internal/persona/interactive_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
43 changes: 43 additions & 0 deletions cliv2/internal/persona/persona.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package persona

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: probably this file should be named analytics.go, or something different. I cannot see any persona type in the whole PR. Not blocker


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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would prefer to use a receiver here and in report() - you are even modifying the a (look in lines 32 to 35)

@octavian-snyk octavian-snyk Jun 24, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would be nice, but main.go will probably end up looking the same
To clarify the approach, I set up Analytics as a local interface to capture a subset of GAF interface methods for testing.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My point is not how it would look in main, but rather if we are modifying the analytics isntance state

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() {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please add a comment to document it in code

interactive = false
}
a.AddExtensionBoolValue(keyInteractive, interactive)
a.AddExtensionIntegerValue(keyInteractiveMode, int(mode))
if agent, ok := detect(); ok {
a.AddExtensionStringValue(keyAgent, string(agent))
}
}
Loading