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
44 changes: 44 additions & 0 deletions docs/common/aihelp.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package common

import (
"os"
"strconv"

"github.com/jfrog/jfrog-cli-core/v2/common/commands"
)

// EnvAIHelp opts a process in or out of AI-oriented help text rendering.
// Values parseable by strconv.ParseBool (1/t/true/0/f/false, case-insensitive)
// force the mode on or off. Unset or unparseable falls back to
// ExecutionContext.IsAgent auto-detection.
const EnvAIHelp = "JFROG_CLI_AI_HELP"

// AIAgentDetector reports whether the running process is an AI agent.
// The default consults the memoized ExecutionContext in
// common/commands. Exposed as a variable so tests can inject a deterministic
// answer — DetectExecutionContext caches via sync.Once and cannot be reset.
var AIAgentDetector = func() bool {
return commands.DetectExecutionContext().IsAgent
}

// AIHelpEnabled reports whether help rendering should prefer AIDescription
// over Description. The env var, when parseable as a bool, wins over
// auto-detection so users can opt out of agent-flavored help.
func AIHelpEnabled() bool {
if v, ok := os.LookupEnv(EnvAIHelp); ok {
if b, err := strconv.ParseBool(v); err == nil {
return b
}
}
return AIAgentDetector()
}

// ResolveDescription returns the AI variant when it is non-empty and AI help
// is enabled; otherwise it returns the human variant. An empty ai always
// falls back to human, so partial backfill across commands is safe.
func ResolveDescription(human, ai string) string {
if ai != "" && AIHelpEnabled() {
return ai
}
return human
}
113 changes: 113 additions & 0 deletions docs/common/aihelp_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package common

import (
"testing"

"github.com/stretchr/testify/assert"
)

// withAgentDetector installs an AIAgentDetector for the duration of a test.
// commands.DetectExecutionContext is sync.Once-memoized, so we can't reach
// the underlying detection by toggling env vars — instead we replace the
// hook AIHelpEnabled uses.
func withAgentDetector(t *testing.T, isAgent bool) {
t.Helper()
prev := AIAgentDetector
AIAgentDetector = func() bool { return isAgent }
t.Cleanup(func() { AIAgentDetector = prev })
}

func TestResolveDescription(t *testing.T) {
const human = "human help"
const ai = "ai help"

tests := []struct {
name string
envAIHelp string // pass "" to leave env unset
setEnv bool // if false, don't touch the env var at all
isAgent bool
ai string
expected string
}{
{
name: "env force-on, no agent -> AI text",
envAIHelp: "true",
setEnv: true,
isAgent: false,
ai: ai,
expected: ai,
},
{
name: "env force-off beats detected agent -> human text",
envAIHelp: "false",
setEnv: true,
isAgent: true,
ai: ai,
expected: human,
},
{
name: "no env + agent detected -> AI text",
setEnv: false,
isAgent: true,
ai: ai,
expected: ai,
},
{
name: "no env + no agent -> human text",
setEnv: false,
isAgent: false,
ai: ai,
expected: human,
},
{
name: "agent detected + empty AI -> human fallback",
setEnv: false,
isAgent: true,
ai: "",
expected: human,
},
{
name: "invalid env value falls back to detection (no agent here)",
envAIHelp: "maybe",
setEnv: true,
isAgent: false,
ai: ai,
expected: human,
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
withAgentDetector(t, tc.isAgent)
if tc.setEnv {
t.Setenv(EnvAIHelp, tc.envAIHelp)
}
assert.Equal(t, tc.expected, ResolveDescription(human, tc.ai))
})
}
}

