From 6f07899b9284bd6187be56b9bbf49339752bda28 Mon Sep 17 00:00:00 2001 From: ehl-jf Date: Thu, 21 May 2026 13:28:19 +0200 Subject: [PATCH] JGC-473 - Add AIDescription field and AI-mode help resolver Add AIDescription to components.Command and components.Namespace, plus a new docs/common.ResolveDescription helper that prefers the AI text when JFROG_CLI_AI_HELP is truthy or the process is detected as an AI agent. Wire it into the components->urfave/cli conversion layer so plugins that populate AIDescription get the agent-flavored help with no other changes. Setting AIDescription is purely additive: empty falls back to Description, so callers that don't opt in see no behavior change. Detection hooks through an AIAgentDetector function variable rather than calling commands.DetectExecutionContext() directly. The underlying ExecutionContext is sync.Once-memoized and cannot be reset, which would make the resolver untestable; the hook lets unit tests inject a deterministic answer. --- docs/common/aihelp.go | 44 ++++++++ docs/common/aihelp_test.go | 113 +++++++++++++++++++++ plugins/components/conversionlayer.go | 9 +- plugins/components/conversionlayer_test.go | 38 +++++++ plugins/components/structure.go | 34 ++++--- 5 files changed, 218 insertions(+), 20 deletions(-) create mode 100644 docs/common/aihelp.go create mode 100644 docs/common/aihelp_test.go 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