Skip to content

Commit 78b23f7

Browse files
aknyshclaude
andauthored
Add global --ai and --skill flags for AI-powered command analysis (#2171)
* Add global --ai flag for AI-powered command output analysis Implement the global `--ai` persistent flag that enables AI-powered analysis of any Atmos CLI command output. When enabled, stdout/stderr are captured via os.Pipe tee pattern and sent to the configured AI provider for analysis after command execution. Errors get explanations with fix instructions; successful output gets concise summaries. - Register --ai flag in pkg/flags with ATMOS_AI env var support - Add pkg/ai/analyze package (capture, validate, analyze, prompt building) - Wrap Execute() in cmd/root.go for pre/post command AI processing - Skip AI analysis for `atmos ai` commands to avoid double processing - Add comprehensive tests (flag, capture, validation, prompt, truncation) - Update global-flags.mdx, environment-variables.mdx, env.mdx docs - Update AI example (README.md, ATMOS.md) with --ai flag documentation - Add PRD for the global --ai flag feature - Regenerate CLI snapshots reflecting new --ai flag in help output Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Exclude 32-bit FreeBSD from release builds modernc.org/libc (transitive dependency via modernc.org/sqlite used by pkg/ai/session) has unresolved type mismatch bugs on 32-bit FreeBSD platforms where size_t is 32-bit but the code mixes it with uint64. This affects both freebsd/386 and freebsd/arm. The 64-bit targets (freebsd/amd64, freebsd/arm64) build fine. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Improve AI analyze package: use pkg/ui, increase test coverage to 95% - Replace utils.PrintfMessageToTUI/PrintfMarkdown with ui.Info/Warningf/Markdown - Extract messageSender interface for testable AI client injection - Add 10 new tests for AnalyzeOutput (mock client, error paths, edge cases) - Add --ai plan analysis example to AI example README - Coverage: 73.7% → 94.8% Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix --ai flag error path: propagate errors instead of calling os.Exit() Replace errUtils.CheckErrorPrintAndExit() calls in terraform command path with proper error returns so errors flow back through Cobra's RunE to Execute() where AI analysis can process them. Changes: - executeSingleComponent: return error instead of CheckErrorPrintAndExit - terraformRunWithOptions: return errors from checkTerraformFlags, ExecuteTerraformQuery, NeedHelp, and hook execution - handleInteractiveIdentitySelection: return error instead of void+exit - executeAffectedCommand: return error directly - Remove debug prints from cmd/root.go - Update PRD to document error propagation approach Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Restore alien emoji and colored markdown for --ai output - Use 👽 emoji (consistent with atmos ai ask/chat) instead of ℹ - Use utils.PrintfMarkdownToTUI for colored markdown rendering to stderr - Use utils.PrintfMessageToTUI for status messages to stderr - Add AI error analysis example to README Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add blog post, update PRD and examples for --ai flag, fix output ordering - Add blog post for AI-powered analysis with global --ai flag - Update PRD with error handling details and ~95% coverage - Update examples/ai/README.md with error analysis and not-configured examples - Fix error ordering in root.go: print error before AI analysis - Add trailing newline after AI response in analyze.go Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix roadmap progress percentages and add --ai flag milestone - auth: 88% → 80% (20/25 shipped) - workflows: 100% shipped → 86% in-progress (6/7, one milestone still in-progress) - extensibility: 89% → 86% (19/22) - vendoring: 95% → 91% (10/11) - migration: 85% → 83% (10/12) - quality: 88% → 80% (8/10) - ai-assistant: 90% → 94% (17/18, was understated) - Add global --ai flag as shipped milestone in ai-assistant initiative Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add global --skill flag for domain-specific AI analysis context The --skill <name> flag works with --ai to provide domain-specific expertise when analyzing command output. The skill's system prompt is prepended to the analysis prompt, giving the AI deep knowledge of the relevant Atmos subsystem (e.g., Terraform, stacks, validation). Changes: - Add ErrAISkillRequiresAIFlag sentinel error - Add Skill string field to global.Flags - Register --skill flag with ATMOS_SKILL env var in pkg/flags/ - Add parseSkillFlagInternal() for early flag parsing in cmd/root.go - Add loadAndValidateSkill() to load skill registry and validate name - Introduce AnalysisInput struct to fix argument-limit lint rule - Extract writeStream() helper to reduce cyclomatic complexity - Extract setupAIAnalysis() to reduce nestif complexity - Add tests for flag parsing, skill prompt injection, flag registration - Update PRD with --skill requirements and 10-step implementation plan Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Update docs for --skill flag: global flags, env vars, PRD, blog, example Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add tests for --skill flag and fix PRD pseudocode consistency - Add 6 new analyze tests: skill+error, skill+send error, separator, both streams, multiple providers, truncate edge case - Add 4 new parseSkillFlagInternal tests: similar flags, between flags, hyphenated names, multiple flags - Update PRD execution flow to match actual setupAIAnalysis() pattern Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Replace static AI analysis message with animated spinner Use pkg/ui/spinner for the AI analysis indicator. Shows animated spinner during the AI provider call, then displays success/error status when complete. Gracefully degrades in non-TTY environments. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add empty line before AI spinner for visibility Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Show skill name in AI spinner and success messages for user visibility Display the active skill name in both the spinner ("Analyzing with AI using skill 'atmos-terraform'...") and success message ("AI analysis complete (skill: atmos-terraform)") so users can confirm which skill was sent to the AI provider. Update PRD with skill prompt injection architecture, add tests, and update blog post and example docs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Document --ai and --skill global flags in AI command docs Add references to the global --ai and --skill flags in the AI usage page, skill management page, and main AI overview page with usage examples. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * updates * Migrate AI analysis output from utils to ui package with ReinitFormatter Add ui.ReinitFormatter() to re-detect terminal capabilities after output capture pipes are restored. This fixes the capture timing issue where InitFormatter() cached ColorNone during PersistentPreRun while pipes were active. AI analysis now uses ui.MarkdownMessage() and ui.Writeln() instead of the legacy utils.PrintfMarkdownToTUI/PrintfMessageToTUI. Update PRD to document the resolved issue, add tests, and regenerate snapshots. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix blog and example: show success message instead of spinner Replace static spinner message with final success message in terminal examples for consistency with actual CLI output after spinner completes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Address PR review: idempotent Stop(), t.Cleanup, tee test, debug log - Add idempotency guard to CaptureSession.Stop() to prevent panic on double call - Register t.Cleanup(cs.Stop) in all capture tests for safe stream restoration - Add TestStartCapture_TeesToOriginalStreams to verify tee contract - Add debug log when marketplace skill loader creation fails - Fix --skill example in ai.mdx to include component and stack arguments Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Remove environment-specific gh auth debug line from golden snapshots The gh auth token debug log only appears when gh CLI is installed but not authenticated (local dev). CI has no gh CLI, so the line is absent. Remove it from snapshots to match CI behavior. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add tests for AI analysis functions to increase patch coverage Tests cover buildCommandName, runAIAnalysis, setupAIAnalysis, and loadAndValidateSkill — the main uncovered functions from the --ai flag PR. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Refactor AI tests: table-driven format, behavioral assertions, fix comments Address CodeRabbit review feedback: - Fix saveAndRestoreArgs comment to match t.Cleanup behavior - Collapse individual tests into table-driven format with validAIConfig helper - Replace tautological "no panic" assertions with behavioral tests that verify stream restoration, capture buffer contents, and formatted error output - Add double-stop idempotency test verifying buffered data preservation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix Windows CI: close temp file before TempDir cleanup On Windows, open file handles prevent deletion. Close the temp stderr file in the cleanup function before t.TempDir() tries to remove it. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Convert --skill flag to StringSlice for multi-skill support Support multiple skills via comma-separated values (--skill a,b) and repeated flags (--skill a --skill b). Updates all pipeline functions, tests, golden snapshots, and documentation to reflect the new behavior. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Remove gh auth token debug line from golden snapshots The "gh auth token failed" debug message is environment-dependent and should not be captured in golden snapshots. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * update docs * Address PR review: simplify isAICommand, add panic safety, fix truncation budget - Simplify isAICommandInternal to skip index 0 explicitly instead of value comparison - Add defer captureSession.Stop() for panic safety during command execution - Surface capture errors to user via ui.Warning instead of silent log.Warn - Split output truncation budget evenly when both stdout and stderr are present - Remove perf.Track from trivial ValidateAIConfig, pass atmosConfig to AnalyzeOutput - Update PRD code snippets to reflect multi-skill slice types Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Address PR review: env var fallback, perf tracking, truncation fix - Add ATMOS_AI env var fallback in hasAIFlagInternal for CI/CD env-only usage - Add ATMOS_SKILL env var fallback in parseSkillFlagInternal with comma splitting - Extract splitCSV helper to reduce cognitive complexity - Restore perf.Track on ValidateAIConfig with proper atmosConfig parameter - Fix truncateOutput to include suffix within the limit (no overshoot) - Add SkillNames to AnalysisInput in PRD code snippet - Fix PRD doc to show proper error handling for StartCapture - Trim blog post, add ActionCard for AI example Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Address PR review: CLI precedence, nil guard, sentinel tests - Respect CLI precedence for --ai flag: --ai=false no longer falls through to ATMOS_AI env var, uses strconv.ParseBool for all boolean forms - Track flagSeen for --skill to prevent env var override of explicit empty - Add nil guard on AnalyzeOutput to prevent panic on nil input - Extract spinnerMessage/successMessage helpers to stay within function length limit - Refactor ValidateAIConfig tests to table-driven with errors.Is() sentinels - Sync PRD snippet with actual parseSkillFlagInternal implementation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Extract AI logic from cmd/ into domain packages with comprehensive tests Move AI flag parsing, setup, and analysis orchestration out of cmd/root.go into proper domain packages per review feedback: - cmd/ai/flags/: pre-Cobra flag parsing (HasAIFlag, ParseSkillFlag, SplitCSV) - cmd/ai/setup/: AI initialization orchestration (InitAI, IsAISubcommand) - pkg/ai/analyze/context.go: AI lifecycle management (Setup, RunAnalysis, Cleanup) - pkg/ai/skills/validate.go: skill validation and prompt building (LoadAndValidate, BuildPrompt) Fix intermittent capture_test.go failures by redirecting tee output to os.DevNull instead of go test's captured stdout/stderr. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Address CodeRabbit review: security, correctness, and CI fixes - Last-value-wins for repeated --ai flags (match Cobra semantics) - Bare --skill suppresses ATMOS_SKILL env var fallback - Short-circuit --help/-h before AI validation (prevent env var errors) - Sanitize BuildCommandName: stop at -- to avoid leaking secrets - Provider-aware error hints (model/env var per provider) - Remove dead ErrAISkillLoadFailed wrapper (LoadSkills never errors) - Redirect stderr in AnalyzeOutput tests to fix CI output leaking - Add nil input guard test for AnalyzeOutput Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix CI test failures: suppress logger output, polish test surface - Redirect logger to devNull in tests (charm logger caches stderr at init, so os.Stderr reassignment alone doesn't suppress ERRO lines) - Unexport buildCommandNameInternal (only used in same-package tests) - Add perf.Track to BuildCommandName per mandatory convention - Remove tautological TestBuildCommandName_ReturnsNonEmpty - Fix TestContext_RunAnalysis_NilContextReturnsFalse to call RunAnalysis Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix CI exit status 2: mock CaptureSession must be pre-stopped Mock CaptureSession with stopped:false has nil pipe fields. When Cleanup() calls Stop(), it nils out os.Stdout/os.Stderr, causing the coverage writer to crash with exit status 2 (only under -coverpkg=./...). Fix: use stopped:true for mock sessions so Stop() short-circuits. Also add redirectToDevNull to error-path tests that write to stderr. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Externalize AI system prompt to embedded file, clean example config - Move system prompt from inline const to //go:embed prompts/system.md - Remove unused terminal settings block from examples/ai/atmos.yaml Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 57c2e21 commit 78b23f7