func TestAIHelpEnabledEnvParsing(t *testing.T) {
// Detection deliberately returns true so we can prove env-parsing
// short-circuits: only truthy/falsy env values should affect the result;
// invalid values must fall through to the (here forced-true) detector.
tests := []struct {
value string
expected bool
}{
{"true", true},
{"1", true},
{"TRUE", true},
{"false", false},
{"0", false},
{"maybe", true}, // unparseable -> falls back to AIAgentDetector (true)
{"", true}, // empty -> ParseBool error -> falls back to detector
}
for _, tc := range tests {
t.Run("value="+tc.value, func(t *testing.T) {
withAgentDetector(t, true)
t.Setenv(EnvAIHelp, tc.value)
assert.Equal(t, tc.expected, AIHelpEnabled())
})
}
}
9 changes: 5 additions & 4 deletions plugins/components/conversionlayer.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ func convertSubcommands(subcommands []Namespace, nameSpaces ...string) ([]cli.Co
nameSpaceCommand := cli.Command{
Name: ns.Name,
Aliases: ns.Aliases,
Usage: ns.Description,
Usage: common.ResolveDescription(ns.Description, ns.AIDescription),
Hidden: ns.Hidden,
Category: ns.Category,
}
Expand Down Expand Up @@ -85,14 +85,15 @@ func convertCommand(cmd Command, namespaces ...string) (cli.Command, error) {
if err != nil {
return cli.Command{}, err
}
chosenDesc := common.ResolveDescription(cmd.Description, cmd.AIDescription)
cliCmd := cli.Command{
Name: cmd.Name,
Flags: convertedFlags,
Aliases: cmd.Aliases,
Category: cmd.Category,
Usage: cmd.Description,
Description: cmd.Description,
HelpName: common.CreateUsage(getCmdUsageString(cmd, namespaces...), cmd.Description, cmdUsages),
Usage: chosenDesc,
Description: chosenDesc,
HelpName: common.CreateUsage(getCmdUsageString(cmd, namespaces...), chosenDesc, cmdUsages),
UsageText: createArgumentsSummary(cmd),
ArgsUsage: createEnvVarsSummary(cmd),
BashComplete: common.CreateBashCompletionFunc(),
Expand Down
38 changes: 38 additions & 0 deletions plugins/components/conversionlayer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -411,3 +411,41 @@ func (d DummyFlagValue) String() string {
func (d DummyFlagValue) Set(value string) error {
return nil
}

func TestConvertCommandAppliesAIHelp(t *testing.T) {
// Pin AI-help to "on" via env so the test is deterministic regardless of
// the parent process's agent detection (see docs/common/aihelp.go).
t.Setenv("JFROG_CLI_AI_HELP", "true")

cmd := Command{
Name: "test-cmd",
Description: "human description",
AIDescription: "ai description",
}

t.Run("env force-on uses AI text", func(t *testing.T) {
t.Setenv("JFROG_CLI_AI_HELP", "true")
converted, err := convertCommand(cmd, "test-ns")
require.NoError(t, err)
assert.Equal(t, "ai description", converted.Usage)
assert.Equal(t, "ai description", converted.Description)
assert.Contains(t, converted.HelpName, "ai description")
})

t.Run("env force-off uses human text", func(t *testing.T) {
t.Setenv("JFROG_CLI_AI_HELP", "false")
converted, err := convertCommand(cmd, "test-ns")
require.NoError(t, err)
assert.Equal(t, "human description", converted.Usage)
assert.Equal(t, "human description", converted.Description)
assert.Contains(t, converted.HelpName, "human description")
})

t.Run("empty AIDescription falls back to human even when force-on", func(t *testing.T) {
t.Setenv("JFROG_CLI_AI_HELP", "true")
humanOnly := Command{Name: "test-cmd", Description: "human only"}
converted, err := convertCommand(humanOnly, "test-ns")
require.NoError(t, err)
assert.Equal(t, "human only", converted.Usage)
})
}
14 changes: 8 additions & 6 deletions plugins/components/structure.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,19 @@ func CreateEmbeddedApp(name string, commands []Command, namespaces ...Namespace)
}

type Namespace struct {
Name string
Aliases []string
Description string
Hidden bool
Category string
Commands []Command
Name string
Aliases []string
Description string
AIDescription string
Hidden bool
Category string
Commands []Command
}

type Command struct {
Name string
Description string
AIDescription string
Category string
Aliases []string
UsageOptions *UsageOptions
Expand Down
Loading