diff --git a/docs/common/aihelp.go b/docs/common/aihelp.go new file mode 100644 index 000000000..ccb4923ea --- /dev/null +++ b/docs/common/aihelp.go @@ -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 +} diff --git a/docs/common/aihelp_test.go b/docs/common/aihelp_test.go new file mode 100644 index 000000000..d33dee72b --- /dev/null +++ b/docs/common/aihelp_test.go @@ -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()) + }) + } +} diff --git a/plugins/components/conversionlayer.go b/plugins/components/conversionlayer.go index 93493bcdb..3157467fb 100644 --- a/plugins/components/conversionlayer.go +++ b/plugins/components/conversionlayer.go @@ -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, } @@ -88,14 +88,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(), diff --git a/plugins/components/conversionlayer_test.go b/plugins/components/conversionlayer_test.go index 7af898792..536c2d525 100644 --- a/plugins/components/conversionlayer_test.go +++ b/plugins/components/conversionlayer_test.go @@ -446,3 +446,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) + }) +} diff --git a/plugins/components/structure.go b/plugins/components/structure.go index 3e3d798a0..0ab23a4c4 100644 --- a/plugins/components/structure.go +++ b/plugins/components/structure.go @@ -31,25 +31,27 @@ 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 - Category string - Aliases []string - UsageOptions *UsageOptions - Arguments []Argument - Flags []Flag - EnvVars []EnvVar - Subcommands []Command - Action ActionFunc + Name string + Description string + AIDescription string + Category string + Aliases []string + UsageOptions *UsageOptions + Arguments []Argument + Flags []Flag + EnvVars []EnvVar + Subcommands []Command + Action ActionFunc // Must not be set when Subcommands is non-empty; convertCommand rejects that combination. SkipFlagParsing bool Hidden bool