File tree

76 files changed

+4258
-50
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

76 files changed

+4258
-50
lines changed

cmd/ai/flags/flags.go

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package flags
2+
3+
import (
4+
"os"
5+
"strconv"
6+
"strings"
7+
8+
"github.com/cloudposse/atmos/pkg/perf"
9+
)
10+
11+
// HasAIFlag checks if --ai flag is present in os.Args.
12+
// This is needed before Cobra parses flags because we set up output capture
13+
// in Execute() before internal.Execute() runs the command.
14+
func HasAIFlag() bool {
15+
defer perf.Track(nil, "flags.HasAIFlag")()
16+
17+
return HasAIFlagInternal(os.Args)
18+
}
19+
20+
// HasAIFlagInternal checks if --ai flag is present in the provided args.
21+
// Uses last-value-wins semantics to match Cobra's behavior for repeated flags.
22+
// Only falls back to ATMOS_AI environment variable when no CLI flag is present.
23+
func HasAIFlagInternal(args []string) bool {
24+
var explicit *bool
25+
for _, arg := range args {
26+
// Stop scanning after bare "--" (end-of-flags delimiter).
27+
if arg == "--" {
28+
break
29+
}
30+
// Bare --ai is equivalent to --ai=true.
31+
if arg == "--ai" {
32+
v := true
33+
explicit = &v
34+
continue
35+
}
36+
// Explicit --ai=<value>: parse the boolean to respect --ai=false.
37+
if strings.HasPrefix(arg, "--ai=") {
38+
val, err := strconv.ParseBool(strings.TrimPrefix(arg, "--ai="))
39+
if err != nil {
40+
continue
41+
}
42+
explicit = &val
43+
}
44+
}
45+
if explicit != nil {
46+
return *explicit
47+
}
48+
// Fall back to ATMOS_AI environment variable for CI/CD env-only usage.
49+
//nolint:forbidigo // Must use os.Getenv: AI flag is processed before Viper configuration loads.
50+
val, err := strconv.ParseBool(os.Getenv("ATMOS_AI"))
51+
return err == nil && val
52+
}
53+
54+
// ParseSkillFlag extracts all --skill flag values from os.Args.
55+
// This is needed before Cobra parses flags because we validate and load skills
56+
// in Execute() before internal.Execute() runs the command.
57+
func ParseSkillFlag() []string {
58+
defer perf.Track(nil, "flags.ParseSkillFlag")()
59+
60+
return ParseSkillFlagInternal(os.Args)
61+
}
62+
63+
// ParseSkillFlagInternal extracts all --skill flag values from the provided args.
64+
// Supports repeated flags (--skill a --skill b) and comma-separated values (--skill a,b).
65+
// Respects CLI precedence: if --skill is explicitly provided (even as empty), the env var is not consulted.
66+
// Only falls back to ATMOS_SKILL environment variable when no --skill flag is present.
67+
func ParseSkillFlagInternal(args []string) []string {
68+
var result []string
69+
flagSeen := false
70+
for i, arg := range args {
71+
// Stop scanning after bare "--" (end-of-flags delimiter).
72+
if arg == "--" {
73+
break
74+
}
75+
76+
var value string
77+
if arg == "--skill" {
78+
flagSeen = true
79+
if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") {
80+
value = args[i+1]
81+
}
82+
} else if strings.HasPrefix(arg, "--skill=") {
83+
value = strings.TrimPrefix(arg, "--skill=")
84+
flagSeen = true
85+
}
86+
87+
if value != "" {
88+
result = append(result, SplitCSV(value)...)
89+
}
90+
}
91+
92+
// Fall back to ATMOS_SKILL environment variable only when no --skill CLI flag was provided.
93+
if !flagSeen {
94+
//nolint:forbidigo // Must use os.Getenv: skill flag is processed before Viper configuration loads.
95+
result = SplitCSV(os.Getenv("ATMOS_SKILL"))
96+
}
97+
98+
return result
99+
}
100+
101+
// HasHelpFlag checks if --help or -h is present in os.Args (before the "--" delimiter).
102+
// Used to short-circuit AI setup when the user is requesting help.
103+
func HasHelpFlag() bool {
104+
return HasHelpFlagInternal(os.Args)
105+
}
106+
107+
// HasHelpFlagInternal checks if --help, -h, or the "help" subcommand is present in the provided args.
108+
func HasHelpFlagInternal(args []string) bool {
109+
for _, arg := range args {
110+
if arg == "--" {
111+
break
112+
}
113+
if arg == "--help" || arg == "-h" || arg == "help" {
114+
return true
115+
}
116+
}
117+
return false
118+
}
119+
120+
// SplitCSV splits a comma-separated string into trimmed, non-empty values.
121+
func SplitCSV(value string) []string {
122+
var result []string
123+
for _, v := range strings.Split(value, ",") {
124+
v = strings.TrimSpace(v)
125+
if v != "" {
126+
result = append(result, v)
127+
}
128+
}
129+
return result
130+
}

cmd/ai/flags/flags_test.go

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
package flags
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
// TestHasAIFlagInternal tests the HasAIFlagInternal function that parses --ai from os.Args.
10+
func TestHasAIFlagInternal(t *testing.T) {
11+
tests := []struct {
12+
name string
13+
args []string
14+
expected bool
15+
}{
16+
{name: "no --ai flag", args: []string{"atmos", "terraform", "plan"}, expected: false},
17+
{name: "--ai flag present", args: []string{"atmos", "--ai", "terraform", "plan"}, expected: true},
18+
{name: "--ai flag at end", args: []string{"atmos", "terraform", "plan", "--ai"}, expected: true},
19+
{name: "--ai=true", args: []string{"atmos", "--ai=true", "terraform", "plan"}, expected: true},
20+
{name: "--ai=false is not enabled", args: []string{"atmos", "--ai=false", "terraform", "plan"}, expected: false},
21+
{name: "--ai after -- delimiter is ignored", args: []string{"atmos", "terraform", "plan", "--", "--ai"}, expected: false},
22+
{name: "similar flag --aim is not matched", args: []string{"atmos", "--aim", "terraform", "plan"}, expected: false},
23+
{name: "empty args", args: []string{}, expected: false},
24+
{name: "repeated --ai last wins (true then false)", args: []string{"atmos", "--ai", "--ai=false", "terraform", "plan"}, expected: false},
25+
{name: "repeated --ai last wins (false then true)", args: []string{"atmos", "--ai=false", "--ai", "terraform", "plan"}, expected: true},
26+
{name: "repeated --ai=value last wins", args: []string{"atmos", "--ai=true", "--ai=false", "terraform"}, expected: false},
27+
{name: "--ai=invalid skipped, bare --ai wins", args: []string{"atmos", "--ai=maybe", "--ai", "terraform"}, expected: true},
28+
}
29+
30+
for _, tt := range tests {
31+
t.Run(tt.name, func(t *testing.T) {
32+
result := HasAIFlagInternal(tt.args)
33+
assert.Equal(t, tt.expected, result)
34+
})
35+
}
36+
}
37+
38+
// TestHasAIFlagInternal_EnvVarFallback tests env var fallback behavior for --ai detection.
39+
func TestHasAIFlagInternal_EnvVarFallback(t *testing.T) {
40+
tests := []struct {
41+
name string
42+
envValue string
43+
expected bool
44+
}{
45+
{name: "ATMOS_AI=true enables AI", envValue: "true", expected: true},
46+
{name: "ATMOS_AI=1 enables AI", envValue: "1", expected: true},
47+
{name: "ATMOS_AI=false disables AI", envValue: "false", expected: false},
48+
{name: "ATMOS_AI=0 disables AI", envValue: "0", expected: false},
49+
{name: "ATMOS_AI=invalid disables AI", envValue: "invalid", expected: false},
50+
{name: "ATMOS_AI empty disables AI", envValue: "", expected: false},
51+
}
52+
53+
for _, tt := range tests {
54+
t.Run(tt.name, func(t *testing.T) {
55+
t.Setenv("ATMOS_AI", tt.envValue)
56+
result := HasAIFlagInternal([]string{"atmos", "terraform", "plan"})
57+
assert.Equal(t, tt.expected, result)
58+
})
59+
}
60+
}
61+
62+
// TestHasAIFlagInternal_CLIOverridesEnv verifies CLI flag takes precedence over env var.
63+
func TestHasAIFlagInternal_CLIOverridesEnv(t *testing.T) {
64+
t.Setenv("ATMOS_AI", "true")
65+
result := HasAIFlagInternal([]string{"atmos", "--ai=false", "terraform", "plan"})
66+
assert.False(t, result, "--ai=false should override ATMOS_AI=true")
67+
}
68+
69+
// TestHasAIFlagInternal_InvalidBoolValue tests --ai with invalid boolean value.
70+
func TestHasAIFlagInternal_InvalidBoolValue(t *testing.T) {
71+
result := HasAIFlagInternal([]string{"atmos", "--ai=maybe", "terraform", "plan"})
72+
assert.False(t, result, "--ai=maybe should return false (invalid boolean)")
73+
}
74+
75+
// TestParseSkillFlagInternal tests the ParseSkillFlagInternal function.
76+
func TestParseSkillFlagInternal(t *testing.T) {
77+
tests := []struct {
78+
name string
79+
args []string
80+
expected []string
81+
}{
82+
{name: "no --skill flag", args: []string{"atmos", "terraform", "plan"}, expected: nil},
83+
{name: "--skill with separate value", args: []string{"atmos", "--ai", "--skill", "atmos-terraform", "terraform", "plan"}, expected: []string{"atmos-terraform"}},
84+
{name: "--skill=value format", args: []string{"atmos", "--ai", "--skill=atmos-stacks", "describe", "stacks"}, expected: []string{"atmos-stacks"}},
85+
{name: "--skill at end with value", args: []string{"atmos", "terraform", "plan", "--ai", "--skill", "atmos-terraform"}, expected: []string{"atmos-terraform"}},
86+
{name: "--skill after -- delimiter is ignored", args: []string{"atmos", "terraform", "plan", "--", "--skill", "atmos-terraform"}, expected: nil},
87+
{name: "--skill without value (next arg is flag)", args: []string{"atmos", "--skill", "--ai", "terraform", "plan"}, expected: nil},
88+
{name: "--skill at end without value", args: []string{"atmos", "terraform", "plan", "--skill"}, expected: nil},
89+
{name: "empty args", args: []string{}, expected: nil},
90+
{name: "--skill=empty value", args: []string{"atmos", "--skill=", "terraform", "plan"}, expected: nil},
91+
{name: "--skilled is not matched", args: []string{"atmos", "--skilled", "atmos-terraform", "terraform", "plan"}, expected: nil},
92+
{name: "--skilled=value not matched", args: []string{"atmos", "--skilled=atmos-terraform", "terraform", "plan"}, expected: nil},
93+
{name: "--skill between other flags", args: []string{"atmos", "--logs-level=Debug", "--skill", "atmos-validation", "--ai", "validate", "stacks"}, expected: []string{"atmos-validation"}},
94+
{name: "hyphens in skill name", args: []string{"atmos", "--skill=my-custom-skill-v2", "terraform", "plan"}, expected: []string{"my-custom-skill-v2"}},
95+
{name: "multiple --skill flags", args: []string{"atmos", "--skill", "first", "--skill", "second", "terraform", "plan"}, expected: []string{"first", "second"}},
96+
{name: "comma-separated skills", args: []string{"atmos", "--ai", "--skill", "atmos-terraform,atmos-stacks", "terraform", "plan"}, expected: []string{"atmos-terraform", "atmos-stacks"}},
97+
{name: "comma-separated with =", args: []string{"atmos", "--ai", "--skill=atmos-terraform,atmos-stacks", "terraform", "plan"}, expected: []string{"atmos-terraform", "atmos-stacks"}},
98+
{name: "mixed repeated and comma", args: []string{"atmos", "--skill", "a,b", "--skill", "c", "terraform", "plan"}, expected: []string{"a", "b", "c"}},
99+
}
100+
101+
for _, tt := range tests {
102+
t.Run(tt.name, func(t *testing.T) {
103+
result := ParseSkillFlagInternal(tt.args)
104+
assert.Equal(t, tt.expected, result)
105+
})
106+
}
107+
}
108+
109+
// TestParseSkillFlagInternal_EnvVarFallback tests env var fallback for --skill.
110+
func TestParseSkillFlagInternal_EnvVarFallback(t *testing.T) {
111+
tests := []struct {
112+
name string
113+
envValue string
114+
expected []string
115+
}{
116+
{name: "single skill from env", envValue: "atmos-terraform", expected: []string{"atmos-terraform"}},
117+
{name: "comma-separated from env", envValue: "a,b,c", expected: []string{"a", "b", "c"}},
118+
{name: "empty env", envValue: "", expected: nil},
119+
}
120+
121+
for _, tt := range tests {
122+
t.Run(tt.name, func(t *testing.T) {
123+
t.Setenv("ATMOS_SKILL", tt.envValue)
124+
result := ParseSkillFlagInternal([]string{"atmos", "terraform", "plan"})
125+
assert.Equal(t, tt.expected, result)
126+
})
127+
}
128+
}
129+
130+
// TestParseSkillFlagInternal_CLIOverridesEnv verifies CLI flag prevents env var fallback.
131+
func TestParseSkillFlagInternal_CLIOverridesEnv(t *testing.T) {
132+
t.Setenv("ATMOS_SKILL", "from-env")
133+
result := ParseSkillFlagInternal([]string{"atmos", "--skill", "from-cli", "terraform", "plan"})
134+
assert.Equal(t, []string{"from-cli"}, result, "CLI --skill should override ATMOS_SKILL env var")
135+
}
136+
137+
// TestParseSkillFlagInternal_BareSkillSuppressesEnv verifies bare --skill without value suppresses env fallback.
138+
func TestParseSkillFlagInternal_BareSkillSuppressesEnv(t *testing.T) {
139+
t.Setenv("ATMOS_SKILL", "from-env")
140+
result := ParseSkillFlagInternal([]string{"atmos", "--skill", "--ai", "terraform", "plan"})
141+
assert.Nil(t, result, "bare --skill without value should suppress ATMOS_SKILL env var fallback")
142+
}
143+
144+
// TestHasHelpFlagInternal tests the HasHelpFlagInternal function.
145+
func TestHasHelpFlagInternal(t *testing.T) {
146+
tests := []struct {
147+
name string
148+
args []string
149+
expected bool
150+
}{
151+
{name: "no help flag", args: []string{"atmos", "terraform", "plan"}, expected: false},
152+
{name: "--help flag", args: []string{"atmos", "terraform", "plan", "--help"}, expected: true},
153+
{name: "-h flag", args: []string{"atmos", "terraform", "-h"}, expected: true},
154+
{name: "help subcommand", args: []string{"atmos", "help", "terraform"}, expected: true},
155+
{name: "--help after -- is ignored", args: []string{"atmos", "terraform", "--", "--help"}, expected: false},
156+
{name: "-h after -- is ignored", args: []string{"atmos", "terraform", "--", "-h"}, expected: false},
157+
{name: "empty args", args: []string{}, expected: false},
158+
{name: "--help with --ai", args: []string{"atmos", "--ai", "terraform", "plan", "--help"}, expected: true},
159+
{name: "--help with --skill", args: []string{"atmos", "--skill", "atmos-terraform", "--help"}, expected: true},
160+
}
161+
162+
for _, tt := range tests {
163+
t.Run(tt.name, func(t *testing.T) {
164+
result := HasHelpFlagInternal(tt.args)
165+
assert.Equal(t, tt.expected, result)
166+
})
167+
}
168+
}
169+
170+
// TestSplitCSV tests the SplitCSV helper function.
171+
func TestSplitCSV(t *testing.T) {
172+
tests := []struct {
173+
name string
174+
input string
175+
expected []string
176+
}{
177+
{name: "empty string", input: "", expected: nil},
178+
{name: "single value", input: "a", expected: []string{"a"}},
179+
{name: "two values", input: "a,b", expected: []string{"a", "b"}},
180+
{name: "values with whitespace", input: " a , b , c ", expected: []string{"a", "b", "c"}},
181+
{name: "trailing comma", input: "a,b,", expected: []string{"a", "b"}},
182+
{name: "leading comma", input: ",a,b", expected: []string{"a", "b"}},
183+
{name: "multiple commas", input: "a,,b", expected: []string{"a", "b"}},
184+
{name: "only commas", input: ",,", expected: nil},
185+
{name: "whitespace only values", input: " , , ", expected: nil},
186+
}
187+
188+
for _, tt := range tests {
189+
t.Run(tt.name, func(t *testing.T) {
190+
result := SplitCSV(tt.input)
191+
assert.Equal(t, tt.expected, result)
192+
})
193+
}
194+
}

cmd/ai/setup/setup.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package setup
2+
3+
import (
4+
"strings"
5+
6+
"github.com/spf13/cobra"
7+
8+
aiflags "github.com/cloudposse/atmos/cmd/ai/flags"
9+
errUtils "github.com/cloudposse/atmos/errors"
10+
"github.com/cloudposse/atmos/pkg/ai/analyze"
11+
"github.com/cloudposse/atmos/pkg/perf"
12+
"github.com/cloudposse/atmos/pkg/schema"
13+
)
14+
15+
// InitAI parses AI flags from os.Args, validates configuration, and starts output capture.
16+
// Returns a ready-to-use Context or an error if validation fails.
17+
// The caller MUST call ctx.Cleanup() (via defer) to restore stdout/stderr.
18+
func InitAI(atmosConfig *schema.AtmosConfiguration) (*analyze.Context, error) {
19+
defer perf.Track(nil, "setup.InitAI")()
20+
21+
// Short-circuit for help requests: --help/-h/help should never fail due to AI env vars.
22+
if aiflags.HasHelpFlag() {
23+
return analyze.NewDisabledContext(), nil
24+
}
25+
26+
aiEnabled := aiflags.HasAIFlag()
27+
skillNames := aiflags.ParseSkillFlag()
28+
29+
// Validate --skill requires --ai.
30+
if len(skillNames) > 0 && !aiEnabled {
31+
skillList := strings.Join(skillNames, ",")
32+
return nil, errUtils.Build(errUtils.ErrAISkillRequiresAIFlag).
33+
WithExplanation("The --skill flag provides domain-specific context for AI analysis, but AI analysis is not enabled. Use --skill together with --ai.").
34+
WithHintf("Add --ai to enable AI analysis:\n atmos <command> --ai --skill %s", skillList).
35+
WithHintf("Or use environment variables:\n ATMOS_AI=true ATMOS_SKILL=%s atmos <command>", skillList).
36+
Err()
37+
}
38+
39+
if !aiEnabled {
40+
return analyze.NewDisabledContext(), nil
41+
}
42+
43+
return analyze.Setup(atmosConfig, skillNames, analyze.BuildCommandName())
44+
}
45+
46+
// IsAISubcommand checks whether cmd is the "atmos ai" subcommand (or one of its children).
47+
// Used after Cobra execution to skip AI analysis for AI commands (avoid double processing).
48+
func IsAISubcommand(cmd *cobra.Command) bool {
49+
for c := cmd; c != nil; c = c.Parent() {
50+
if c.Name() == "ai" && c.Parent() != nil && c.Parent().Parent() == nil {
51+
return true
52+
}
53+
}
54+
return false
55+
}

0 commit comments

Comments
 (0)