diff --git a/NOTICE b/NOTICE index b0813edb20..6339277850 100644 --- a/NOTICE +++ b/NOTICE @@ -119,7 +119,7 @@ APACHE 2.0 LICENSED DEPENDENCIES - github.com/aws/aws-sdk-go-v2/feature/s3/manager License: Apache-2.0 - URL: https://github.com/aws/aws-sdk-go-v2/blob/feature/s3/manager/v1.22.10/feature/s3/manager/LICENSE.txt + URL: https://github.com/aws/aws-sdk-go-v2/blob/feature/s3/manager/v1.22.11/feature/s3/manager/LICENSE.txt - github.com/aws/aws-sdk-go-v2/internal/configsources License: Apache-2.0 @@ -167,11 +167,11 @@ APACHE 2.0 LICENSED DEPENDENCIES - github.com/aws/aws-sdk-go-v2/service/organizations License: Apache-2.0 - URL: https://github.com/aws/aws-sdk-go-v2/blob/service/organizations/v1.50.6/service/organizations/LICENSE.txt + URL: https://github.com/aws/aws-sdk-go-v2/blob/service/organizations/v1.51.0/service/organizations/LICENSE.txt - github.com/aws/aws-sdk-go-v2/service/s3 License: Apache-2.0 - URL: https://github.com/aws/aws-sdk-go-v2/blob/service/s3/v1.97.3/service/s3/LICENSE.txt + URL: https://github.com/aws/aws-sdk-go-v2/blob/service/s3/v1.98.0/service/s3/LICENSE.txt - github.com/aws/aws-sdk-go-v2/service/secretsmanager License: Apache-2.0 @@ -279,7 +279,7 @@ APACHE 2.0 LICENSED DEPENDENCIES - github.com/go-git/go-git/v5 License: Apache-2.0 - URL: https://github.com/go-git/go-git/blob/v5.17.0/LICENSE + URL: https://github.com/go-git/go-git/blob/v5.17.2/LICENSE - github.com/go-ini/ini License: Apache-2.0 @@ -379,15 +379,15 @@ APACHE 2.0 LICENSED DEPENDENCIES - github.com/open-policy-agent/opa License: Apache-2.0 - URL: https://github.com/open-policy-agent/opa/blob/v1.15.0/LICENSE + URL: https://github.com/open-policy-agent/opa/blob/v1.15.1/LICENSE - github.com/open-policy-agent/opa/internal/gojsonschema License: Apache-2.0 - URL: https://github.com/open-policy-agent/opa/blob/v1.15.0/internal/gojsonschema/LICENSE-APACHE-2.0.txt + URL: https://github.com/open-policy-agent/opa/blob/v1.15.1/internal/gojsonschema/LICENSE-APACHE-2.0.txt - github.com/open-policy-agent/opa/internal/semver License: Apache-2.0 - URL: https://github.com/open-policy-agent/opa/blob/v1.15.0/internal/semver/LICENSE + URL: https://github.com/open-policy-agent/opa/blob/v1.15.1/internal/semver/LICENSE - github.com/openai/openai-go License: Apache-2.0 @@ -403,7 +403,7 @@ APACHE 2.0 LICENSED DEPENDENCIES - github.com/petermattis/goid License: Apache-2.0 - URL: https://github.com/petermattis/goid/blob/17d1149c6ac6/LICENSE + URL: https://github.com/petermattis/goid/blob/df67b199bc81/LICENSE - github.com/pjbgf/sha1cd License: Apache-2.0 @@ -543,23 +543,23 @@ APACHE 2.0 LICENSED DEPENDENCIES - google.golang.org/genai License: Apache-2.0 - URL: https://github.com/googleapis/go-genai/blob/v1.51.0/LICENSE + URL: https://github.com/googleapis/go-genai/blob/v1.52.1/LICENSE - google.golang.org/genproto/googleapis License: Apache-2.0 - URL: https://github.com/googleapis/go-genproto/blob/d00831a3d3e7/LICENSE + URL: https://github.com/googleapis/go-genproto/blob/d5a96adf58d8/LICENSE - google.golang.org/genproto/googleapis/api License: Apache-2.0 - URL: https://github.com/googleapis/go-genproto/blob/d00831a3d3e7/googleapis/api/LICENSE + URL: https://github.com/googleapis/go-genproto/blob/d5a96adf58d8/googleapis/api/LICENSE - google.golang.org/genproto/googleapis/rpc License: Apache-2.0 - URL: https://github.com/googleapis/go-genproto/blob/d00831a3d3e7/googleapis/rpc/LICENSE + URL: https://github.com/googleapis/go-genproto/blob/d5a96adf58d8/googleapis/rpc/LICENSE - google.golang.org/grpc License: Apache-2.0 - URL: https://github.com/grpc/grpc-go/blob/v1.79.3/LICENSE + URL: https://github.com/grpc/grpc-go/blob/v1.80.0/LICENSE - gopkg.in/ini.v1 License: Apache-2.0 @@ -583,7 +583,7 @@ APACHE 2.0 LICENSED DEPENDENCIES - k8s.io/kube-openapi/pkg/util License: Apache-2.0 - URL: https://github.com/kubernetes/kube-openapi/blob/5883c5ee87b9/LICENSE + URL: https://github.com/kubernetes/kube-openapi/blob/16be699c7b31/LICENSE - k8s.io/utils License: Apache-2.0 @@ -626,6 +626,10 @@ BSD LICENSED DEPENDENCIES License: BSD-3-Clause URL: https://github.com/PuerkitoBio/goquery/blob/v1.12.0/LICENSE + - github.com/andybalholm/brotli/flate + License: BSD-3-Clause + URL: https://github.com/andybalholm/brotli/blob/v1.2.1/flate/LICENSE + - github.com/andybalholm/cascadia License: BSD-2-Clause URL: https://github.com/andybalholm/cascadia/blob/v1.3.3/LICENSE @@ -760,7 +764,7 @@ BSD LICENSED DEPENDENCIES - github.com/open-policy-agent/opa/internal/edittree/bitvector License: BSD-3-Clause - URL: https://github.com/open-policy-agent/opa/blob/v1.15.0/internal/edittree/bitvector/license.txt + URL: https://github.com/open-policy-agent/opa/blob/v1.15.1/internal/edittree/bitvector/license.txt - github.com/pierrec/lz4/v4 License: BSD-3-Clause @@ -868,11 +872,11 @@ BSD LICENSED DEPENDENCIES - google.golang.org/api License: BSD-3-Clause - URL: https://github.com/googleapis/google-api-go-client/blob/v0.273.0/LICENSE + URL: https://github.com/googleapis/google-api-go-client/blob/v0.273.1/LICENSE - google.golang.org/api/internal/third_party/uritemplates License: BSD-3-Clause - URL: https://github.com/googleapis/google-api-go-client/blob/v0.273.0/internal/third_party/uritemplates/LICENSE + URL: https://github.com/googleapis/google-api-go-client/blob/v0.273.1/internal/third_party/uritemplates/LICENSE - google.golang.org/protobuf License: BSD-3-Clause @@ -912,7 +916,7 @@ BSD LICENSED DEPENDENCIES - modernc.org/sqlite License: BSD-3-Clause - URL: https://gitlab.com/cznic/sqlite/blob/v1.47.0/LICENSE + URL: https://gitlab.com/cznic/sqlite/blob/v1.48.0/LICENSE - mvdan.cc/sh/v3 License: BSD-3-Clause @@ -993,7 +997,7 @@ MOZILLA PUBLIC LICENSE (MPL) 2.0 DEPENDENCIES - github.com/hashicorp/go-version License: MPL-2.0 - URL: https://github.com/hashicorp/go-version/blob/v1.8.0/LICENSE + URL: https://github.com/hashicorp/go-version/blob/v1.9.0/LICENSE - github.com/hashicorp/golang-lru/simplelru License: MPL-2.0 @@ -1146,7 +1150,7 @@ MIT LICENSED DEPENDENCIES - github.com/andybalholm/brotli License: MIT - URL: https://github.com/andybalholm/brotli/blob/v1.2.0/LICENSE + URL: https://github.com/andybalholm/brotli/blob/v1.2.1/LICENSE - github.com/anthropics/anthropic-sdk-go License: MIT @@ -1246,7 +1250,7 @@ MIT LICENSED DEPENDENCIES - github.com/charmbracelet/x/exp/slice License: MIT - URL: https://github.com/charmbracelet/x/blob/df7b1bcffcca/exp/slice/LICENSE + URL: https://github.com/charmbracelet/x/blob/2dce04b6f8a4/exp/slice/LICENSE - github.com/charmbracelet/x/exp/strings License: MIT @@ -1342,7 +1346,7 @@ MIT LICENSED DEPENDENCIES - github.com/fxamacker/cbor/v2 License: MIT - URL: https://github.com/fxamacker/cbor/blob/v2.9.0/LICENSE + URL: https://github.com/fxamacker/cbor/blob/v2.9.1/LICENSE - github.com/gabriel-vasile/mimetype License: MIT @@ -1526,7 +1530,7 @@ MIT LICENSED DEPENDENCIES - github.com/lestrrat-go/httprc/v3 License: MIT - URL: https://github.com/lestrrat-go/httprc/blob/v3.0.4/LICENSE + URL: https://github.com/lestrrat-go/httprc/blob/v3.0.5/LICENSE - github.com/lestrrat-go/jwx/v3 License: MIT @@ -1542,7 +1546,7 @@ MIT LICENSED DEPENDENCIES - github.com/lucasb-eyer/go-colorful License: MIT - URL: https://github.com/lucasb-eyer/go-colorful/blob/v1.3.0/LICENSE + URL: https://github.com/lucasb-eyer/go-colorful/blob/v1.4.0/LICENSE - github.com/marshallbrekka/go-u2fhost License: MIT @@ -1634,7 +1638,7 @@ MIT LICENSED DEPENDENCIES - github.com/rs/zerolog License: MIT - URL: https://github.com/rs/zerolog/blob/v1.34.0/LICENSE + URL: https://github.com/rs/zerolog/blob/v1.35.0/LICENSE - github.com/ryanuber/go-glob License: MIT diff --git a/cmd/ai/init.go b/cmd/ai/init.go index 4a27067bb0..0504aa362b 100644 --- a/cmd/ai/init.go +++ b/cmd/ai/init.go @@ -48,9 +48,14 @@ func initializeAIToolsAndExecutor(atmosConfig *schema.AtmosConfiguration, mcpSer } // Register external MCP server tools (filtered by routing). - mcpMgr := registerMCPServerTools(registry, atmosConfig, mcpServerNames, question) + // Skip for CLI providers — they handle MCP via provider-specific pass-through. + var mcpMgr *mcpclient.Manager + if !isCLIProvider(atmosConfig.AI.DefaultProvider) { + mcpMgr = registerMCPServerTools(registry, atmosConfig, mcpServerNames, question) + } ui.Info(fmt.Sprintf("AI tools initialized: %d total", registry.Count())) + ui.Info(fmt.Sprintf("AI provider: %s", atmosConfig.AI.DefaultProvider)) // Initialize permission cache for persistent decisions. permCache, err := permission.NewPermissionCache(atmosConfig.BasePath) @@ -292,6 +297,19 @@ func resolveAuthProvider(atmosConfig *schema.AtmosConfiguration) mcpclient.AuthE return mgr } +// cliProviders lists providers that invoke a local CLI binary as a subprocess. +// These providers handle MCP via provider-specific pass-through, not via the Atmos tool registry. +var cliProviders = map[string]bool{ + "claude-code": true, + "codex-cli": true, + "gemini-cli": true, +} + +// isCLIProvider returns true if the provider invokes a local CLI binary. +func isCLIProvider(providerName string) bool { + return cliProviders[providerName] +} + // serversNeedAuth returns true if any configured MCP server has identity set. func serversNeedAuth(servers map[string]schema.MCPServerConfig) bool { for _, s := range servers { diff --git a/cmd/ai/init_test.go b/cmd/ai/init_test.go index 2730939028..7bf050bdbc 100644 --- a/cmd/ai/init_test.go +++ b/cmd/ai/init_test.go @@ -527,6 +527,27 @@ func TestSortedServerNames(t *testing.T) { }) } +func TestIsCLIProvider(t *testing.T) { + tests := []struct { + name string + provider string + expected bool + }{ + {"claude-code is CLI", "claude-code", true}, + {"codex-cli is CLI", "codex-cli", true}, + {"gemini-cli is CLI", "gemini-cli", true}, + {"anthropic is not CLI", "anthropic", false}, + {"openai is not CLI", "openai", false}, + {"ollama is not CLI", "ollama", false}, + {"empty is not CLI", "", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, isCLIProvider(tt.provider)) + }) + } +} + func TestSelectManualServers(t *testing.T) { servers := map[string]schema.MCPServerConfig{ "aws": {Command: "aws-mcp"}, diff --git a/cmd/mcp/client/export.go b/cmd/mcp/client/export.go index 07381fa2d0..3647c1ec11 100644 --- a/cmd/mcp/client/export.go +++ b/cmd/mcp/client/export.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "os" + "strings" "github.com/spf13/cobra" @@ -87,7 +88,10 @@ func executeMCPExport(cmd *cobra.Command, _ []string) error { // buildMCPJSONEntry creates a .mcp.json entry for a server. // Servers with identity are wrapped with 'atmos auth exec' for credential injection. +// Env keys are uppercased because Viper lowercases all YAML map keys. func buildMCPJSONEntry(_ string, serverCfg *schema.MCPServerConfig) mcpJSONServer { + env := uppercaseEnvKeys(serverCfg.Env) + if serverCfg.Identity != "" { // Wrap with atmos auth exec for credential injection. args := []string{"auth", "exec", "-i", serverCfg.Identity, "--", serverCfg.Command} @@ -95,7 +99,7 @@ func buildMCPJSONEntry(_ string, serverCfg *schema.MCPServerConfig) mcpJSONServe return mcpJSONServer{ Command: "atmos", Args: args, - Env: serverCfg.Env, + Env: env, } } @@ -103,6 +107,19 @@ func buildMCPJSONEntry(_ string, serverCfg *schema.MCPServerConfig) mcpJSONServe return mcpJSONServer{ Command: serverCfg.Command, Args: serverCfg.Args, - Env: serverCfg.Env, + Env: env, + } +} + +// uppercaseEnvKeys returns a copy of the env map with all keys uppercased. +// Viper lowercases all YAML map keys, but env vars are conventionally UPPERCASE. +func uppercaseEnvKeys(env map[string]string) map[string]string { + if env == nil { + return nil + } + result := make(map[string]string, len(env)) + for k, v := range env { + result[strings.ToUpper(k)] = v } + return result } diff --git a/docs/prd/atmos-ai-global-flag.md b/docs/prd/atmos-ai-global-flag.md index 9ce3333da8..0529396a15 100644 --- a/docs/prd/atmos-ai-global-flag.md +++ b/docs/prd/atmos-ai-global-flag.md @@ -1,8 +1,8 @@ # Atmos Global `--ai` Flag - Product Requirements Document -**Status:** In Progress (v3.0 — `--skill` flag) -**Version:** 3.0 -**Last Updated:** 2026-03-11 +**Status:** Shipped +**Version:** 3.1 +**Last Updated:** 2026-03-30 --- diff --git a/docs/prd/atmos-ai-local-providers.md b/docs/prd/atmos-ai-local-providers.md new file mode 100644 index 0000000000..0a541511bf --- /dev/null +++ b/docs/prd/atmos-ai-local-providers.md @@ -0,0 +1,1193 @@ +# Atmos AI Local Providers — Use Claude Code, Gemini CLI, and OpenAI Codex Instead of API Tokens + +**Status:** Phase 1-3 Shipped (all 3 providers), Phase 4 Planned +**Version:** 1.6 +**Last Updated:** 2026-04-01 + +--- + +## Executive Summary + +Atmos AI currently requires users to purchase API tokens from providers (Anthropic, OpenAI, +Google, etc.) to use AI features like `atmos ai chat` or `--ai` flag analysis. Many users +already have Claude Code or Gemini CLI installed with active subscriptions (Claude Max at +$100-200/mo, or Gemini's free tier with Google account). + +This PRD proposes adding **local CLI providers** that invoke the user's installed `claude` +or `gemini` binary as a subprocess, reusing their existing subscription instead of requiring +separate API tokens. + +**Key Finding:** Claude Code (`claude -p`), Gemini CLI (`gemini -p`) and OpenAI Codex support +non-interactive mode with structured JSON output, making subprocess integration +straightforward. No new protocols or SDKs needed — just `exec.Command` + JSON parsing. + +### Why This Matters + +1. **No API tokens to buy** — Users with Claude Max or Google accounts use their existing + subscription. Zero additional cost. +2. **Familiar auth** — Users already authenticated with `claude` or `gemini` on their + system. No API key configuration in `atmos.yaml`. +3. **Latest models** — CLI tools auto-update. Users always get the latest models without + Atmos needing to update provider code. +4. **Free tier** — Gemini CLI offers 1,000 requests/day free with just a Google account. +5. **Simplicity** — New users can `brew install --cask claude-code` + `atmos ai chat` with zero + configuration. The current flow requires: create API account → generate key → + configure `atmos.yaml` → set env var. + +--- + +## Feasibility Analysis + +### Claude Code CLI (`claude -p`) + +**Feasibility: YES — HIGH** + +Claude Code supports a non-interactive print mode that accepts a prompt and returns +structured output: + +```bash +# Basic usage. +claude -p "Explain this terraform plan" + +# Structured JSON output. +claude -p "Analyze this" --output-format json + +# Schema-validated output. +claude -p "List issues" --json-schema '{"type":"object","properties":{"issues":{"type":"array"}}}' + +# Pipe context via stdin. +cat plan.txt | claude -p "Analyze this terraform plan" + +# Control tool access and budget. +claude -p "query" --max-turns 3 --max-budget-usd 0.50 --allowedTools "Read,Glob,Grep" + +# Custom system prompt. +claude -p "query" --append-system-prompt "You are an Atmos infrastructure expert" + +# Load MCP servers (Atmos can provide its own MCP config). +claude -p "query" --mcp-config ./atmos-mcp.json + +# Continue a conversation. +claude -p "follow up" --resume +``` + +**Output format (`--output-format json`):** +```json +{ + "type": "result", + "subtype": "success", + "cost_usd": 0.003, + "duration_ms": 1250, + "duration_api_ms": 980, + "is_error": false, + "num_turns": 1, + "result": "The terraform plan shows 3 resources will be created...", + "session_id": "abc123", + "total_cost_usd": 0.003 +} +``` + +**Authentication:** Uses the user's Claude Code OAuth session (Claude Pro/Max subscription). +No API key needed. The user authenticates once with `claude auth login`. + +**Pricing:** Included in Claude Pro ($20/mo) or Claude Max ($100-200/mo) subscription. +No per-token charges. + +### Gemini CLI (`gemini -p`) + +**Feasibility: YES — HIGH** + +Gemini CLI supports non-interactive mode: + +```bash +# Basic usage. +gemini -p "Explain this infrastructure" + +# JSON output. +gemini -p "Analyze" --output-format json + +# Streaming JSON events. +gemini -p "query" --output-format stream-json + +# Model selection. +gemini -p "query" -m gemini-2.5-flash + +# Include directory context. +gemini -p "Review this component" --include-directories ../components +``` + +**Authentication:** Google Sign-In (OAuth) via browser. No API key required for free tier. + +**Pricing:** +- Free tier: 60 requests/min, 1,000 requests/day (with Google account) +- Paid tier: Higher rate limits with AI Studio API key + +### OpenAI Codex CLI (`codex exec`) + +**Feasibility: YES — HIGH** + +OpenAI Codex CLI is a full-featured coding agent comparable to Claude Code. It supports +non-interactive execution via the `codex exec` subcommand: + +```bash +# Basic non-interactive usage. +codex exec "Explain this terraform plan" + +# Structured JSONL output (streaming events). +codex exec --json "Analyze this infrastructure" + +# JSON Schema validated output. +codex exec --output-schema ./response-schema.json "List issues" + +# Pipe context via stdin. +cat plan.txt | codex exec - + +# Full-auto mode (no approval prompts). +codex exec --full-auto "Fix the linting errors" + +# Save final response to file. +codex exec -o result.txt "Summarize the changes" + +# Select model. +codex exec -m gpt-5.4-mini "Quick analysis" + +# Resume a previous session. +codex exec resume --last + +# Load MCP servers. +# Configured via ~/.codex/config.toml [mcp_servers.] section. +``` + +**Output format (`--json`):** + +Codex CLI emits JSONL (newline-delimited JSON) events: +```json +{"type":"thread.started","thread_id":"019d499a-ca7f-7ec3-af21-5860784b0a11"} +{"type":"turn.started"} +{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"Analysis..."}} +{"type":"turn.completed","usage":{"input_tokens":1200,"output_tokens":450}} +``` + +**Note:** The actual Codex CLI output uses `item.type="agent_message"` with text directly +on `item.text`, not `item.type="message"` with nested `item.content[].text` as documented +in the OpenAI API reference. The Atmos parser handles both formats for compatibility. + +**Authentication — dual model:** +- **ChatGPT subscription** (default): `codex login` — usage counts against plan limits + - Plus ($20/mo): 30-150 messages per 5 hours + - Pro ($200/mo): 300-1,500 messages per 5 hours + - Team/Business/Enterprise: included +- **API key**: `CODEX_API_KEY` env var — billed per token + +**MCP support:** Full MCP client AND server. Can load MCP servers from config and also +act as an MCP server itself (`codex mcp-server`). + +**Installation:** +- npm: `npm install -g @openai/codex` +- Homebrew: `brew install --cask codex` +- Binary: GitHub Releases (macOS/Linux ARM/x86) + +**Models:** gpt-5.4 (default), gpt-5.4-mini, gpt-5.3-codex, local models via `--oss` (Ollama). + +**Unique features vs Claude Code/Gemini CLI:** +- `--output-schema` for JSON Schema validated output +- `codex cloud exec` for remote/cloud execution +- `--oss` flag for local Ollama models (no cloud needed) +- TypeScript SDK (`@openai/codex-sdk`) for programmatic embedding +- Open source (Apache 2.0) +- Can act as MCP server (`codex mcp-server`) + +### Summary of All Local AI Tools + +| Tool | Non-Interactive Mode | Structured Output | MCP | Subscription Auth | Free Tier | Feasibility | +|--------------------|----------------------|-------------------|-----------------|-------------------|--------------|----------------| +| Claude Code | `claude -p` | JSON | Client only | Claude Pro/Max | No | **YES — HIGH** | +| Codex CLI | `codex exec` | JSONL + Schema | Client + Server | ChatGPT Plus/Pro | No | **YES — HIGH** | +| Gemini CLI | `gemini -p` | JSON | Client ⚠️ | Google account | Yes (1K/day) | **YES — HIGH** | +| GitHub Copilot CLI | Retired | N/A | N/A | N/A | N/A | NO | +| Cursor CLI | No programmatic API | N/A | N/A | N/A | N/A | NO | + +### Claude Agent SDK / Codex SDK — Why NOT to Use Them + +Both Claude Agent SDK (Python/TypeScript) and Codex SDK (TypeScript) exist but are +**not suitable** for direct Atmos integration: + +1. **Language mismatch** — Both SDKs are Python/TypeScript, Atmos is Go. Would require + bundling a runtime. +2. **Licensing restriction (Claude)** — Anthropic explicitly states: "Unless previously + approved, Anthropic does not allow third party developers to offer claude.ai login or + rate limits for their products." +3. **Unnecessary** — The CLI tools (`claude -p`, `codex exec`, `gemini -p`) provide + everything the SDKs do, with simpler integration (subprocess vs. FFI). + +**Important distinction:** When Atmos invokes the user's locally installed CLI binary, +the user is running their own tool — Atmos is not "offering" any provider's login. This is +the same pattern as Atmos invoking `terraform` — it uses the user's installation, not a +bundled copy. + +--- + +## Architecture + +### Provider Registration + +Three new providers join the existing 7-provider registry: + +```text +pkg/ai/agent/ +├── anthropic/ # (existing) Direct Anthropic API +├── openai/ # (existing) Direct OpenAI API +├── gemini/ # (existing) Direct Google AI API +├── grok/ # (existing) Direct xAI API +├── ollama/ # (existing) Local Ollama API +├── bedrock/ # (existing) AWS Bedrock API +├── azureopenai/ # (existing) Azure OpenAI API +├── claudecode/ # (NEW) Claude Code CLI subprocess +├── codexcli/ # (NEW) OpenAI Codex CLI subprocess +└── geminicli/ # (NEW) Gemini CLI subprocess +``` + +Each new provider implements the existing `registry.Client` interface by shelling out to +the CLI binary and parsing JSON/JSONL responses. + +**Interface note:** The `registry.Client` interface has 5 methods (`SendMessage`, +`SendMessageWithTools`, `SendMessageWithHistory`, `SendMessageWithToolsAndHistory`, +`SendMessageWithSystemPromptAndTools`). CLI providers implement `SendMessage` natively. +Tool-use methods (`SendMessageWithTools`, etc.) are not supported because the CLI subprocess +manages its own tool loop internally. These methods return a "not supported" error — the +executor falls back to `SendMessage` with tool descriptions included in the prompt text. +For tool execution, MCP pass-through (Phase 3) is the recommended approach. + +### Execution Flow — CLI Provider with MCP Pass-Through + +This diagram shows the complete flow when a CLI provider (e.g., `claude-code`) is used +with MCP servers configured in `atmos.yaml`. This is the most complex path — simple +prompt-only queries skip steps 3-8. + +```text +┌─────────────────────────────────────────────────────────────────────┐ +│ 1. User runs: atmos ai ask "What did we spend on EC2 last month?" │ +└────────────────────────────┬────────────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ 2. Atmos reads atmos.yaml │ +│ • AI provider: claude-code (CLI provider) │ +│ • MCP servers: 2 configured (aws-docs, aws-billing) │ +│ • Auth identity: "readonly" on servers that need credentials │ +│ • Toolchain: uv → astral-sh/uv (for uvx binary) │ +└────────────────────────────┬────────────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ 3. Atmos resolves toolchain │ +│ • Loads toolchain dependencies (uv → astral-sh/uv) │ +│ • Extracts toolchain bin PATH: ~/.atmos/toolchain/bin/... │ +└────────────────────────────┬────────────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ 4. Atmos generates MCP config │ +│ (CLI providers skip MCP routing — ALL servers are included) │ +│ │ +│ For each MCP server in atmos.yaml: │ +│ • Servers WITH identity → wrapped with atmos auth exec: │ +│ command: "atmos" │ +│ args: ["auth", "exec", "-i", "readonly", "--", │ +│ "uvx", "awslabs.billing-cost-management-mcp-server"] │ +│ • Servers WITHOUT identity → command used directly │ +│ • Toolchain PATH injected into each server's env │ +│ • Env var keys uppercased (Viper lowercases YAML keys) │ +│ • ATMOS_* env vars injected (Codex CLI only) │ +│ │ +│ Config delivery per provider: │ +│ • Claude Code: temp .mcp.json via --mcp-config flag │ +│ • Codex CLI: ~/.codex/config.toml (backup/restore after exit) │ +│ • Gemini CLI: .gemini/settings.json in cwd (backup/restore) │ +└────────────────────────────┬────────────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ 5. Atmos invokes the CLI tool as a subprocess │ +│ │ +│ Claude Code: │ +│ claude -p --output-format json --max-turns 10 \ │ +│ --mcp-config /tmp/atmos-mcp.json \ │ +│ --dangerously-skip-permissions │ +│ │ +│ Codex CLI: │ +│ codex exec --json \ │ +│ --dangerously-bypass-approvals-and-sandbox │ +│ │ +│ Prompt sent via stdin. │ +└────────────────────────────┬────────────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ 6. CLI tool reads the MCP config and starts relevant servers │ +│ │ +│ The CLI tool sees all configured servers and their tools. │ +│ It decides which server to use based on the query. │ +│ → Starts aws-billing MCP server from the config: │ +│ atmos auth exec -i readonly -- \ │ +│ uvx awslabs.billing-cost-management-mcp-server@latest │ +└────────────────────────────┬────────────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ 7. atmos auth exec handles authentication │ +│ │ +│ • Resolves "readonly" identity through SSO provider chain │ +│ • Writes credentials to ~/.aws/atmos// │ +│ • Sets AWS_SHARED_CREDENTIALS_FILE, AWS_CONFIG_FILE, │ +│ AWS_PROFILE on the subprocess environment │ +│ • Starts the MCP server with authenticated credentials │ +└────────────────────────────┬────────────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ 8. MCP server runs with AWS credentials │ +│ │ +│ • uvx installs awslabs.billing-cost-management-mcp-server │ +│ • MCP server connects to AWS Cost Explorer API │ +│ • CLI tool calls the billing tool via JSON-RPC │ +│ • Tool returns raw cost data (service line items, amounts) │ +└────────────────────────────┬────────────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ 9. CLI tool analyzes the raw data and returns result │ +│ │ +│ Claude Code JSON: │ +│ { "type": "result", "result": "EC2 spend was $88.10." } │ +│ │ +│ Codex CLI JSONL: │ +│ {"type":"item.completed","item":{"type":"agent_message", │ +│ "text":"EC2 spend was $88.10."}} │ +└────────────────────────────┬────────────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ 10. Atmos parses the response and renders it │ +│ │ +│ EC2 spend was $88.10. │ +│ │ +│ 11. Atmos cleans up (temp MCP config, restore backed-up config) │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### Execution Flow — API Provider with MCP Routing + +For comparison, here's the flow when an API provider (e.g., `anthropic`) is used with +MCP servers. The key difference is that Atmos manages MCP servers directly and routes +to the relevant ones based on the query. + +```text +┌─────────────────────────────────────────────────────────────────────┐ +│ 1. User runs: atmos ai ask "What did we spend on EC2 last month?" │ +└────────────────────────────┬────────────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ 2. Atmos reads atmos.yaml │ +│ • AI provider: anthropic (API provider) │ +│ • MCP servers: 2 configured (aws-docs, aws-billing) │ +│ • API key: from ANTHROPIC_API_KEY env var │ +└────────────────────────────┬────────────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ 3. Smart MCP routing │ +│ • Atmos sends server descriptions + query to the AI provider │ +│ • AI returns: ["aws-billing"] (relevant to "EC2 spend") │ +│ • Only aws-billing is started (aws-docs skipped) │ +└────────────────────────────┬────────────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ 4. Atmos starts the MCP server with auth credentials │ +│ • atmos auth exec -i readonly -- uvx awslabs.billing@latest │ +│ • MCP server connects, tools are registered in Atmos │ +│ • Tools appear alongside native Atmos tools (describe, list) │ +└────────────────────────────┬────────────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ 5. Atmos sends prompt + tool definitions to the API │ +│ • API call to Anthropic with tool schemas │ +│ • AI decides to call the billing tool │ +│ • Atmos executes the tool and returns results to the AI │ +│ • AI generates final response from the tool results │ +└────────────────────────────┬────────────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ 6. Atmos displays the result with markdown rendering │ +│ │ +│ EC2 spend was $88.10. │ +│ │ +│ 7. Atmos stops MCP server(s) │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +**Key difference:** With API providers, Atmos manages the tool execution loop — it sends +tool definitions, executes tools on behalf of the AI, and returns results. With CLI +providers, the CLI tool manages its own tool loop — Atmos just provides the MCP config +and the CLI tool handles everything internally. + +### Configuration + +**Zero-config mode** (auto-detect installed CLI): + +```yaml +# atmos.yaml +ai: + enabled: true + default_provider: claude-code + # No api_key, no model needed. + # Atmos auto-detects the installed claude binary and uses + # the user's subscription. +``` + +**Explicit configuration** (follows existing `ai.providers.` pattern): + +```yaml +ai: + enabled: true + default_provider: claude-code + providers: + claude-code: + # Path to claude binary (optional, defaults to exec.LookPath). + binary: /usr/local/bin/claude + # Max agentic turns per invocation. + max_turns: 5 + # Budget cap per invocation (USD). + max_budget_usd: 1.00 + # Allowed tools for Claude Code to use. + allowed_tools: + - Read + - Glob + - Grep + + codex-cli: + # Path to codex binary (optional, defaults to exec.LookPath). + binary: /usr/local/bin/codex + # Model selection. + model: gpt-5.4-mini + # Approval policy: full-auto for file writes (no MCP). + # When MCP servers are configured, --dangerously-bypass-approvals-and-sandbox + # is used automatically (full-auto doesn't approve MCP tool calls). + full_auto: true + + gemini-cli: + binary: /usr/local/bin/gemini + model: gemini-2.5-flash +``` + +**Note:** CLI providers use the same `ai.providers.` structure as API providers. +Fields like `api_key` are simply not needed — the CLI handles auth via its own session. +CLI-specific fields (`binary`, `max_turns`, `max_budget_usd`, `full_auto`, `allowed_tools`) +are stored as extended provider config. + +### Provider Implementations + +#### Claude Code Provider + +```go +// pkg/ai/agent/claudecode/client.go + +type Client struct { + binaryPath string + maxTurns int + maxBudget float64 + tools []string + fallback string +} + +func (c *Client) SendMessage(ctx context.Context, prompt string) (string, error) { + args := []string{ + "-p", + "--output-format", "json", + "--max-turns", strconv.Itoa(c.maxTurns), + } + if c.maxBudget > 0 { + args = append(args, "--max-budget-usd", fmt.Sprintf("%.2f", c.maxBudget)) + } + if c.fallback != "" { + args = append(args, "--fallback-model", c.fallback) + } + + cmd := exec.CommandContext(ctx, c.binaryPath, args...) + cmd.Stdin = strings.NewReader(prompt) + + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("claude-code execution failed: %w", err) + } + + var response claudeCodeResponse + if err := json.Unmarshal(output, &response); err != nil { + return "", fmt.Errorf("failed to parse claude-code response: %w", err) + } + + if response.IsError { + return "", fmt.Errorf("claude-code error: %s", response.Result) + } + + return response.Result, nil +} + +type claudeCodeResponse struct { + Type string `json:"type"` + Result string `json:"result"` + CostUSD float64 `json:"cost_usd"` + TotalCostUSD float64 `json:"total_cost_usd"` + DurationMS int `json:"duration_ms"` + IsError bool `json:"is_error"` + SessionID string `json:"session_id"` + NumTurns int `json:"num_turns"` +} +``` + +#### OpenAI Codex CLI Provider + +```go +// pkg/ai/agent/codexcli/client.go + +type Client struct { + binaryPath string + model string + fullAuto bool + mcpServers map[string]schema.MCPServerConfig + toolchainPATH string + hasMCPServers bool // True if MCP servers were written to ~/.codex/config.toml. + originalConfig []byte // Original ~/.codex/config.toml content for restore. + configBackedUp bool +} + +func (c *Client) SendMessage(ctx context.Context, prompt string) (string, error) { + args := []string{"exec", "--json"} + if c.model != "" && c.model != ProviderName { + args = append(args, "-m", c.model) + } + // --full-auto only auto-approves file writes, not MCP tool calls. + // --dangerously-bypass-approvals-and-sandbox is needed for MCP. + if c.hasMCPServers { + args = append(args, "--dangerously-bypass-approvals-and-sandbox") + } else if c.fullAuto { + args = append(args, "--full-auto") + } + + cmd := exec.CommandContext(ctx, c.binaryPath, args...) + cmd.Stdin = strings.NewReader(prompt) + + // Restore ~/.codex/config.toml after Codex exits. + if c.hasMCPServers { + defer c.restoreGlobalConfig() + } + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { ... } + + return ExtractResult(stdout.Bytes()) +} + +// ExtractResult parses JSONL and extracts text from "agent_message" or "message" events. +func ExtractResult(output []byte) (string, error) { + var lastText string + scanner := bufio.NewScanner(bytes.NewReader(output)) + for scanner.Scan() { + if text := extractTextFromEvent(scanner.Bytes()); text != "" { + lastText = text + } + } + if lastText == "" { + trimmed := strings.TrimSpace(string(output)) + if trimmed != "" { return trimmed, nil } + return "", errUtils.ErrCLIProviderParseResponse + } + return lastText, nil +} +``` + +#### Gemini CLI Provider + +```go +// pkg/ai/agent/geminicli/client.go + +type Client struct { + binaryPath string + model string + includeDirs bool +} + +func (c *Client) SendMessage(ctx context.Context, prompt string) (string, error) { + args := []string{ + "-p", + "--output-format", "json", + } + if c.model != "" { + args = append(args, "-m", c.model) + } + + cmd := exec.CommandContext(ctx, c.binaryPath, args...) + cmd.Stdin = strings.NewReader(prompt) + + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("gemini-cli execution failed: %w", err) + } + + var response geminiCLIResponse + if err := json.Unmarshal(output, &response); err != nil { + // Gemini CLI may return plain text in some modes. + return string(output), nil + } + + return response.Response, nil +} + +type geminiResponse struct { + SessionID string `json:"session_id"` + Response string `json:"response"` // Note: "response" not "result". +} +``` + +**Gemini CLI output format (`--output-format json`):** + +Gemini CLI returns structured JSON with the model response. When `--output-format stream-json` +is used, it emits newline-delimited JSON events for incremental processing. + +**Gemini CLI key differences from Claude Code:** + +| Feature | Claude Code (`claude -p`) | Gemini CLI (`gemini -p`) | +|-----------------------|--------------------------------------|--------------------------------------| +| **Auth** | OAuth (Claude Pro/Max) | Google Sign-In (free tier available) | +| **Cost** | $20-200/mo subscription | Free (1K req/day) or API key | +| **Structured output** | `--json-schema` for validated output | `--output-format json` | +| **Tool control** | `--allowedTools` flag | N/A | +| **Budget cap** | `--max-budget-usd` | N/A | +| **Session resume** | `--resume ` | N/A | +| **MCP config** | `--mcp-config file.json` | `.gemini/settings.json` (blocked ⚠️) | +| **System prompt** | `--append-system-prompt` | N/A (via prompt engineering) | +| **Model selection** | `--fallback-model` | `-m gemini-2.5-flash` | +| **Directory context** | N/A (uses MCP/tools) | `--include-directories` | +| **Max turns** | `--max-turns N` | N/A | + +**When to use which:** + +- **Claude Code** — Best for complex infrastructure analysis, tool-use workflows, MCP + integration, and users with Claude Max subscriptions. Richest feature set. +- **Codex CLI** — Best for OpenAI users with ChatGPT Plus/Pro subscriptions. Full MCP + support (client + server), JSON Schema output validation, open source, local model + support via Ollama. +- **Gemini CLI** — Best for cost-conscious users (free tier), quick prompt-only queries, + and environments where Google auth is already available. MCP servers are not available + with the free `oauth-personal` tier — use Claude Code or Codex CLI for MCP workflows. + +### MCP Integration — Best of Both Worlds + +The most powerful usage combines local providers with MCP: + +```yaml +ai: + default_provider: claude-code + +mcp: + servers: + aws-cost-explorer: + command: "uvx" + args: ["awslabs.cost-explorer-mcp-server@latest"] + identity: "billing-readonly" # Atmos Auth identity (from the auth section) +``` + +When the user runs `atmos ai chat`: + +1. Atmos starts the AWS Cost Explorer MCP server (with auth credentials). +2. Atmos generates a temporary `mcp.json` config pointing to the running MCP server. +3. Atmos invokes `claude -p --mcp-config /tmp/atmos-mcp.json "query"`. +4. Claude Code can use both its built-in tools AND the Atmos MCP tools AND the AWS MCP + tools — all through the user's Claude Max subscription. + +```text +User's Claude Max subscription + │ + claude -p --mcp-config atmos-mcp.json + │ + ┌────┴────────────────────────────┐ + │ Claude Code │ + │ ┌──────────┐ ┌──────────────┐ │ + │ │ Built-in │ │ MCP Clients │ │ + │ │ Tools │ │ │ │ + │ │ (Read, │ │ ┌──────────┐ │ │ + │ │ Edit, │ │ │ Atmos │ │ │ + │ │ Bash) │ │ │ MCP Srv │ │ │ + │ └──────────┘ │ ├──────────┤ │ │ + │ │ │ AWS Cost │ │ │ + │ │ │ Explorer │ │ │ + │ │ └──────────┘ │ │ + │ └──────────────┘ │ + └─────────────────────────────────┘ +``` + +--- + +## User Experience Comparison + +### Current Flow (API Tokens) + +```text +1. Sign up for Anthropic Console account +2. Add payment method +3. Generate API key +4. Configure atmos.yaml: + ai: + provider: anthropic + api_key_env_var: ANTHROPIC_API_KEY +5. Set env var: export ANTHROPIC_API_KEY=sk-ant-... +6. Run: atmos ai chat + → Pay per token ($3-15 per million tokens) +``` + +### New Flow (Local Provider) + +```text +1. Install Claude Code: brew install --cask claude-code (already done by most users) +2. Authenticate: claude auth login (already done by most users) +3. Configure atmos.yaml: + ai: + provider: claude-code +4. Run: atmos ai chat + → Uses existing Claude Max subscription + → No additional cost +``` + +### Even Simpler — Auto-Detection + +```text +# If claude is on PATH and no provider is configured: +ai: + enabled: true + # provider auto-detected: claude-code (found /usr/local/bin/claude) +``` + +--- + +## Comparison Matrix + +| Feature | API Providers (Current) | Claude Code | Codex CLI | Gemini CLI | +|-----------------------|----------------------------|-------------------------|--------------------------|-------------------------------| +| **Setup** | API account + key + config | `brew install --cask claude-code` | `npm i -g @openai/codex` | `npm i -g @google/gemini-cli` | +| **Auth** | API key in env var | OAuth (subscription) | OAuth or API key | Google Sign-In | +| **Cost** | Per-token ($3-15/M tokens) | $20-200/mo subscription | $20-200/mo or per-token | Free (1K/day) | +| **Models** | Configurable | Latest (auto-updates) | gpt-5.4, gpt-5.4-mini | gemini-2.5-flash | +| **Tool use** | Atmos tools only | Claude tools + MCP | Codex tools + MCP | Gemini built-in tools only | +| **MCP** | N/A | Client only | Client + Server | Blocked with free tier ⚠️ | +| **Structured output** | Provider-dependent | JSON + schema | JSONL + JSON Schema | JSON | +| **Session** | Atmos-managed SQLite | Claude-managed | Codex-managed | N/A | +| **Offline** | No (except Ollama) | No | Yes (`--oss` Ollama) | No | +| **Rate limits** | API-specific | Subscription tier | Subscription or API | 60/min, 1K/day (free) | +| **Open source** | N/A | No | Yes (Apache 2.0) | Yes (Apache 2.0) | + +--- + +## Tool and MCP Integration — Key Difference from API Providers + +With API providers (Anthropic, OpenAI, etc.), Atmos sends tool definitions directly to the +AI provider and manages the tool execution loop in-process. The AI decides which tools to +call, Atmos executes them, and sends results back. + +**CLI providers cannot receive tool definitions directly.** The CLI subprocess manages its +own tool loop internally. Atmos has no way to inject custom tool schemas into +`claude -p` or `codex exec`. + +**MCP server routing and registration is skipped for CLI providers.** When a CLI provider +is selected (`claude-code`, `codex-cli`, `gemini-cli`), Atmos does NOT: + +- Call the AI to select relevant MCP servers (no routing call) +- Start MCP server subprocesses +- Register MCP tools in the Atmos tool registry +- Show "MCP routing selected..." or "MCP server started..." messages + +This is enforced by `isCLIProvider()` in `cmd/ai/init.go`. The check uses the +`default_provider` name from `atmos.yaml` to determine if the provider is CLI-based. + +Instead, MCP servers are available to CLI providers via **MCP pass-through** (Phase 3) — +Atmos generates a provider-specific MCP config and passes it to the CLI tool. The CLI +tool starts and manages the MCP servers itself, each with their own approach: + +- **Claude Code**: Temp `.mcp.json` file passed via `--mcp-config` flag. +- **Codex CLI**: MCP servers written to `~/.codex/config.toml` (backup/restore after exit). +- **Gemini CLI**: `.gemini/settings.json` written to current working directory. + +In all cases, MCP servers with `identity` are wrapped with `atmos auth exec -i --` +for automatic credential injection. Toolchain PATH and `ATMOS_*` env vars are injected +so subprocesses can find `uvx`/`npx` and auth config. + +| Capability | API Providers | CLI Providers (Phase 1-2) | CLI Providers (Phase 3) | +|--------------------------------------------|-------------------------|---------------------------|-------------------------| +| **Atmos tools** (describe, list, validate) | Direct injection | Not available | Via MCP pass-through | +| **External MCP tools** (AWS, custom) | Via BridgedTool | Not available | Via MCP pass-through | +| **Tool execution loop** | Atmos-managed | N/A | CLI-managed | +| **Tool results in output** | Tool Executions section | N/A | Displayed by CLI tool | + +Phase 3 MCP pass-through is shipped for Claude Code and Codex CLI. Gemini CLI has the +implementation complete, but MCP is blocked server-side for personal Google accounts (see +Known Limitations). Without MCP pass-through, CLI providers work as **prompt-only** — the +AI answers based on the prompt text and any context Atmos provides. + +--- + +## Phased Implementation + +### Phase 1: Claude Code Provider (MVP) ✅ Shipped + +- `pkg/ai/agent/claudecode/` with `registry.Client` interface. +- Auto-detect `claude` binary via `exec.LookPath`. +- `claude -p --output-format json` invocation with JSON response parsing. +- Configuration in `atmos.yaml` under `ai.providers.claude-code`. +- Error handling: binary not found, auth expired, rate limited. +- 15 unit tests. + +### Phase 2: Codex CLI + Gemini CLI Providers ✅ Shipped + +- `pkg/ai/agent/codexcli/` with `registry.Client` interface. +- Auto-detect `codex` binary. `codex exec --json` invocation with JSONL parsing. +- `pkg/ai/agent/geminicli/` with `registry.Client` interface. +- Auto-detect `gemini` binary. `gemini -p --output-format json` invocation. +- Configuration in `atmos.yaml` under `ai.providers.codex-cli` / `gemini-cli`. +- 19 unit tests across both providers. + +### Phase 3: MCP Pass-Through ✅ Shipped (Claude Code, Codex CLI) + +**Goal:** Give CLI providers access to the same MCP tools that API providers have. + +**Key insight:** `atmos mcp export` already generates `.mcp.json` with auth-wrapped +servers. The exported config is exactly what Claude Code needs. + +**How it works:** + +1. When a CLI provider is selected and `mcp.servers` is configured in `atmos.yaml`: +2. Atmos generates a temp `.mcp.json` via `WriteMCPConfigToTempFile()`. +3. The exported `.mcp.json` wraps each server with `atmos auth exec -i --` + for automatic credential injection (same as IDE integration). +4. Env var keys are uppercased (Viper lowercases them, but env vars must be UPPERCASE). +5. Toolchain PATH is injected so `uvx`/`npx` are available to MCP server subprocesses. +6. Atmos passes `--mcp-config --dangerously-skip-permissions` to Claude Code. +7. `--dangerously-skip-permissions` is required because `-p` mode is non-interactive + and cannot show approval prompts. This is safe because the MCP servers were explicitly + configured by the user in `atmos.yaml`. +8. The temp file is cleaned up after the CLI tool exits. + +**Implemented for:** +- ✅ Claude Code: `claude -p --mcp-config --dangerously-skip-permissions` +- ⚠️ Gemini CLI: writes `.gemini/settings.json` to cwd, passes `--allowed-mcp-server-names` — **MCP blocked with `oauth-personal` auth** (see Known Limitations) +- ✅ Codex CLI: writes to `~/.codex/config.toml` (backup/restore), uses `--dangerously-bypass-approvals-and-sandbox` + +**Gemini CLI approach:** +Gemini CLI has no `--mcp-config` flag. Instead, it reads MCP servers from +`.gemini/settings.json` in the project directory. Atmos writes `.gemini/settings.json` +in the **current working directory** (not a temp dir) because Gemini CLI's Trusted Folders +feature blocks MCP servers in untrusted directories. The `--approval-mode auto_edit` flag +is used instead of `--yolo` because Google Workspace admin policies may block YOLO mode. +Server names are passed via `--allowed-mcp-server-names` to explicitly enable them. + +**Gemini CLI — Trusted Folders and admin restrictions:** + +Gemini CLI has a security feature called "Trusted Folders" that blocks MCP servers, +YOLO mode, and workspace settings in untrusted directories. Enterprise settings are +controlled at three levels: + +1. **System settings** (highest precedence): + - macOS: `/Library/Application Support/GeminiCli/settings.json` + - Linux: `/etc/gemini-cli/settings.json` + - Override via `GEMINI_CLI_SYSTEM_SETTINGS_PATH` env var + - Can set `security.disableYoloMode: true` and control `mcp.allowed` list + +2. **Google Workspace admin policies:** + When authenticated with a managed Google Workspace account, the admin may enforce: + - MCP disabled: `"MCP is disabled by your administrator"` + - YOLO disabled: `"YOLO mode is disabled by secureModeEnabled setting"` + - These cannot be overridden locally — requires admin action + +3. **Folder trust:** + - Trust is stored in `~/.gemini/trustedFolders.json` + - Untrusted folders block: MCP servers, workspace settings, tool auto-accept + - Atmos writes to cwd (trusted by user) instead of temp dirs to avoid this + +**Gemini CLI MCP — Known Limitation with `oauth-personal` auth:** + +When using `oauth-personal` authentication (the default for personal `@gmail.com` accounts), +Gemini CLI routes all requests through Google's internal proxy project (`splendid-syntax-pf16k`). +**Google has disabled the MCP feature flag on this proxy for all personal accounts.** This is +a server-side restriction that cannot be overridden by any local configuration. + +**This restriction is based on account type, not subscription tier.** Even users paying for +Gemini Advanced or Gemini 3 Pro are affected — the paid subscription controls model quality +and rate limits, but the MCP feature gate is an orthogonal infrastructure decision by Google. +All `@gmail.com` accounts route through the same proxy regardless of tier. + +**How Gemini CLI authentication works:** + +Gemini CLI supports three authentication modes, each with different infrastructure paths: + +| Auth Mode | Account Type | MCP Support | How It Works | +|------------------|--------------------------------------|----------------------|-----------------------------------------------------------------------| +| `oauth-personal` | Personal `@gmail.com` (free or paid) | **Blocked** | Routes through Google's internal proxy with MCP feature flag disabled | +| `gemini-api-key` | AI Studio API key (any account) | **Works** | Direct API calls to Gemini API, bypasses the proxy entirely | +| Google Workspace | Managed `@company.com` accounts | **Admin-controlled** | Routes through org proxy, admin can enable/disable MCP | + +The `oauth-personal` mode is the default when running `gemini auth login` with a personal +Google account. The proxy it uses (`cloudcode-pa.googleapis.com`) handles all personal +account traffic and has MCP disabled at the infrastructure level — there is no user-facing +setting, admin console, or environment variable that can override this. + +**Symptoms:** +- `gemini mcp list` returns exit code 52: "MCP is disabled by your administrator" +- The error message says "please request an update to the settings at: https://goo.gle/manage-gemini-cli" + but that link redirects to `https://geminicli.com/` — a dead end for personal accounts +- Gemini CLI can **read** `.gemini/settings.json` and **see** configured MCP server names, + but the servers are never loaded as tools (verified: `totalCalls: 0` in response stats) +- No local settings file, environment variable, or admin console can fix this + +**What we verified during investigation (2026-04-01):** +- `~/.gemini/settings.json` has `"selectedType": "oauth-personal"` (personal Gmail account) +- No system settings file exists at `/Library/Application Support/GeminiCli/settings.json` +- No `GEMINI_CLI_SYSTEM_SETTINGS_PATH` env var is set +- The working directory IS in `~/.gemini/trustedFolders.json` (Trusted Folders is not the issue) +- `.gemini/settings.json` in cwd has correct `mcpServers` format with all servers +- Gemini CLI version 0.28.2 +- `gemini -p "List available MCP tool names"` returns server names (reads settings.json via + `read_file` tool) but `stats.tools.totalCalls: 0` — no MCP tools were actually invoked +- Adding `"admin": { "mcp": { "enabled": true } }` to user settings has no effect + +**Workaround — switch to API key auth:** +1. Get a Gemini API key from [AI Studio](https://aistudio.google.com/app/apikey) +2. Set `selectedType: "gemini-api-key"` in `~/.gemini/settings.json` +3. Set `GEMINI_API_KEY` env var + +**However**, using an API key makes `gemini-cli` functionally equivalent to the existing +`gemini` API provider (which Atmos already supports) — it uses the same models and the +same API billing. The key value proposition of `gemini-cli` (free tier with personal +Google account, no API tokens needed) is lost when switching to API key auth. + +**Future outlook:** This restriction may be lifted in a future Gemini CLI release as Google +rolls out MCP support more broadly. The implementation on the Atmos side is complete — +`.gemini/settings.json` is generated correctly with auth wrapping, toolchain PATH, and +uppercased env vars. Once Google enables MCP for personal accounts, it should work +without any changes to Atmos. + +**Recommendation:** Use `gemini-cli` provider for prompt-only queries (no MCP) when +leveraging the free personal Google account tier. For MCP-enabled workflows, use +`claude-code` (recommended) or `codex-cli` instead — both have full MCP support with +their subscription-based auth. + +**Codex CLI approach:** +Codex CLI only reads MCP servers from `~/.codex/config.toml` (global config only — no +project-level config discovery, and `-c` flag overrides do NOT register MCP servers as +tools). Atmos writes MCP servers to `~/.codex/config.toml` with backup/restore: + +1. Back up the existing `~/.codex/config.toml` content (if any). +2. Append MCP server TOML sections with auth wrapping, toolchain PATH, and env vars. +3. Inject all `ATMOS_*` env vars (e.g., `ATMOS_PROFILE`) into each server's env section. +4. After Codex exits, restore the original config file. + +```toml +# Generated ~/.codex/config.toml example: +[mcp_servers.aws-billing] +command = "atmos" +args = ["auth", "exec", "-i", "core-root/terraform", "--", + "uvx", "awslabs.billing-cost-management-mcp-server@latest"] + +[mcp_servers.aws-billing.env] +AWS_REGION = "us-east-1" +ATMOS_PROFILE = "managers" +PATH = "/toolchain/bin:/usr/local/bin:/usr/bin" +``` + +**Key findings during Codex CLI MCP testing (2026-04-01):** + +1. **`--full-auto` does NOT auto-approve MCP tool calls** — it only auto-approves file + writes and shell commands. MCP tool calls require explicit approval or + `--dangerously-bypass-approvals-and-sandbox`. This is safe because MCP servers are + explicitly configured by the user in `atmos.yaml`. + +2. **Codex CLI output format differs from API docs** — The JSONL events use + `item.type="agent_message"` with text directly on `item.text`, not the documented + `item.type="message"` with nested `item.content[].text` array. `ExtractResult()` + handles both formats. + +3. **Project-level `.codex/config.toml` is not supported** — Codex CLI only reads from + `~/.codex/config.toml`. The initial temp-dir approach (writing `.codex/config.toml` + and setting `cmd.Dir`) did not work. `-c` flag overrides also don't register MCP + servers — they are visible in config but not loaded as tools at runtime. + +4. **`uvx` must be on PATH** — When `uvx` is only available in the Atmos toolchain, + the PATH env var must be injected into each MCP server's config via toolchain PATH + resolution. + +5. **Codex CLI MCP servers do NOT inherit the parent process environment** — Unlike + Claude Code (where `cmd.Env` is nil, causing Go to inherit the parent env), Codex + CLI's MCP server subprocesses only receive env vars explicitly configured in the + `[mcp_servers..env]` TOML section. `ATMOS_PROFILE` and other `ATMOS_*` vars + must be injected so `atmos auth exec` can discover the auth config. Without this, + auth fails with "identity not found" because `atmos` can't find the profile-based + auth configuration. + +**Also shipped:** +- MCP server routing and registration is skipped for CLI providers (`isCLIProvider()`). +- AI provider name shown in output: `ℹ AI provider: codex-cli`. +- MCP server count shown: `ℹ MCP servers configured: 8 (in ~/.codex/config.toml)`. +- Global config backup/restore ensures user's existing Codex config is preserved. + +**Summary of MCP config delivery per provider:** + +| Provider | Config Method | Approval Flag | Config Location | +|-------------|---------------------------------|----------------------------------------------|--------------------------------------------| +| Claude Code | `--mcp-config ` | `--dangerously-skip-permissions` | Temp `.mcp.json` file | +| Codex CLI | Write to `~/.codex/config.toml` | `--dangerously-bypass-approvals-and-sandbox` | Global config (backup/restore) | +| Gemini CLI | `.gemini/settings.json` in cwd | `--approval-mode auto_edit` | Current working directory (backup/restore) | + +**Auth handling:** + +The exported `.mcp.json` already handles auth correctly: + +```json +{ + "mcpServers": { + "aws-billing": { + "command": "atmos", + "args": ["auth", "exec", "-i", "readonly", "--", + "uvx", "awslabs.billing-cost-management-mcp-server@latest"], + "env": { "AWS_REGION": "us-east-1" } + } + } +} +``` + +When the CLI tool (Claude Code) starts this MCP server, `atmos auth exec` handles: +- SSO authentication via the configured identity chain +- Writing isolated credential files to `~/.aws/atmos//` +- Setting `AWS_SHARED_CREDENTIALS_FILE`, `AWS_CONFIG_FILE`, `AWS_PROFILE` +- The MCP server's AWS SDK picks up credentials automatically + +**Toolchain:** + +When Atmos manages MCP servers directly (API providers), it uses `WithToolchain` to +prepend the Atmos toolchain PATH to the subprocess environment. This makes `uvx`/`npx` +available even if not on the system PATH. + +When the CLI tool (Claude Code) manages MCP servers via `.mcp.json`, it starts them +as its own subprocesses — and doesn't know about the Atmos toolchain PATH. If `uvx` is +only available in the toolchain bin directory, the MCP server will fail to start. + +**Solution:** Before generating the temp `.mcp.json`, resolve the toolchain PATH via +`resolveToolchain()` and inject it into each server's `env` section: + +```json +{ + "mcpServers": { + "aws-billing": { + "command": "atmos", + "args": ["auth", "exec", "-i", "readonly", "--", + "uvx", "awslabs.billing-cost-management-mcp-server@latest"], + "env": { + "AWS_REGION": "us-east-1", + "PATH": "/Users/user/.atmos/toolchain/bin:/usr/local/bin:/usr/bin" + } + } + } +} +``` + +This ensures the CLI tool's MCP subprocess can find `uvx` regardless of whether the +user has it on the system PATH or only in the Atmos toolchain. + +**Implementation (shipped):** The `BuildMCPJSONEntry` function in `pkg/mcp/client/mcpconfig.go`: +1. Resolves toolchain via `dependencies.LoadToolVersionsDependencies` + `NewEnvironmentFromDeps`. +2. Extracts the toolchain PATH from `resolver.EnvVars()`. +3. Prepends it to the `PATH` in each server's `env` map via `injectToolchainPATH()`. +4. Uppercases all env var keys via `copyEnv()` (Viper lowercases YAML keys). +5. Deduplicates PATH entries via `deduplicatePATH()`. + +**Atmos tools via MCP (future):** + +To expose native Atmos tools (describe_component, list_stacks, etc.) to CLI providers: +1. Start `atmos mcp start` as a background MCP server process. +2. Add it to the generated MCP config as a local server entry. +3. The CLI tool connects to it alongside the external MCP servers. + +This is optional — many use cases only need external MCP servers (AWS billing, security). + +### Phase 4: Auto-Detection and Smart Defaults (Planned) + +- Auto-detect installed CLI tools and suggest/use the best available provider. +- Fallback chain: `claude-code` → `codex-cli` → `gemini-cli` → prompt for API key. +- Display provider and cost info in `atmos ai` output. +- Session continuity via `--resume` for Claude Code and Codex CLI. + +--- + +## Limitations and Trade-offs + +### Limitations + +1. **No tool-use loop** — Claude Code's `-p` mode runs its own tool loop internally. + Atmos cannot inject custom tools mid-conversation (but can provide them via MCP). +2. **No streaming to Atmos TUI** — The subprocess completes before output is available + (unless `stream-json` is parsed incrementally). +3. **Binary dependency** — Users must have `claude` or `gemini` installed. Not all + environments (CI/CD containers) will have them. +4. **Version coupling** — Claude Code's `-p` output format could change between versions. + Atmos needs to handle format evolution gracefully. +5. **Rate limits** — Subscription rate limits may be lower than API rate limits for + high-volume usage. +6. **Gemini CLI MCP blocked for all personal accounts** — Google disables MCP on the + server-side proxy for `oauth-personal` auth. This affects ALL personal `@gmail.com` + accounts regardless of subscription tier (free, Gemini Advanced, Gemini 3 Pro) — + the restriction is based on account type, not payment level. MCP servers configured + in `.gemini/settings.json` are visible to Gemini but cannot be invoked as tools. + Switching to `gemini-api-key` auth enables MCP but makes the provider functionally + equivalent to the existing `gemini` API provider. The `gemini-cli` provider works + for prompt-only queries without MCP. See Phase 3 section for full details. + +### Trade-offs + +| | API Providers | Local Providers | +|-------------------------|------------------------------------------|--------------------------------------| +| **Control** | Full control over prompts, tools, tokens | Limited to CLI flags | +| **Cost predictability** | Pay-per-use (variable) | Fixed subscription (predictable) | +| **CI/CD** | Works everywhere with env var | Requires CLI installation + auth | +| **Tool execution** | Atmos tools run in-process | Tools run inside Claude Code process | +| **Latency** | Direct API call | Subprocess spawn + API call | + +### Recommended Usage + +- **Interactive development with MCP** → Claude Code provider (subscription, rich features, full MCP) +- **CI/CD pipelines** → API providers (env var auth, no interactive login) +- **Cost-conscious users (no MCP)** → Gemini CLI provider (free tier, prompt-only) +- **MCP with OpenAI** → Codex CLI provider (ChatGPT subscription, full MCP client + server) +- **Enterprise** → API providers or Bedrock (compliance, audit trails) + +--- + +## References + +### Claude Code + +- [Claude Code CLI Reference](https://docs.anthropic.com/en/docs/claude-code/cli-reference) +- [Claude Code Non-Interactive Mode](https://docs.anthropic.com/en/docs/claude-code/cli-usage#non-interactive-mode) +- [Claude Agent SDK](https://docs.anthropic.com/en/docs/agents/agent-sdk) + +### OpenAI Codex CLI + +- [OpenAI Codex CLI](https://github.com/openai/codex) +- [Codex CLI Non-Interactive Mode](https://developers.openai.com/codex/noninteractive) +- [Codex CLI Reference](https://developers.openai.com/codex/cli/reference) +- [Codex CLI MCP](https://developers.openai.com/codex/mcp) +- [Codex CLI Configuration Reference](https://developers.openai.com/codex/config-reference) +- [Codex CLI Advanced Configuration](https://developers.openai.com/codex/config-advanced) +- [Codex SDK](https://developers.openai.com/codex/sdk) + +### Gemini CLI + +- [Gemini CLI Repository](https://github.com/google-gemini/gemini-cli) +- [Gemini CLI Configuration](https://geminicli.com/docs/get-started/configuration) +- [Gemini CLI MCP Server Setup](https://github.com/google-gemini/gemini-cli/blob/main/docs/tools/mcp-server.md) +- [Gemini CLI MCP Tutorial](https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/tutorials/mcp-setup.md) +- [GitHub MCP Server — Gemini CLI Install Guide](https://github.com/github/github-mcp-server/blob/main/docs/installation-guides/install-gemini-cli.md) + +### Atmos + +- [Atmos AI PRD](./atmos-ai.md) — Core AI architecture +- [Atmos MCP Integrations PRD](./atmos-mcp-integrations.md) — External MCP servers +- [Atmos AI Global Flag PRD](./atmos-ai-global-flag.md) — `--ai` and `--skill` flags diff --git a/errors/errors.go b/errors/errors.go index 293343312e..6c30f5edf4 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -1004,6 +1004,12 @@ var ( ErrAIComponentPathNotFound = errors.New("component path not found") ErrAIComponentPathNotDirectory = errors.New("component path is not a directory") + // CLI provider errors. + ErrCLIProviderBinaryNotFound = errors.New("CLI provider binary not found on PATH") + ErrCLIProviderExecFailed = errors.New("CLI provider execution failed") + ErrCLIProviderParseResponse = errors.New("failed to parse CLI provider response") + ErrCLIProviderToolsNotSupported = errors.New("tool execution not supported for CLI providers; use MCP pass-through instead") + // Web search errors. ErrWebSearchFailed = errors.New("web search request failed") ErrWebSearchParseFailed = errors.New("failed to parse web search results") diff --git a/examples/ai-claude-code/README.md b/examples/ai-claude-code/README.md new file mode 100644 index 0000000000..6b9c5545e4 --- /dev/null +++ b/examples/ai-claude-code/README.md @@ -0,0 +1,56 @@ +# Example: AI with Claude Code CLI + +Use your Claude Pro/Max subscription instead of API tokens. Claude Code manages the AI +conversation — Atmos provides MCP server orchestration with automatic AWS credential injection. + +Learn more in the [Atmos AI documentation](https://atmos.tools/ai). + +## What You'll See + +- [Claude Code as an AI provider](https://atmos.tools/cli/configuration/ai/providers#cli-providers) — no API key needed +- [MCP pass-through](https://atmos.tools/cli/configuration/mcp) — AWS MCP servers passed to Claude Code automatically +- [Atmos Auth](https://atmos.tools/cli/configuration/auth) — SSO credential injection for MCP servers + +## Prerequisites + +1. **Claude Code** installed and authenticated: + ```bash + brew install --cask claude-code + claude auth login + ``` + +2. **Atmos Auth** configured for AWS MCP servers that need credentials. + Update the `auth` section in `atmos.yaml` with your SSO start URL, permission set, + and account ID, then run: + ```bash + atmos auth login + ``` + See the [Atmos Auth documentation](https://atmos.tools/cli/configuration/auth) for setup details. + +## Try It + +```shell +cd examples/ai-claude-code + +# Simple question (no MCP needed) +atmos ai ask "What stacks do we have?" + +# Uses aws-docs MCP server (no credentials needed) +atmos ai ask "Search AWS docs for VPC peering" + +# Uses aws-billing MCP server (requires Atmos Auth) +atmos ai ask "What did we spend on EC2 last month?" +``` + +## Related Examples + +- **[AI with API Providers](../ai/)** — Use API tokens (Anthropic, OpenAI, etc.) + instead of a CLI subscription. + +## Key Files + +| File | Purpose | +|-----------------------------|--------------------------------------------------| +| `atmos.yaml` | AI provider, MCP servers, and auth configuration | +| `stacks/` | Minimal stack configuration | +| `components/terraform/vpc/` | Mock VPC component | diff --git a/examples/ai-claude-code/atmos.yaml b/examples/ai-claude-code/atmos.yaml new file mode 100644 index 0000000000..ca759af801 --- /dev/null +++ b/examples/ai-claude-code/atmos.yaml @@ -0,0 +1,94 @@ +# Atmos AI with Claude Code CLI Provider +# +# Use your Claude Pro/Max subscription instead of API tokens. +# +# Prerequisites: +# - Claude Code: brew install --cask claude-code && claude auth login +# - For aws-billing: atmos auth login (see auth section below) +# +# Quick start: +# atmos ai ask "What stacks do we have?" +# atmos ai ask "Search AWS docs for VPC peering" +# atmos ai ask "What did we spend on EC2 last month?" + +base_path: "." + +# Map uv to the aqua registry so uvx is available for MCP servers. +# Docs: https://atmos.tools/cli/configuration/toolchain +toolchain: + aliases: + uv: astral-sh/uv + +# MCP Servers — their tools become available to Claude Code via pass-through. +# Docs: https://atmos.tools/cli/configuration/mcp +# +# For more AWS MCP servers (security, IAM, CloudTrail, API), see: +# https://atmos.tools/cli/configuration/mcp#example-aws-mcp-servers +mcp: + servers: + # No credentials needed — searches public AWS documentation. + aws-docs: + command: uvx + args: ["awslabs.aws-documentation-mcp-server@latest"] + env: + FASTMCP_LOG_LEVEL: "ERROR" + description: "AWS Documentation — search and fetch AWS docs" + + # Requires AWS credentials via Atmos Auth (see auth section below). + aws-billing: + command: uvx + args: ["awslabs.billing-cost-management-mcp-server@latest"] + env: + AWS_REGION: "us-east-1" + FASTMCP_LOG_LEVEL: "ERROR" + description: "AWS Billing — billing summaries and payment history" + identity: "readonly" + +# Atmos Auth — automatic credential injection for MCP servers. +# Update these values to match your AWS organization, then run: atmos auth login +# Docs: https://atmos.tools/cli/configuration/auth +auth: + providers: + aws-sso: + kind: aws/iam-identity-center + start_url: "https://your-org.awsapps.com/start" # ← Change this + region: "us-east-1" + identities: + readonly: + kind: aws/permission-set + provider: aws-sso + default: true + principal: + permission_set: "ReadOnlyAccess" # ← Change this + account: + id: "123456789012" # ← Change this + +# AI Configuration +# Docs: https://atmos.tools/cli/configuration/ai +ai: + enabled: true + default_provider: "claude-code" + timeout_seconds: 300 + + providers: + claude-code: + max_turns: 10 + + tools: + enabled: true + + sessions: + enabled: true + path: ".atmos/sessions" + +# Minimal stacks config (required for Atmos to load). +stacks: + base_path: stacks + included_paths: + - "**/*.yaml" + excluded_paths: [] + name_template: "{{ .vars.stage }}" + +components: + terraform: + base_path: components/terraform diff --git a/examples/ai-claude-code/components/terraform/vpc/main.tf b/examples/ai-claude-code/components/terraform/vpc/main.tf new file mode 100644 index 0000000000..7de26c99db --- /dev/null +++ b/examples/ai-claude-code/components/terraform/vpc/main.tf @@ -0,0 +1,22 @@ +# VPC Component +# Mock Terraform component for demonstrating Atmos AI features. +# This does not create any real cloud resources. + +terraform { + required_version = ">= 1.0.0" + + required_providers { + null = { + source = "hashicorp/null" + version = ">= 3.0.0" + } + } +} + +resource "null_resource" "vpc" { + triggers = { + vpc_cidr = var.vpc_cidr + availability_zones = join(",", var.availability_zones) + environment = lookup(var.tags, "Environment", "unknown") + } +} diff --git a/examples/ai-claude-code/components/terraform/vpc/variables.tf b/examples/ai-claude-code/components/terraform/vpc/variables.tf new file mode 100644 index 0000000000..3818d599c0 --- /dev/null +++ b/examples/ai-claude-code/components/terraform/vpc/variables.tf @@ -0,0 +1,17 @@ +variable "vpc_cidr" { + type = string + description = "VPC CIDR block." + default = "10.0.0.0/16" +} + +variable "availability_zones" { + type = list(string) + description = "Availability zones for subnets." + default = ["us-east-2a", "us-east-2b"] +} + +variable "tags" { + type = map(string) + description = "Tags to apply to resources." + default = {} +} diff --git a/examples/ai-claude-code/stacks/example.yaml b/examples/ai-claude-code/stacks/example.yaml new file mode 100644 index 0000000000..8a807729a5 --- /dev/null +++ b/examples/ai-claude-code/stacks/example.yaml @@ -0,0 +1,15 @@ +# Example stack for the Claude Code AI example. +vars: + stage: dev + +components: + terraform: + vpc: + vars: + vpc_cidr: "10.0.0.0/16" + availability_zones: + - "us-east-2a" + - "us-east-2b" + tags: + Environment: dev + ManagedBy: atmos diff --git a/examples/ai/README.md b/examples/ai/README.md index 22e5b8f422..16332176d4 100644 --- a/examples/ai/README.md +++ b/examples/ai/README.md @@ -1,18 +1,17 @@ # Example: Atmos AI -Configure and use the Atmos AI Assistant with mock components — no cloud credentials required. +Use AI to chat with your infrastructure, inspect stacks and components, and analyze command output. Learn more in the [Atmos AI documentation](https://atmos.tools/ai). +> This example uses mock Terraform components — no cloud credentials required. + ## What You'll See -- [Multi-provider AI configuration](https://atmos.tools/cli/configuration/ai/providers) (OpenAI, Anthropic, Bedrock, Azure OpenAI, Gemini, Grok, Ollama) +- [Multi-provider AI configuration](https://atmos.tools/cli/configuration/ai/providers) (Anthropic, OpenAI, Gemini, Ollama) - [Interactive chat](https://atmos.tools/cli/commands/ai/chat) and single-question modes -- [Session management](https://atmos.tools/cli/configuration/ai/sessions) with persistent conversation history -- [Tool execution](https://atmos.tools/ai) — AI inspects stacks, components, and dependencies -- [Project instructions](https://atmos.tools/ai) via `ATMOS.md` for context-aware responses -- [Global `--ai` flag](https://atmos.tools/cli/global-flags) — AI-powered analysis of any command output -- [`--skill` flag](https://atmos.tools/cli/global-flags) — Domain-specific AI analysis with skills +- [Tool execution](https://atmos.tools/ai) — AI inspects stacks and components +- [Global `--ai` flag](https://atmos.tools/cli/global-flags) — AI analysis of any command output ## Try It @@ -22,38 +21,26 @@ cd examples/ai # Set up at least one provider API key export ANTHROPIC_API_KEY="your-api-key" -# Interactive chat -atmos ai chat - # Ask a single question atmos ai ask "What stacks and components do we have?" -# Structured output for CI/CD -atmos ai exec "validate stacks" --format json +# Interactive chat +atmos ai chat # AI-powered analysis of any command -atmos terraform plan vpc -s ue1-network --ai - -# With domain-specific skill -atmos terraform plan vpc -s ue1-prod --ai --skill atmos-terraform - -# Multiple skills (comma-separated) -atmos terraform plan vpc -s ue1-prod --ai --skill atmos-terraform,atmos-stacks +atmos terraform plan vpc -s dev --ai +``` -# Multiple skills (repeated flag) -atmos terraform plan vpc -s ue1-prod --ai --skill atmos-terraform --skill atmos-stacks +## Related Examples -# Via environment variables -ATMOS_AI=true ATMOS_SKILL=atmos-terraform,atmos-stacks atmos terraform plan vpc -s ue1-prod -``` +- **[AI with Claude Code CLI](../ai-claude-code/)** — Use your Claude Pro/Max subscription + instead of API tokens, with MCP server pass-through for AWS tools. ## Key Files -| File | Purpose | -|------|---------| -| `atmos.yaml` | Atmos configuration with AI provider settings | -| `ATMOS.md` | Project instructions the AI reads automatically | -| `stacks/deploy/` | Environment-specific stack files | -| `stacks/mixins/` | Shared region and stage configuration | +| File | Purpose | +|-------------------------|--------------------------------------------------| +| `atmos.yaml` | Atmos configuration with AI provider settings | +| `ATMOS.md` | Project instructions the AI reads automatically | +| `stacks/` | Stack configuration files | | `components/terraform/` | Mock Terraform components (VPC, Transit Gateway) | -| `workflows/ai-demo.yaml` | Workflow demonstrating AI usage | diff --git a/examples/ai/atmos.yaml b/examples/ai/atmos.yaml index d344ee4187..7e18df2e74 100644 --- a/examples/ai/atmos.yaml +++ b/examples/ai/atmos.yaml @@ -1,167 +1,70 @@ -# Atmos AI Assistant Example Configuration -# This demonstrates multi-provider AI configuration with sessions, tools, and custom skills. +# Atmos AI Assistant Example # -# Documentation: https://atmos.tools/ai +# Multi-provider AI configuration with sessions and tool execution. +# No cloud credentials required — uses mock Terraform components. +# +# Quick start: +# export ANTHROPIC_API_KEY="your-api-key" +# atmos ai ask "What stacks and components do we have?" +# atmos ai chat -# Base paths base_path: "." -# Components configuration -# Docs: https://atmos.tools/core-concepts/components components: terraform: base_path: "components/terraform" -# Stacks configuration -# Docs: https://atmos.tools/core-concepts/stacks stacks: base_path: "stacks" included_paths: - "deploy/**/*" - # Stack naming template - # Docs: https://atmos.tools/core-concepts/stacks/templates name_template: "{{ .vars.environment }}-{{ .vars.stage }}" -# Templates configuration -# Docs: https://atmos.tools/core-concepts/stacks/templates templates: settings: enabled: true - # Sprig functions: https://atmos.tools/core-concepts/stacks/templates/functions/sprig-functions sprig: enabled: true - # Gomplate functions: https://atmos.tools/core-concepts/stacks/templates/functions/gomplate-functions gomplate: enabled: true -# MCP Server Configuration (disabled by default) -# Docs: https://atmos.tools/cli/configuration/mcp -# Enable to allow external AI clients (Claude Desktop, VS Code, Cursor) to connect. -# mcp: -# enabled: true - -# AI Assistant Configuration +# AI Configuration # Docs: https://atmos.tools/cli/configuration/ai ai: - # Enable AI features (required) - # Docs: https://atmos.tools/cli/configuration/ai#quick-start enabled: true - - # Default provider for CLI commands - # Docs: https://atmos.tools/cli/configuration/ai/providers default_provider: "anthropic" - # Default skill to use - # Docs: https://atmos.tools/cli/configuration/ai/skills - # The --skill flag supports multiple skills: - # atmos terraform plan vpc -s prod --ai --skill atmos-terraform,atmos-stacks - # atmos terraform plan vpc -s prod --ai --skill atmos-terraform --skill atmos-stacks - # ATMOS_SKILL=atmos-terraform,atmos-stacks atmos terraform plan vpc -s prod --ai - default_skill: "general" - - # Context settings - control what information is sent to the AI - # Docs: https://atmos.tools/cli/configuration/ai#context-aware-ai-queries - send_context: false # Don't auto-send stack configs - prompt_on_send: true # Prompt before sending context - - # History management - limit conversation history size - # Docs: https://atmos.tools/cli/configuration/ai#conversation-history-management - max_history_messages: 50 # Keep last 50 messages - max_history_tokens: 8000 # Or limit by tokens - - # Timeouts for AI API calls - # Docs: https://atmos.tools/cli/configuration/ai#tool-execution-timeouts - timeout_seconds: 60 - - # Configure multiple AI providers - # Docs: https://atmos.tools/cli/configuration/ai/providers providers: - # Anthropic Claude - Best for complex reasoning - # Docs: https://atmos.tools/cli/configuration/ai/providers#anthropic + # Anthropic Claude — requires ANTHROPIC_API_KEY anthropic: model: "claude-sonnet-4-6" api_key: !env "ANTHROPIC_API_KEY" max_tokens: 4096 - # Token caching for cost savings (Anthropic-specific) - # Docs: https://atmos.tools/cli/configuration/ai/providers#prompt-caching - cache: - enabled: true - cache_system_prompt: true - cache_project_instructions: true - # OpenAI GPT - Strong general capabilities - # Docs: https://atmos.tools/cli/configuration/ai/providers#openai + # OpenAI GPT — requires OPENAI_API_KEY openai: model: "gpt-5.4" api_key: !env "OPENAI_API_KEY" max_tokens: 4096 - # Google Gemini - Fast with large context - # Docs: https://atmos.tools/cli/configuration/ai/providers#gemini - gemini: - model: "gemini-2.5-flash" - api_key: !env "GEMINI_API_KEY" - max_tokens: 8192 - - # Ollama - Local, private, no API costs - # Docs: https://atmos.tools/cli/configuration/ai/providers#ollama + # Ollama — local, no API key needed ollama: model: "llama4" base_url: "http://localhost:11434/v1" - # Session management - persist conversations across sessions - # Docs: https://atmos.tools/cli/configuration/ai/sessions sessions: enabled: true path: ".atmos/sessions" - max_sessions: 10 - # Auto-compact for long conversations - # Docs: https://atmos.tools/cli/configuration/ai/sessions#auto-compact-extended-conversations - auto_compact: - enabled: true - trigger_threshold: 0.75 # Compact at 75% of max_history_messages - compact_ratio: 0.4 # Compact 40% of old messages - preserve_recent: 10 # Always keep last 10 messages - use_ai_summary: true # Use AI for intelligent summaries - show_summary_markers: false - - # Tool execution settings - control what the AI can do - # Docs: https://atmos.tools/cli/configuration/ai/tools tools: enabled: true - require_confirmation: true # Prompt before tool execution - - # Tools that execute without confirmation - # Docs: https://atmos.tools/cli/configuration/ai/tools#allowed-tools + require_confirmation: true allowed_tools: - atmos_describe_component - atmos_list_stacks - atmos_validate_stacks - read_stack_file - # Tools that always require confirmation - # Docs: https://atmos.tools/cli/configuration/ai/tools#restricted-tools - restricted_tools: - - edit_file - - execute_atmos_command - - # Tools that are completely blocked - # Docs: https://atmos.tools/cli/configuration/ai/tools#blocked-tools - blocked_tools: - - execute_bash_command - - # Skip all confirmations (DANGEROUS - only for trusted environments) - # Docs: https://atmos.tools/cli/configuration/ai/tools#yolo-mode--dangerous - yolo_mode: false - - # Project instructions - persistent context across sessions via ATMOS.md - # Docs: https://atmos.tools/cli/configuration/ai/instructions instructions: enabled: true file: "ATMOS.md" - -# Workflows -# Docs: https://atmos.tools/core-concepts/workflows -workflows: - base_path: "workflows" diff --git a/examples/ai/workflows/ai-demo.yaml b/examples/ai/workflows/ai-demo.yaml deleted file mode 100644 index 913ba82f1d..0000000000 --- a/examples/ai/workflows/ai-demo.yaml +++ /dev/null @@ -1,48 +0,0 @@ -# AI Demo Workflow -# Demonstrates using Atmos AI features in workflows. - -name: AI Demo Workflow -description: Demonstrates Atmos AI features with multi-region infrastructure - -workflows: - # List all available stacks - list-stacks: - description: List all stacks in the project - steps: - - command: list stacks - - # Describe network components - describe-ue1-network: - description: Describe the VPC in the ue1-network stack - steps: - - command: describe component vpc -s ue1-network - - describe-uw2-network: - description: Describe the VPC in the uw2-network stack - steps: - - command: describe component vpc -s uw2-network - - # Describe production components - describe-ue1-prod: - description: Describe the VPC in the ue1-prod stack - steps: - - command: describe component vpc -s ue1-prod - - # Compare network stacks across regions - compare-network-regions: - description: Compare network VPC configurations across regions - steps: - - command: describe component vpc -s ue1-network - - command: describe component vpc -s uw2-network - - # Validate all stacks - validate-all: - description: Validate all stack configurations - steps: - - command: validate stacks - - # AI-assisted analysis (if AI is enabled) - ai-analyze: - description: Use AI to analyze the infrastructure - steps: - - command: ai ask "Analyze the VPC and Transit Gateway configuration across all stacks. What are the component dependencies and how does cross-region connectivity work?" diff --git a/examples/mcp/README.md b/examples/mcp/README.md index a146f6ec6d..41670954f2 100644 --- a/examples/mcp/README.md +++ b/examples/mcp/README.md @@ -1,572 +1,105 @@ -# Atmos MCP Integrations Example +# Example: MCP Server Integrations -This example demonstrates how to connect Atmos to external MCP (Model Context Protocol) servers from the AWS ecosystem. -Instead of reimplementing cloud provider functionality, Atmos installs and orchestrates existing MCP servers — their -tools become available across the Atmos AI surface: +Connect Atmos to external MCP servers from the AWS ecosystem. Their tools become available +in `atmos ai chat`, `atmos ai ask`, and `atmos ai exec` — no custom integration code needed. -- **`atmos ai chat`** — interactive conversations with MCP tools -- **`atmos ai ask`** — one-shot questions using MCP tools -- **`atmos ai exec`** — execute AI-driven tasks with MCP tools -- **`atmos mcp tools `** — directly list tools from any MCP server -- **`atmos mcp test `** — verify server connectivity and available tools +Learn more in the [MCP Configuration documentation](https://atmos.tools/cli/configuration/mcp). -## What's Included +## MCP Servers Included -The following AWS MCP servers are pre-configured in `atmos.yaml`: - -### Cost Analysis & FinOps - -| Server | What It Does | Credentials | -|----------------------|---------------------------------------------------|---------------------------| -| **aws-billing** | Billing summaries, payment history, cost tags | Yes — `ce:*`, `billing:*` | -| **aws-pricing** | On-demand/reserved pricing, cost comparisons | Yes — `pricing:*` (free) | - -### Security & Compliance - -| Server | What It Does | Credentials | -|--------------------|-------------------------------------------------|---------------------------------| -| **aws-security** | Well-Architected Security Pillar assessment | Yes — security services read | -| **aws-iam** | IAM role/policy analysis, permission boundaries | Yes — `iam:Get*`, `iam:List*` | -| **aws-cloudtrail** | CloudTrail event history, API call auditing | Yes — `cloudtrail:LookupEvents` | - -### General - -| Server | What It Does | Credentials | -|-------------------|----------------------------------------------|-------------| -| **aws-api** | Direct AWS CLI access with security controls | Yes | -| **aws-docs** | Search and fetch AWS documentation | No | -| **aws-knowledge** | Managed AWS knowledge base (remote service) | No | - -All configured MCP servers are available across all Atmos AI commands — `atmos ai ask`, -`atmos ai chat`, and `atmos ai exec`. +| Server | Description | Credentials | +|--------------------|---------------------------------------|-------------| +| **aws-docs** | Search and fetch AWS documentation | No | +| **aws-knowledge** | Managed AWS knowledge base (remote) | No | +| **aws-billing** | Billing summaries and payment history | Yes | +| **aws-pricing** | Real-time pricing and cost analysis | Yes | +| **aws-security** | Well-Architected security posture | Yes | +| **aws-iam** | IAM role/policy analysis | Yes | +| **aws-cloudtrail** | Event history and API auditing | Yes | +| **aws-api** | Direct AWS CLI access (read-only) | Yes | ## Prerequisites -1. **Python 3.10+** — the `uv` package manager is auto-installed by the Atmos toolchain - (configured in `atmos.yaml`), so you don't need to install it manually. +1. **Python 3.10+** — `uvx` is auto-installed by the [Atmos Toolchain](https://atmos.tools/cli/configuration/toolchain). -2. **Atmos Auth** — all servers that need AWS credentials use `identity: "readonly"`. - Update the auth section in `atmos.yaml` with your SSO start URL, permission set, and - account ID, then run: +2. **Atmos Auth** — servers that need credentials use `identity: "readonly"`. + Update the `auth` section in `atmos.yaml` with your SSO start URL, permission set, + and account ID, then run: ```bash atmos auth login ``` - That's it — Atmos authenticates once and injects credentials into every MCP server - automatically. No manual `aws configure`, no environment variables to export, no credential files to manage. + See the [Atmos Auth documentation](https://atmos.tools/cli/configuration/auth) for setup details. -## Quick Start +3. **AI provider** — configure at least one [AI provider](https://atmos.tools/cli/configuration/ai/providers). -```bash -# Navigate to this example +## Try It + +```shell cd examples/mcp -# List all configured MCP servers +# List configured servers atmos mcp list -# Test connectivity to a server (starts it, checks tools, pings) +# Test a server (no credentials needed) atmos mcp test aws-docs -# List tools from a specific server +# List tools from a server atmos mcp tools aws-docs -# Use MCP tools in AI chat (requires AI provider configured) -atmos ai chat -# Then ask: "Search AWS docs for EKS best practices" -``` - -## CLI Commands - -### Managing Servers - -```bash -# Start the Atmos MCP server (for IDE/Claude Code integration) -atmos mcp start - -# List all configured servers -atmos mcp list - -# Show live status (starts each server and checks health) -atmos mcp status - -# Test a specific server -atmos mcp test aws-pricing +# Ask a question (AI auto-routes to the right server) +atmos ai ask "How do I configure S3 bucket lifecycle rules?" -# List tools from a server -atmos mcp tools aws-security +# Billing query (requires Atmos Auth) +atmos ai ask "What did we spend on EC2 last month?" -# Restart a server -atmos mcp restart aws-api +# Security audit +atmos ai ask "Is GuardDuty enabled in all regions?" -# Generate .mcp.json for Claude Code / Cursor / IDE -atmos mcp export +# Manual server selection (skip auto-routing) +atmos ai ask --mcp aws-iam "List all IAM roles with admin access" ``` -### Smart Server Routing +## Smart Server Routing -When multiple MCP servers are configured, Atmos automatically selects only the servers -relevant to your question using a lightweight routing call to your configured AI provider. -This keeps tool payloads small and responses fast, even with dozens of servers configured: +When multiple servers are configured, Atmos automatically selects only the relevant ones: ```text $ atmos ai ask "List all IAM roles with admin access" ℹ MCP routing selected 1 of 8 servers: aws-iam ℹ MCP server "aws-iam" started (29 tools) -ℹ Registered 29 tools from 1 MCP server(s) -ℹ AI tools initialized: 39 -``` - -Use `--mcp` to override and specify servers directly: - -```bash -# Specify one server -atmos ai ask --mcp aws-iam "List all admin roles" - -# Specify multiple servers (comma-separated) -atmos ai ask --mcp aws-iam,aws-cloudtrail "Who accessed the admin role last week?" - -# Specify multiple servers (repeated flag) -atmos ai ask --mcp aws-iam --mcp aws-cloudtrail "Who accessed the admin role?" - -# Works with all AI commands -atmos ai chat --mcp aws-billing -atmos ai exec --mcp aws-security,aws-iam "audit our security posture" - -# Environment variable -ATMOS_AI_MCP=aws-billing atmos ai ask "What did we spend last month?" -``` - -Routing is skipped when only one server is configured or when `--mcp` is provided. -In `chat` mode, routing is skipped because the question isn't known upfront — use -`--mcp` to filter servers in chat. - -### How to Know MCP Tools Are Active - -When you run any AI command, Atmos logs which MCP servers started and how many tools were discovered: - -```text -ℹ MCP routing selected 2 of 8 servers: aws-docs, aws-pricing -ℹ MCP server "aws-docs" started (4 tools) -ℹ MCP server "aws-pricing" started (7 tools) -ℹ Registered 11 tools from 2 MCP server(s) -ℹ AI tools initialized: 26 total -``` - -After the AI responds, a "Tool Executions" section shows which tools were actually called: - -```text ---- -## Tool Executions (2) -1. ✅ aws-docs → aws.search_documentation (234ms) -2. ✅ aws-pricing → get_pricing (456ms) -``` - -Tool usage is not inferred — the AI provider explicitly declares which tools it wants to call -via the API protocol (`tool_use` stop reason with a `tool_calls` array). Atmos executes the -requested tools, sends results back to the AI for the final answer, and records every call. -If no "Tool Executions" section appears, the AI genuinely chose not to use any tools for -that question. - -### Using MCP Tools in AI Chat - -Interactive chat sessions with full access to all MCP server tools: - -```bash -# Start an AI chat session — MCP tools are automatically available -atmos ai chat - -# Example prompts: -# "What's the current pricing for m7i.xlarge instances in us-east-1?" -# "Check the security posture of our production account" -# "Search AWS docs for how to set up VPC peering" -# "List all EC2 instances in us-west-2" -# "What AWS services are available in the af-south-1 region?" -``` - -### Using MCP Tools with `atmos ai ask` - -One-shot questions — get an answer and exit. No interactive session: - -```bash -# Cost analysis (uses aws-pricing) -atmos ai ask "What's the on-demand price for m7i.xlarge in us-east-1?" - -# Spend breakdown (uses aws-billing) -atmos ai ask "What did we spend on EC2 last month?" - -# Billing history (uses aws-billing) -atmos ai ask "Show our billing summary for the past 3 months" - -# Security posture (uses aws-security) -atmos ai ask "Is GuardDuty enabled in all regions?" - -# IAM analysis (uses aws-iam) -atmos ai ask "List all IAM roles with admin access" - -# Audit trail (uses aws-cloudtrail) -atmos ai ask "Show recent API calls from the root account" - -# Documentation (uses aws-docs, no credentials needed) -atmos ai ask "How do I configure S3 bucket lifecycle rules?" - -# AWS knowledge (uses aws-knowledge, no credentials needed) -atmos ai ask "Which AWS regions support Amazon Bedrock?" -``` - -### Using MCP Tools with `atmos ai exec` - -Execute multi-step AI tasks with tool access: - -```bash -# Generate a security report -atmos ai exec "Check security services, storage encryption, and network \ - security in us-east-1, then summarize the findings as a markdown report" - -# Research pricing for a migration -atmos ai exec "Look up pricing for m7i.xlarge, r7i.2xlarge, and c7i.xlarge \ - in us-east-1 and us-west-2, then create a comparison table" - -# Documentation research -atmos ai exec "Find AWS best practices for EKS cluster security, \ - then list the top 5 recommendations with links" -``` - -### Directly Exploring MCP Servers - -Use `atmos mcp` commands to explore what each server offers without AI: - -```bash -# List all tools from the AWS API server -atmos mcp tools aws-api - -# List tools from the security server -atmos mcp tools aws-security -# Example output: -# TOOL DESCRIPTION -# CheckSecurityServices Verify security services are enabled -# GetSecurityFindings Retrieve security findings with severity filtering -# CheckStorageEncryption Check encryption on S3, EBS, RDS, DynamoDB, EFS -# CheckNetworkSecurity Check TLS/HTTPS on ELB, VPC, API Gateway -# ListServicesInRegion List active AWS services in a region - -# List tools from the pricing server -atmos mcp tools aws-pricing - -# Test all servers at once -atmos mcp status -# Example output: -# NAME STATUS TOOLS DESCRIPTION -# aws-api running 2 AWS API — direct AWS CLI access with security controls -# aws-billing running 25 AWS Billing — billing summaries and payment history -# aws-cloudtrail running 5 AWS CloudTrail — event history and API call auditing -# aws-docs running 4 AWS Documentation — search and fetch AWS docs -# aws-iam running 29 AWS IAM — role/policy analysis and access patterns -# aws-knowledge running 6 AWS Knowledge — managed AWS knowledge base (remote) -# aws-pricing running 9 AWS Pricing — real-time pricing and cost analysis -# aws-security running 6 AWS Security — Well-Architected security posture assessment -``` - -## Configuration Reference - -### atmos.yaml Structure - -```yaml -mcp: - servers: - : - # Standard MCP fields (compatible with Claude Code / Codex / Gemini CLI) - command: "uvx" # Command to run - args: [ "package-name@latest" ] # Arguments - env: # Environment variables - AWS_REGION: "us-east-1" - - # Atmos extensions - description: "Human-readable description" # Shown in `atmos mcp list` - identity: "my-identity" # Atmos Auth identity (from the auth section) - auto_start: false # Start automatically - timeout: "30s" # Connection timeout ``` -### YAML Functions in Environment Variables +Use `--mcp` to override routing and specify servers directly. +See the [MCP documentation](https://atmos.tools/cli/configuration/mcp#smart-routing) for details. -Atmos YAML functions work in `env` values: +## IDE Integration -```yaml -mcp: - servers: - my-server: - command: uvx - args: [ "my-mcp-server@latest" ] - env: - # Read from OS environment - AWS_REGION: !env AWS_DEFAULT_REGION - AWS_PROFILE: !env AWS_PROFILE - - # Execute a command (e.g., read a secret) - API_KEY: !exec "vault kv get -field=key secret/mcp" - - # Git repository root - PROJECT_ROOT: !repo-root - - # Current working directory - WORK_DIR: !cwd -``` - -## Atmos Auth Integration - -This example uses Atmos Auth to automatically inject AWS credentials into every MCP server -that needs them. Instead of manually running `aws configure` or exporting environment -variables for each server, you configure auth once and every server with `identity` -gets credentials automatically. - -### Setup - -1. Edit the `auth` section in `atmos.yaml` — update `start_url`, `permission_set`, and - `account.id` to match your AWS organization -2. Run `atmos auth login` to authenticate -3. All MCP servers with `identity: "readonly"` will get credentials injected - -### How it works - -When `identity` is set on a server, Atmos: - -1. Authenticates through the identity chain (SSO → role assumption) -2. Writes isolated credential files to `~/.aws/atmos//` -3. Sets `AWS_SHARED_CREDENTIALS_FILE`, `AWS_CONFIG_FILE`, `AWS_PROFILE` on the subprocess -4. The MCP server's AWS SDK picks up credentials automatically - -No credential files to manage, no environment variables to set, no expiration headaches. -Run `atmos auth login` once and all servers work. - -## Atmos Toolchain Integration - -Map `uv` to the aqua registry so the toolchain can resolve it, then install: - -```yaml -# atmos.yaml -toolchain: - aliases: - uv: astral-sh/uv -``` - -```bash -atmos toolchain install astral-sh/uv@0.7.12 -``` - -Atmos resolves `uvx` from the toolchain PATH before starting any MCP server. - -## Server Details - -### aws-api — Direct AWS CLI Access - -The most powerful server — enables AI to run any AWS CLI command. Use with caution. - -**Safety controls:** - -- `READ_OPERATIONS_ONLY=true` — Only allow read operations (default in this example) -- `REQUIRE_MUTATION_CONSENT=true` — Require explicit approval before mutations - -**IAM:** `ReadOnlyAccess` for read-only mode, `AdministratorAccess` for full access. - -### aws-security — Security Posture Assessment - -Checks your AWS environment against the Well-Architected Security Pillar: - -- Security services enabled (GuardDuty, Inspector, SecurityHub, Access Analyzer) -- Storage encryption (S3, EBS, RDS, DynamoDB, EFS) -- Network security (ELB HTTPS, API Gateway WAF, CloudFront TLS) - -**IAM:** Read-only access to security services + storage/network resource metadata. - -### aws-pricing — Cost Analysis - -Real-time pricing lookups and cost comparisons. All Pricing API calls are free. - -**IAM:** `pricing:*` (all calls are free of charge). - -### aws-docs — Documentation Search - -Searches and fetches AWS documentation in markdown format. **No credentials needed** — this is -the easiest server to try first since it accesses public AWS documentation endpoints. - -**Try it now:** - -```bash -# Verify the server works -atmos mcp test aws-docs - -# See what tools are available -atmos mcp tools aws-docs - -# Ask a documentation question -atmos ai ask "How do I configure S3 bucket lifecycle rules?" - -# Research a topic -atmos ai ask "What are the VPC quotas and limits?" - -# Interactive session for deeper research -atmos ai chat -# Then: "Search AWS docs for EKS pod identity best practices" -# Then: "What's the difference between IRSA and EKS Pod Identity?" -``` - -The AI calls the MCP server's tools (like `search_documentation`, `get_documentation`) -behind the scenes and renders the markdown response directly in the terminal. You can also -see the raw tool list without AI: - -```bash -atmos mcp tools aws-docs -``` - -**Configuration:** No `env` variables needed. Optionally set `AWS_DOCUMENTATION_PARTITION` -to `"aws-cn"` for China partition docs. - -### aws-billing — Billing & Cost Management - -Access to billing summaries, payment history, and cost allocation tags. - -**IAM:** `ce:*`, `billing:*` - -### aws-iam — IAM Analysis - -Analyze IAM roles, policies, permission boundaries, and access patterns. Read-only — no changes to IAM. - -**IAM:** `iam:Get*`, `iam:List*` - -### aws-cloudtrail — Event History & Auditing - -Query CloudTrail event history for API call auditing, security investigations, and compliance reporting. - -**IAM:** `cloudtrail:LookupEvents` - -### aws-knowledge — Managed Knowledge Base - -Remote MCP server operated by AWS. Provides documentation, code samples, and regional availability information. No -credentials or local installation needed. - -## IDE Integration (Claude Code / Cursor) - -Generate a `.mcp.json` file from your `atmos.yaml` configuration for use with Claude Code, -Cursor, or any MCP-compatible IDE: +Generate `.mcp.json` for Claude Code, Cursor, or any MCP-compatible IDE: ```bash atmos mcp export -``` - -This creates a `.mcp.json` file where: - -- Servers **without** `identity` use their command directly -- Servers **with** `identity` are wrapped with `atmos auth exec -i --` for - automatic credential injection - -Example generated output: - -```json -{ - "mcpServers": { - "aws-docs": { - "command": "uvx", - "args": ["awslabs.aws-documentation-mcp-server@latest"], - "env": { "FASTMCP_LOG_LEVEL": "ERROR" } - }, - "aws-security": { - "command": "atmos", - "args": ["auth", "exec", "-i", "readonly", "--", - "uvx", "awslabs.well-architected-security-mcp-server@latest"], - "env": { "AWS_REGION": "us-east-1" } - } - } -} -``` - -Use `--output` to specify a different file path: - -```bash atmos mcp export --output .cursor/mcp.json ``` -## See It in Action - -> **Note:** All outputs below are from real AWS accounts. Account IDs, role suffixes, -> and internal identifiers have been redacted (replaced with `...`). Cost figures -> represent an example of real-world spending. - -### List configured servers - -```text -$ atmos mcp list - NAME STATUS DESCRIPTION -───────────────────────────────────────────────────────────────────────────────────────── - aws-api stopped AWS API — direct AWS CLI access with security controls - aws-billing stopped AWS Billing — billing summaries and payment history - aws-cloudtrail stopped AWS CloudTrail — event history and API call auditing - aws-docs stopped AWS Documentation — search and fetch AWS docs - aws-iam stopped AWS IAM — role/policy analysis and access patterns - aws-knowledge stopped AWS Knowledge — managed AWS knowledge base (remote) - aws-pricing stopped AWS Pricing — real-time pricing and cost analysis - aws-security stopped AWS Security — Well-Architected security posture assessment -``` - -### Explore tools from a server +Servers with `identity` are wrapped with `atmos auth exec` for automatic credential injection. -```text -$ atmos mcp tools aws-api - TOOL DESCRIPTION -─────────────────────────────────────────────────────────────────────────────────────────── - suggest_aws_commands Suggest AWS CLI commands based on a natural language query. - call_aws Execute AWS CLI commands with validation and proper error handling. -``` - -```text -$ atmos mcp tools aws-security - TOOL DESCRIPTION -────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - CheckSecurityServices Verify if selected AWS security services are enabled in the specified region and account. - GetSecurityFindings Retrieve security findings from AWS security services. - GetStoredSecurityContext Retrieve security services data that was stored in context from a previous CheckSecurityServices call. - CheckStorageEncryption Check if AWS storage resources have encryption enabled. - ListServicesInRegion List all AWS services being used in a specific region. - CheckNetworkSecurity Check if AWS network resources are configured for secure data-in-transit. -``` - -### Test server connectivity - -```text -$ atmos mcp test aws-docs -✓ Server started successfully -✓ Initialization handshake complete -✓ 4 tools available -✓ Server responds to ping -``` +## See It in Action -```text -$ atmos mcp test aws-security -✓ Server started successfully -✓ Initialization handshake complete -✓ 6 tools available -✓ Server responds to ping -``` +> Outputs below are from an AWS account. Identifiers have been redacted. -### Ask AI with MCP tools +### Documentation Search ```text $ atmos ai ask "How do I configure S3 bucket lifecycle rules?" ℹ MCP routing selected 1 of 8 servers: aws-knowledge -ℹ MCP server "aws-knowledge" started (6 tools) -ℹ Registered 6 tools from 1 MCP server(s) ℹ AI tools initialized: 16 👽 Thinking... Configuring S3 Bucket Lifecycle Rules S3 lifecycle rules automate object management by transitioning objects between - storage classes, archiving, or expiring them. Here's a comprehensive guide: - - -------- - - ## 📋 Lifecycle Rule Structure - - Each rule contains 3 key components: + storage classes, archiving, or expiring them. Component │ Description ───────────┼──────────────────────────────────────────────────────── @@ -576,42 +109,20 @@ $ atmos ai ask "How do I configure S3 bucket lifecycle rules?" │ A bucket can have up to 1,000 rules per lifecycle configuration. - -------- - - ## 🔧 Available Actions - - Action │ Description - ──────────────────────────────────┼──────────────────────────────────────────────────────── - Transition │ Move objects to a different storage class after N days - Expiration │ Permanently delete current object versions - NoncurrentVersionTransition │ Transition old versions in versioned buckets - NoncurrentVersionExpiration │ Delete noncurrent versions in versioned buckets - AbortIncompleteMultipartUpload │ Cancel incomplete uploads after N days - ExpiredObjectDeleteMarker │ Remove expired delete markers - --- - ## Tool Executions (1) - 1. ✅ aws-knowledge → aws.search_documentation (2874ms) ``` -### Ask AI about billing with MCP tools +### Billing Summary ```text $ atmos ai ask "Show our billing summary for the past 2 months" + ℹ MCP routing selected 1 of 8 servers: aws-billing -ℹ MCP server "aws-billing" started (25 tools) -ℹ Registered 25 tools from 1 MCP server(s) ℹ AI tools initialized: 35 👽 Thinking... - I'll retrieve your billing summary for the past 2 months right away! - - Here's your billing summary for the past 2 months (February–March 2026): - - -------- - ## 📊 AWS Billing Summary — February & March 2026 ### 💰 Monthly Totals @@ -623,22 +134,7 @@ $ atmos ai ask "Show our billing summary for the past 2 months" EC2 - Other │ $88.12 │ $123.39 │ ▲ $35.27 AWS Key Management Service │ $50.94 │ $47.98 │ ▼ $2.96 Amazon Redshift │ $50.87 │ $0.30 │ ▼ $50.57 - Tax │ $47.80 │ $40.98 │ ▼ $6.82 - Amazon Relational Database Service │ $43.99 │ $0.11 │ ▼ $43.88 - AWS App Runner │ $28.42 │ $27.55 │ ▼ $0.87 - AWS WAF │ $18.00 │ $18.00 │ — - Amazon EC2 Compute │ $5.03 │ $15.30 │ ▲ $10.27 - AmazonCloudWatch │ $10.08 │ $10.45 │ ▲ $0.37 - Amazon Simple Storage Service │ $10.08 │ $9.35 │ ▼ $0.73 - Amazon Route 53 │ $5.61 │ $5.68 │ ▲ $0.07 - Amazon Elastic File System │ $3.65 │ — │ ▼ $3.65 - Amazon EC2 Container Registry (ECR) │ $2.69 │ $2.75 │ ▲ $0.06 - Amazon EKS │ — │ $1.86 │ ▲ $1.86 - Amazon Registrar │ — │ $14.00 │ ▲ $14.00 - AWS Cost Explorer │ $2.07 │ $1.91 │ ▼ $0.16 - Other services │ ~$1.65 │ ~$1.41 │ ▼ $0.24 - - -------- + ... │ │ │ ### 📈 Grand Totals @@ -648,37 +144,17 @@ $ atmos ai ask "Show our billing summary for the past 2 months" Mar 2026 │ ~$745.26 Difference │ ▼ $36.90 (-4.7%) - -------- - - ### 🔍 Key Observations - - 1. Amazon VPC is the largest cost at ~$261–$310/month (NAT Gateway data - transfer is typically the main driver). Costs dropped notably in March. - 2. Redshift & RDS dropped sharply in March ($50.87 → $0.30 and - $43.99 → $0.11), suggesting those resources were stopped or deleted. - 3. EC2 - Other increased by $35 in March — worth investigating if this - correlates with EBS, data transfer, or Elastic IP costs. - 4. Amazon EC2 Compute tripled in March ($5.03 → $15.30), possibly linked - to the appearance of EKS ($1.86), indicating new compute workloads. - 5. Amazon Registrar appeared as a new $14 charge in March (domain renewal). - - │ ⚠️ Note: Credits and refunds are excluded from these figures. - - -------- - + --- ## Tool Executions (1) - 1. ✅ aws-billing → cost-explorer (381ms) ``` -### Check security posture with MCP tools +### Security Audit ```text $ atmos ai ask "Is GuardDuty enabled in all regions?" + ℹ MCP routing selected 2 of 8 servers: aws-api, aws-security -ℹ MCP server "aws-api" started (2 tools) -ℹ MCP server "aws-security" started (6 tools) -ℹ Registered 8 tools from 2 MCP server(s) ℹ AI tools initialized: 18 👽 Thinking... @@ -694,114 +170,74 @@ $ atmos ai ask "Is GuardDuty enabled in all regions?" ### ❌ Not Enabled (33 regions) - Region │ Region │ Region - ────────────────┼────────────────┼──────────────── - ap-south-1 │ ap-south-2 │ ap-southeast-1 - ap-southeast-2 │ ap-southeast-3 │ ap-southeast-4 - ap-northeast-1 │ ap-northeast-2 │ ap-northeast-3 - ap-east-1 │ ca-central-1 │ ca-west-1 - eu-central-1 │ eu-central-2 │ eu-north-1 - eu-south-1 │ eu-south-2 │ eu-west-1 - eu-west-2 │ eu-west-3 │ me-central-1 - me-south-1 │ il-central-1 │ af-south-1 - sa-east-1 │ us-east-1 │ us-west-1 - us-west-2 │ mx-central-1 │ ... - - -------- + ap-south-1, ap-southeast-1, eu-central-1, eu-west-1, us-east-1, + us-west-2, ... (28 more) ### 🔒 Recommendations + 1. Enable GuardDuty in all active regions + 2. Use delegated administrator via AWS Organizations + 3. Consider AWS Security Hub to aggregate findings + 4. Prioritize us-east-1, us-west-2, eu-west-1 - 1. Enable GuardDuty in all active regions — even regions where you don't - actively deploy can be targeted by attackers. - 2. Use a GuardDuty delegated administrator (via AWS Organizations) to - centrally enable and manage across all accounts and regions. - 3. Consider AWS Security Hub to aggregate findings from all regions. - 4. Prioritize us-east-1, us-west-2, eu-west-1 immediately. - - -------- - + --- ## Tool Executions (4) - 1. ✅ aws-api → call_aws (400ms) 2. ✅ aws-api → call_aws (14ms) 3. ✅ aws-api → call_aws (7ms) 4. ✅ aws-api → call_aws (9450ms) ``` -### Audit IAM roles with smart routing +### IAM Audit ```text $ atmos ai ask "List all IAM roles with admin access" + ℹ MCP routing selected 1 of 8 servers: aws-iam -ℹ MCP server "aws-iam" started (29 tools) -ℹ Registered 29 tools from 1 MCP server(s) ℹ AI tools initialized: 39 👽 Thinking... - I'll help you find all IAM roles with admin access. Let me fetch the - list of IAM roles and policies simultaneously. - - -------- - ## 🔐 IAM Roles with Admin Access - ### 1. ✅ Direct AdministratorAccess Policy (4 attachments) - - Role Name │ Description │ Trust Principal - ──────────────────────────────────────────────────┼────────────────────────────────────────────────┼─────────────────────────── - AWSReservedSSO_AdministratorAccess_... │ Allow Full Administrator access to the account │ AWS SSO (SAML Federation) - AWSReservedSSO_RootAccess_... │ Centralized root access to member accounts │ AWS SSO (SAML Federation) - AWSReservedSSO_TerraformApplyAccess_... │ Full Terraform state and account access │ AWS SSO (SAML Federation) - AWSReservedSSO_TerraformApplyAccess-Core_... │ Full Terraform access (core backend) │ AWS SSO (SAML Federation) + ### Direct AdministratorAccess Policy (4 attachments) - -------- + Role Name │ Trust Principal + ──────────────────────────────────────────────────┼─────────────────────────── + AWSReservedSSO_AdministratorAccess_... │ AWS SSO (SAML Federation) + AWSReservedSSO_RootAccess_... │ AWS SSO (SAML Federation) + AWSReservedSSO_TerraformApplyAccess_... │ AWS SSO (SAML Federation) + AWSReservedSSO_TerraformApplyAccess-Core_... │ AWS SSO (SAML Federation) - ## 📋 Summary + ### 📋 Summary Category │ Count ───────────────────────────────────────────┼────────── Full Admin (AdministratorAccess policy) │ 4 roles Broad Terraform/State access (elevated) │ 4 roles AWS Service-Linked Roles (scoped) │ 13 roles - Other application roles (Lambda, etc.) │ 4 roles - - -------- - - ### 🛡️ Security Recommendations - - 1. Review SSO assignments for AdministratorAccess and RootAccess roles. - 2. Audit TerraformApplyAccess roles — ensure MFA/session policies are enforced. - 3. Monitor tfstate roles — cross-account trust across 14 accounts. - 4. Enable CloudTrail for AssumeRole calls on high-privilege roles. - - -------- + --- ## Tool Executions (2) - 1. ✅ aws-iam → list_roles (314ms) 2. ✅ aws-iam → list_policies (174ms) ``` -### Check status of all servers +## Related Examples -```text -$ atmos mcp status - NAME STATUS TOOLS DESCRIPTION -───────────────────────────────────────────────────────────────────────────────────────── - aws-api running 2 AWS API — direct AWS CLI access with security controls - aws-billing running 25 AWS Billing — billing summaries and payment history - aws-cloudtrail running 5 AWS CloudTrail — event history and API call auditing - aws-docs running 4 AWS Documentation — search and fetch AWS docs - aws-iam running 29 AWS IAM — role/policy analysis and access patterns - aws-knowledge running 6 AWS Knowledge — managed AWS knowledge base (remote) - aws-pricing running 9 AWS Pricing — real-time pricing and cost analysis - aws-security running 6 AWS Security — Well-Architected security posture assessment -``` +- **[AI with Claude Code CLI](../ai-claude-code/)** — Use your Claude subscription + instead of API tokens. Claude Code manages MCP servers via pass-through. +- **[AI with API Providers](../ai/)** — Multi-provider AI configuration with sessions and tools. + +## Key Files + +| File | Purpose | +|-------------------------|-------------------------------------------------------------| +| `atmos.yaml` | MCP servers, auth, AI provider, and toolchain configuration | +| `stacks/` | Stack configuration files | +| `components/terraform/` | Mock Terraform components | ## Learn More -- [Atmos MCP Documentation](https://atmos.tools/cli/commands/mcp) +- [MCP Configuration](https://atmos.tools/cli/configuration/mcp) - [AWS MCP Servers](https://github.com/awslabs/mcp) -- [MCP Protocol Specification](https://modelcontextprotocol.io/) - [Atmos AI Documentation](https://atmos.tools/ai) -- [Atmos Auth Documentation](https://atmos.tools/cli/commands/auth) +- [Atmos Auth Documentation](https://atmos.tools/cli/configuration/auth) diff --git a/go.mod b/go.mod index 2c54c39de4..0a5f19d07d 100644 --- a/go.mod +++ b/go.mod @@ -31,8 +31,8 @@ require ( github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4 github.com/aws/aws-sdk-go-v2/service/ecr v1.56.2 github.com/aws/aws-sdk-go-v2/service/eks v1.81.2 - github.com/aws/aws-sdk-go-v2/service/organizations v1.50.6 - github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 + github.com/aws/aws-sdk-go-v2/service/organizations v1.51.0 + github.com/aws/aws-sdk-go-v2/service/s3 v1.98.0 github.com/aws/aws-sdk-go-v2/service/ssm v1.68.4 github.com/aws/aws-sdk-go-v2/service/sso v1.30.14 github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.18 @@ -56,7 +56,7 @@ require ( github.com/fatih/color v1.19.0 github.com/gabriel-vasile/mimetype v1.4.13 github.com/getsentry/sentry-go v0.44.1 - github.com/go-git/go-git/v5 v5.17.0 + github.com/go-git/go-git/v5 v5.17.2 github.com/go-viper/mapstructure/v2 v2.5.0 github.com/gobwas/glob v0.2.3 github.com/goccy/go-yaml v1.19.2 @@ -70,7 +70,7 @@ require ( github.com/hairyhenderson/gomplate/v3 v3.11.8 github.com/hairyhenderson/gomplate/v4 v4.3.3 github.com/hashicorp/go-getter v1.8.5 - github.com/hashicorp/go-version v1.8.0 + github.com/hashicorp/go-version v1.9.0 github.com/hashicorp/hcl v1.0.1-vault-7 github.com/hashicorp/hcl/v2 v2.24.0 github.com/hashicorp/terraform-config-inspect v0.0.0-20260224005459-813a97530220 @@ -92,7 +92,7 @@ require ( github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 github.com/muesli/reflow v0.3.0 github.com/muesli/termenv v0.16.0 - github.com/open-policy-agent/opa v1.15.0 + github.com/open-policy-agent/opa v1.15.1 github.com/openai/openai-go v1.12.0 github.com/opencontainers/image-spec v1.1.1 github.com/otiai10/copy v1.14.1 @@ -118,15 +118,15 @@ require ( golang.org/x/oauth2 v0.36.0 golang.org/x/term v0.41.0 golang.org/x/text v0.35.0 - google.golang.org/api v0.273.0 - google.golang.org/genai v1.51.0 - google.golang.org/grpc v1.79.3 + google.golang.org/api v0.273.1 + google.golang.org/genai v1.52.1 + google.golang.org/grpc v1.80.0 gopkg.in/ini.v1 v1.67.1 gopkg.in/op/go-logging.v1 v1.0.0-20160211212156-b2cb9fa56473 gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v3 v3.0.1 k8s.io/client-go v0.35.3 - modernc.org/sqlite v1.47.0 + modernc.org/sqlite v1.48.0 mvdan.cc/sh/v3 v3.13.0 ) @@ -159,7 +159,7 @@ require ( github.com/agext/levenshtein v1.2.3 // indirect github.com/agnivade/levenshtein v1.2.1 // indirect github.com/alecthomas/participle/v2 v2.1.4 // indirect - github.com/andybalholm/brotli v1.2.0 // indirect + github.com/andybalholm/brotli v1.2.1 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect github.com/apparentlymart/go-cidr v1.1.0 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect @@ -169,7 +169,7 @@ require ( github.com/aws/aws-sdk-go v1.55.8 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect - github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.10 // indirect + github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.11 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect @@ -193,7 +193,7 @@ require ( github.com/chainguard-dev/git-urls v1.0.2 // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect - github.com/charmbracelet/x/exp/slice v0.0.0-20260323091123-df7b1bcffcca // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20260330094520-2dce04b6f8a4 // indirect github.com/charmbracelet/x/exp/strings v0.1.0 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect @@ -236,7 +236,7 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/forPelevin/gomoji v1.4.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.1 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.8.0 // indirect github.com/go-ini/ini v1.67.0 // indirect @@ -320,10 +320,10 @@ require ( github.com/lestrrat-go/dsig v1.0.0 // indirect github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect - github.com/lestrrat-go/httprc/v3 v3.0.4 // indirect + github.com/lestrrat-go/httprc/v3 v3.0.5 // indirect github.com/lestrrat-go/jwx/v3 v3.0.13 // indirect github.com/lestrrat-go/option/v2 v2.0.0 // indirect - github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/marshallbrekka/go-u2fhost v0.0.0-20210111072507-3ccdec8c8105 // indirect github.com/mattn/go-colorable v0.1.14 // indirect @@ -346,7 +346,7 @@ require ( github.com/opencontainers/go-digest v1.0.0 // indirect github.com/otiai10/mint v1.6.3 // indirect github.com/pelletier/go-toml/v2 v2.3.0 // indirect - github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 // indirect + github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect github.com/pierrec/lz4/v4 v4.1.26 // indirect github.com/pjbgf/sha1cd v0.5.0 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect @@ -362,7 +362,7 @@ require ( github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect - github.com/rs/zerolog v1.34.0 // indirect + github.com/rs/zerolog v1.35.0 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect @@ -434,16 +434,16 @@ require ( golang.org/x/time v0.15.0 // indirect golang.org/x/tools v0.43.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect - google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect + google.golang.org/genproto v0.0.0-20260330182312-d5a96adf58d8 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260330182312-d5a96adf58d8 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260330182312-d5a96adf58d8 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect inet.af/netaddr v0.0.0-20230525184311-b8eac61e914a // indirect k8s.io/apimachinery v0.35.3 // indirect k8s.io/klog/v2 v2.140.0 // indirect - k8s.io/kube-openapi v0.0.0-20260319004828-5883c5ee87b9 // indirect + k8s.io/kube-openapi v0.0.0-20260330154417-16be699c7b31 // indirect k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 // indirect modernc.org/libc v1.70.0 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index 0939c27c06..ab8e0bc123 100644 --- a/go.sum +++ b/go.sum @@ -139,8 +139,8 @@ github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7O github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= -github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= -github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= +github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= @@ -179,8 +179,8 @@ github.com/aws/aws-sdk-go-v2/credentials v1.19.13 h1:mA59E3fokBvyEGHKFdnpNNrvaR3 github.com/aws/aws-sdk-go-v2/credentials v1.19.13/go.mod h1:yoTXOQKea18nrM69wGF9jBdG4WocSZA1h38A+t/MAsk= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI= -github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.10 h1:GHKiUsNpMVIrrf4v+IvC56VfCB0LeZ6FUFpMUDIckSI= -github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.10/go.mod h1:wGl2ts9ULQknI/BNi3VzcRFv3ebvOViQdtyxaMpBzzI= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.11 h1:389etN1xVFox972wTlppZOhdE9hviegagWS00FK6D+4= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.11/go.mod h1:w47JHXVTLCfgMR5ogaztz9jgOgOuBhorYB4RScKfMXw= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A= @@ -203,10 +203,10 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3x github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ= -github.com/aws/aws-sdk-go-v2/service/organizations v1.50.6 h1:xtFQzzqV9GTWtHI/0JEZmV+bOZsjF25mGqU7nOMTg0Q= -github.com/aws/aws-sdk-go-v2/service/organizations v1.50.6/go.mod h1:urLFj1twuR/h5T0wN/2/kmY1gxBFa1tTKr+c60lZ2fA= -github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo= -github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM= +github.com/aws/aws-sdk-go-v2/service/organizations v1.51.0 h1:WWZx5pDUGGG/WjlAM6agF0s5jUSz2HLFGZkDFZJa9oE= +github.com/aws/aws-sdk-go-v2/service/organizations v1.51.0/go.mod h1:urLFj1twuR/h5T0wN/2/kmY1gxBFa1tTKr+c60lZ2fA= +github.com/aws/aws-sdk-go-v2/service/s3 v1.98.0 h1:foqo/ocQ7WqKwy3FojGtZQJo0FR4vto9qnz9VaumbCo= +github.com/aws/aws-sdk-go-v2/service/s3 v1.98.0/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM= github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.5 h1:z2ayoK3pOvf8ODj/vPR0FgAS5ONruBq0F94SRoW/BIU= github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.5/go.mod h1:mpZB5HAl4ZIISod9qCi12xZ170TbHX9CCJV5y7nb7QU= github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg= @@ -289,8 +289,8 @@ github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9 github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= -github.com/charmbracelet/x/exp/slice v0.0.0-20260323091123-df7b1bcffcca h1:QQoyQLgUzojMNWHVHToN6d9qTvT0KWtxUKIRPx/Ox5o= -github.com/charmbracelet/x/exp/slice v0.0.0-20260323091123-df7b1bcffcca/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA= +github.com/charmbracelet/x/exp/slice v0.0.0-20260330094520-2dce04b6f8a4 h1:VSd4zShIAf/4FgEDFJpapEcAPrc7h3dyyN7V9JlJpQw= +github.com/charmbracelet/x/exp/slice v0.0.0-20260330094520-2dce04b6f8a4/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA= github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9nwd6aoEm7gkA= github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= @@ -337,7 +337,6 @@ github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkE github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= -github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -441,8 +440,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fsouza/fake-gcs-server v1.52.2 h1:j6ne83nqHrlX5EEor7WWVIKdBsztGtwJ1J2mL+k+iio= github.com/fsouza/fake-gcs-server v1.52.2/go.mod h1:47HKyIkz6oLTes1R8vEaHLwXfzYsGfmDUk1ViHHAUsA= -github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= -github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ= +github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/getsentry/sentry-go v0.44.1 h1:/cPtrA5qB7uMRrhgSn9TYtcEF36auGP3Y6+ThvD/yaI= @@ -462,8 +461,8 @@ github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDz github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= -github.com/go-git/go-git/v5 v5.17.0 h1:AbyI4xf+7DsjINHMu35quAh4wJygKBKBuXVjV/pxesM= -github.com/go-git/go-git/v5 v5.17.0/go.mod h1:f82C4YiLx+Lhi8eHxltLeGC5uBTXSFa6PC5WW9o4SjI= +github.com/go-git/go-git/v5 v5.17.2 h1:B+nkdlxdYrvyFK4GPXVU8w1U+YkbsgciIR7f2sZJ104= +github.com/go-git/go-git/v5 v5.17.2/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo= github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= @@ -507,7 +506,6 @@ github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/godbus/dbus v4.1.0+incompatible h1:WqqLRTsQic3apZUK9qC5sGNfXthmPXzUZ7nQPrNITa4= github.com/godbus/dbus v4.1.0+incompatible/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= @@ -682,8 +680,8 @@ github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0Yg github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= -github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= +github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= @@ -815,8 +813,8 @@ github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7 github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU= github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= -github.com/lestrrat-go/httprc/v3 v3.0.4 h1:pXyH2ppK8GYYggygxJ3TvxpCZnbEUWc9qSwRTTApaLA= -github.com/lestrrat-go/httprc/v3 v3.0.4/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= +github.com/lestrrat-go/httprc/v3 v3.0.5 h1:S+Mb4L2I+bM6JGTibLmxExhyTOqnXjqx+zi9MoXw/TM= +github.com/lestrrat-go/httprc/v3 v3.0.5/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= github.com/lestrrat-go/jwx/v3 v3.0.13 h1:AdHKiPIYeCSnOJtvdpipPg/0SuFh9rdkN+HF3O0VdSk= github.com/lestrrat-go/jwx/v3 v3.0.13/go.mod h1:2m0PV1A9tM4b/jVLMx8rh6rBl7F6WGb3EG2hufN9OQU= github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= @@ -825,8 +823,8 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lrstanley/bubblezone v1.0.0 h1:bIpUaBilD42rAQwlg/4u5aTqVAt6DSRKYZuSdmkr8UA= github.com/lrstanley/bubblezone v1.0.0/go.mod h1:kcTekA8HE/0Ll2bWzqHlhA2c513KDNLW7uDfDP4Mly8= -github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= -github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= @@ -839,14 +837,11 @@ github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRH github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= @@ -913,8 +908,8 @@ github.com/nwaples/rardecode/v2 v2.2.2/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsR github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= -github.com/open-policy-agent/opa v1.15.0 h1:h4n6AEnw4YXvCmFJW08dwrE0l9MwMF5vu8IV4qMvCnY= -github.com/open-policy-agent/opa v1.15.0/go.mod h1:c6SN+7jSsUcKJLQc5P4yhwx8YYDRbjpAiGkBOTqxaa4= +github.com/open-policy-agent/opa v1.15.1 h1:ZE4JaXsVUzDiHFSlOMBS3nJohR5BRGB/RNz6gTNugzE= +github.com/open-policy-agent/opa v1.15.1/go.mod h1:c6SN+7jSsUcKJLQc5P4yhwx8YYDRbjpAiGkBOTqxaa4= github.com/openai/openai-go v1.12.0 h1:NBQCnXzqOTv5wsgNC36PrFEiskGfO5wccfCWDo9S1U0= github.com/openai/openai-go v1.12.0/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= @@ -931,8 +926,8 @@ github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/9 github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM= github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= -github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 h1:rh2lKw/P/EqHa724vYH2+VVQ1YnW4u6EOXl0PMAovZE= -github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM= +github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= @@ -1006,9 +1001,8 @@ github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTE github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= -github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= -github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI= +github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= @@ -1352,7 +1346,6 @@ golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -1415,31 +1408,31 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/api v0.273.0 h1:r/Bcv36Xa/te1ugaN1kdJ5LoA5Wj/cL+a4gj6FiPBjQ= -google.golang.org/api v0.273.0/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.273.1 h1:L7G/TmpAMz0nKx/ciAVssVmWQiOF6+pOuXeKrWVsquY= +google.golang.org/api v0.273.1/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/genai v1.51.0 h1:IZGuUqgfx40INv3hLFGCbOSGp0qFqm7LVmDghzNIYqg= -google.golang.org/genai v1.51.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= +google.golang.org/genai v1.52.1 h1:dYoljKtLDXMiBdVaClSJ/ZPwZ7j1N0lGjMhwOKOQUlk= +google.golang.org/genai v1.52.1/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= -google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= -google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI= -google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto v0.0.0-20260330182312-d5a96adf58d8 h1:sySa53TjfcJqYj9NDInPweJWT4oTPySurSM7e3nr6hQ= +google.golang.org/genproto v0.0.0-20260330182312-d5a96adf58d8/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= +google.golang.org/genproto/googleapis/api v0.0.0-20260330182312-d5a96adf58d8 h1:udju5p8o61FW6K2fxHWPIZhChk4FHl2Hjk8+uuLNnpM= +google.golang.org/genproto/googleapis/api v0.0.0-20260330182312-d5a96adf58d8/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260330182312-d5a96adf58d8 h1:OHkuo1i98/05rzpm9NBbfEtpJH/k3abEgZUKaAuCI7Y= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260330182312-d5a96adf58d8/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -1501,8 +1494,8 @@ k8s.io/client-go v0.35.3 h1:s1lZbpN4uI6IxeTM2cpdtrwHcSOBML1ODNTCCfsP1pg= k8s.io/client-go v0.35.3/go.mod h1:RzoXkc0mzpWIDvBrRnD+VlfXP+lRzqQjCmKtiwZ8Q9c= k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= -k8s.io/kube-openapi v0.0.0-20260319004828-5883c5ee87b9 h1:Sztf7ESG9tAXRW/ACJZjrj5jhdOUqS2KFRQT+CTvu78= -k8s.io/kube-openapi v0.0.0-20260319004828-5883c5ee87b9/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= +k8s.io/kube-openapi v0.0.0-20260330154417-16be699c7b31 h1:V+sn9a/1fEYDGwnllCmqXBk8x7obZ+hl869Q3Abumkg= +k8s.io/kube-openapi v0.0.0-20260330154417-16be699c7b31/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 h1:kBawHLSnx/mYHmRnNUf9d4CpjREbeZuxoSGOX/J+aYM= k8s.io/utils v0.0.0-20260319190234-28399d86e0b5/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= @@ -1527,8 +1520,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk= -modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= +modernc.org/sqlite v1.48.0 h1:ElZyLop3Q2mHYk5IFPPXADejZrlHu7APbpB0sF78bq4= +modernc.org/sqlite v1.48.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= diff --git a/pkg/ai/agent/base/config.go b/pkg/ai/agent/base/config.go index 206e77b110..d20a19499e 100644 --- a/pkg/ai/agent/base/config.go +++ b/pkg/ai/agent/base/config.go @@ -2,8 +2,10 @@ package base import ( + "strings" "time" + "github.com/cloudposse/atmos/pkg/dependencies" "github.com/cloudposse/atmos/pkg/schema" ) @@ -64,6 +66,25 @@ func ExtractConfig(atmosConfig *schema.AtmosConfiguration, providerName string, return config } +// ResolveToolchainPATH extracts the toolchain bin PATH for MCP server subprocesses. +// Returns the PATH string from the toolchain environment, or empty if no toolchain is configured. +func ResolveToolchainPATH(atmosConfig *schema.AtmosConfiguration) string { + deps, err := dependencies.LoadToolVersionsDependencies(atmosConfig) + if err != nil || len(deps) == 0 { + return "" + } + tenv, err := dependencies.NewEnvironmentFromDeps(atmosConfig, deps) + if err != nil || tenv == nil { + return "" + } + for _, envVar := range tenv.EnvVars() { + if strings.HasPrefix(envVar, "PATH=") { + return envVar[len("PATH="):] + } + } + return "" +} + // applyProviderOverrides applies provider-specific configuration overrides to the config. func applyProviderOverrides(config *Config, providerConfig *schema.AIProviderConfig) { if providerConfig == nil { diff --git a/pkg/ai/agent/base/config_test.go b/pkg/ai/agent/base/config_test.go index 36df906b62..2daaee16b8 100644 --- a/pkg/ai/agent/base/config_test.go +++ b/pkg/ai/agent/base/config_test.go @@ -330,3 +330,9 @@ func TestExtractConfig_TableDriven(t *testing.T) { }) } } + +func TestResolveToolchainPATH_NoDeps(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{} + result := ResolveToolchainPATH(atmosConfig) + assert.Empty(t, result) +} diff --git a/pkg/ai/agent/base/messages.go b/pkg/ai/agent/base/messages.go index 666334ab2c..0e9759f991 100644 --- a/pkg/ai/agent/base/messages.go +++ b/pkg/ai/agent/base/messages.go @@ -1,6 +1,8 @@ package base import ( + "strings" + "github.com/cloudposse/atmos/pkg/ai/types" ) @@ -39,3 +41,19 @@ func PrependSystemMessages(systemPrompt, atmosMemory string, messages []types.Me return result } + +// FormatMessagesAsPrompt concatenates conversation messages into a single prompt string. +// User messages are included as-is; assistant messages are prefixed with "Assistant: ". +// Used by CLI providers that pass the entire conversation as a single text prompt. +func FormatMessagesAsPrompt(messages []types.Message) string { + var parts []string + for _, msg := range messages { + switch msg.Role { + case types.RoleUser: + parts = append(parts, msg.Content) + case types.RoleAssistant: + parts = append(parts, "Assistant: "+msg.Content) + } + } + return strings.Join(parts, "\n\n") +} diff --git a/pkg/ai/agent/base/messages_tools_test.go b/pkg/ai/agent/base/messages_tools_test.go index 1c8a4780cc..429b3b9244 100644 --- a/pkg/ai/agent/base/messages_tools_test.go +++ b/pkg/ai/agent/base/messages_tools_test.go @@ -433,6 +433,42 @@ func TestExtractAllToolInfo_CapacityOptimized(t *testing.T) { assert.Len(t, result, toolCount) } +// FormatMessagesAsPrompt tests. + +func TestFormatMessagesAsPrompt(t *testing.T) { + messages := []types.Message{ + {Role: types.RoleUser, Content: "What stacks?"}, + {Role: types.RoleAssistant, Content: "You have 4."}, + {Role: types.RoleUser, Content: "Describe vpc."}, + } + result := FormatMessagesAsPrompt(messages) + assert.Contains(t, result, "What stacks?") + assert.Contains(t, result, "Assistant: You have 4.") + assert.Contains(t, result, "Describe vpc.") +} + +func TestFormatMessagesAsPrompt_Empty(t *testing.T) { + result := FormatMessagesAsPrompt(nil) + assert.Empty(t, result) +} + +func TestFormatMessagesAsPrompt_SingleUser(t *testing.T) { + messages := []types.Message{{Role: types.RoleUser, Content: "Hello"}} + assert.Equal(t, "Hello", FormatMessagesAsPrompt(messages)) +} + +func TestFormatMessagesAsPrompt_SkipsUnknownRoles(t *testing.T) { + messages := []types.Message{ + {Role: types.RoleUser, Content: "Hello"}, + {Role: "system", Content: "ignored"}, + {Role: types.RoleAssistant, Content: "Hi"}, + } + result := FormatMessagesAsPrompt(messages) + assert.Contains(t, result, "Hello") + assert.Contains(t, result, "Assistant: Hi") + assert.NotContains(t, result, "ignored") +} + // ToolPropertySchema and ToolParameterSchema struct tests. func TestToolPropertySchema_Fields(t *testing.T) { diff --git a/pkg/ai/agent/claudecode/client.go b/pkg/ai/agent/claudecode/client.go new file mode 100644 index 0000000000..536cecd7cc --- /dev/null +++ b/pkg/ai/agent/claudecode/client.go @@ -0,0 +1,267 @@ +// Package claudecode provides an AI provider that invokes the Claude Code CLI +// as a subprocess, reusing the user's Claude Pro/Max subscription instead of +// requiring separate API tokens. +package claudecode + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "strconv" + "strings" + + errUtils "github.com/cloudposse/atmos/errors" + "github.com/cloudposse/atmos/pkg/ai/agent/base" + "github.com/cloudposse/atmos/pkg/ai/tools" + "github.com/cloudposse/atmos/pkg/ai/types" + log "github.com/cloudposse/atmos/pkg/logger" + mcpclient "github.com/cloudposse/atmos/pkg/mcp/client" + "github.com/cloudposse/atmos/pkg/perf" + "github.com/cloudposse/atmos/pkg/schema" + "github.com/cloudposse/atmos/pkg/ui" +) + +const ( + // ProviderName is the name of this provider for configuration lookup. + ProviderName = "claude-code" + // DefaultBinary is the default binary name for Claude Code. + DefaultBinary = "claude" + // DefaultMaxTurns is the default maximum agentic turns per invocation. + DefaultMaxTurns = 5 +) + +// Client invokes the Claude Code CLI in non-interactive mode. +type Client struct { + binaryPath string + maxTurns int + maxBudget float64 + allowedTools []string + model string + mcpServers map[string]schema.MCPServerConfig // MCP servers to pass through via --mcp-config. + toolchainPATH string // Toolchain bin PATH for MCP server subprocesses. + mcpConfigPath string // Pre-generated MCP config file path. +} + +// NewClient creates a new Claude Code CLI client from Atmos configuration. +func NewClient(atmosConfig *schema.AtmosConfiguration) (*Client, error) { + defer perf.Track(atmosConfig, "claudecode.NewClient")() + + config := base.ExtractConfig(atmosConfig, ProviderName, base.ProviderDefaults{ + Model: ProviderName, + }) + + if !config.Enabled { + return nil, errUtils.ErrAIDisabledInConfiguration + } + + providerConfig := base.GetProviderConfig(atmosConfig, ProviderName) + + client := &Client{ + maxTurns: DefaultMaxTurns, + model: config.Model, + } + + // Apply provider-specific settings. + applyProviderConfig(client, providerConfig) + + // Resolve binary path. + if client.binaryPath == "" { + resolved, err := exec.LookPath(DefaultBinary) + if err != nil { + return nil, errUtils.Build(errUtils.ErrCLIProviderBinaryNotFound). + WithContext("provider", ProviderName). + WithContext("binary", DefaultBinary). + WithHint("Install Claude Code: brew install --cask claude-code"). + Err() + } + client.binaryPath = resolved + } + + // Capture MCP servers for pass-through (only if configured). + if len(atmosConfig.MCP.Servers) > 0 { + client.mcpServers = atmosConfig.MCP.Servers + client.toolchainPATH = base.ResolveToolchainPATH(atmosConfig) + // Pre-generate MCP config so we can show the path before "Thinking...". + mcpConfigPath, mcpErr := mcpclient.WriteMCPConfigToTempFile(client.mcpServers, client.toolchainPATH) + if mcpErr != nil { + log.Debug("Failed to generate MCP config for Claude Code", "error", mcpErr) + } else { + client.mcpConfigPath = mcpConfigPath + ui.Info(fmt.Sprintf("MCP servers configured: %d (config: %s)", len(client.mcpServers), mcpConfigPath)) + } + } + + return client, nil +} + +// SendMessage sends a prompt to Claude Code and returns the response. +func (c *Client) SendMessage(ctx context.Context, message string) (string, error) { + defer perf.Track(nil, "claudecode.Client.SendMessage")() + + return c.execClaude(ctx, message, "") +} + +// SendMessageWithTools is not supported — Claude Code manages its own tools. +func (c *Client) SendMessageWithTools(_ context.Context, _ string, _ []tools.Tool) (*types.Response, error) { + return nil, errUtils.ErrCLIProviderToolsNotSupported +} + +// SendMessageWithHistory concatenates history into a single prompt. +func (c *Client) SendMessageWithHistory(ctx context.Context, messages []types.Message) (string, error) { + defer perf.Track(nil, "claudecode.Client.SendMessageWithHistory")() + + prompt := base.FormatMessagesAsPrompt(messages) + return c.execClaude(ctx, prompt, "") +} + +// SendMessageWithToolsAndHistory is not supported — Claude Code manages its own tools. +func (c *Client) SendMessageWithToolsAndHistory(_ context.Context, _ []types.Message, _ []tools.Tool) (*types.Response, error) { + return nil, errUtils.ErrCLIProviderToolsNotSupported +} + +// SendMessageWithSystemPromptAndTools sends with system prompt via --append-system-prompt. +func (c *Client) SendMessageWithSystemPromptAndTools( + ctx context.Context, + systemPrompt string, + atmosMemory string, + messages []types.Message, + _ []tools.Tool, +) (*types.Response, error) { + defer perf.Track(nil, "claudecode.Client.SendMessageWithSystemPromptAndTools")() + + prompt := base.FormatMessagesAsPrompt(messages) + combined := systemPrompt + if atmosMemory != "" { + combined += "\n\n" + atmosMemory + } + + result, err := c.execClaude(ctx, prompt, combined) + if err != nil { + return nil, err + } + + return &types.Response{ + Content: result, + StopReason: types.StopReasonEndTurn, + }, nil +} + +// GetModel returns the provider name. +func (c *Client) GetModel() string { + return c.model +} + +// GetMaxTokens returns 0 — managed by Claude Code internally. +func (c *Client) GetMaxTokens() int { + return 0 +} + +// buildArgs constructs the CLI arguments for claude -p invocation. +func (c *Client) buildArgs(systemPrompt string) []string { + args := []string{ + "-p", + "--output-format", "json", + "--max-turns", strconv.Itoa(c.maxTurns), + } + + if c.maxBudget > 0 { + args = append(args, "--max-budget-usd", fmt.Sprintf("%.2f", c.maxBudget)) + } + + if systemPrompt != "" { + args = append(args, "--append-system-prompt", systemPrompt) + } + + for _, tool := range c.allowedTools { + args = append(args, "--allowedTools", tool) + } + + // MCP pass-through: use pre-generated config file. + if c.mcpConfigPath != "" { + args = append(args, "--mcp-config", c.mcpConfigPath) + args = append(args, "--dangerously-skip-permissions") + } + + return args +} + +// execClaude runs the claude CLI and returns the result text. +func (c *Client) execClaude(ctx context.Context, prompt, systemPrompt string) (string, error) { + args := c.buildArgs(systemPrompt) + + cmd := exec.CommandContext(ctx, c.binaryPath, args...) //nolint:gosec // Binary path is from user config or exec.LookPath. + cmd.Stdin = strings.NewReader(prompt) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + // Clean up temp MCP config file after Claude Code exits. + if c.mcpConfigPath != "" { + defer os.Remove(c.mcpConfigPath) + } + + if err := cmd.Run(); err != nil { + stderrStr := stderr.String() + if stderrStr != "" { + return "", fmt.Errorf("%w: %s: %s: %w", errUtils.ErrCLIProviderExecFailed, ProviderName, stderrStr, err) + } + return "", fmt.Errorf("%w: %s: %w", errUtils.ErrCLIProviderExecFailed, ProviderName, err) + } + + return parseResponse(stdout.Bytes()) +} + +// claudeResponse is the JSON output from `claude -p --output-format json`. +type claudeResponse struct { + Type string `json:"type"` + Subtype string `json:"subtype"` + Result string `json:"result"` + CostUSD float64 `json:"cost_usd"` + TotalCostUSD float64 `json:"total_cost_usd"` + DurationMS int `json:"duration_ms"` + IsError bool `json:"is_error"` + SessionID string `json:"session_id"` + NumTurns int `json:"num_turns"` +} + +// parseResponse extracts the result text from Claude Code JSON output. +func parseResponse(output []byte) (string, error) { + var resp claudeResponse + if err := json.Unmarshal(output, &resp); err != nil { + // If not valid JSON, return raw text (Claude Code may output plain text on some errors). + trimmed := strings.TrimSpace(string(output)) + if trimmed != "" { + return trimmed, nil + } + return "", fmt.Errorf("%w: %w", errUtils.ErrCLIProviderParseResponse, err) + } + + if resp.IsError { + return "", fmt.Errorf("%w: %s: %s", errUtils.ErrCLIProviderExecFailed, ProviderName, resp.Result) + } + + return resp.Result, nil +} + +// applyProviderConfig applies provider-specific settings to the client. +func applyProviderConfig(client *Client, providerConfig *schema.AIProviderConfig) { + if providerConfig == nil { + return + } + if providerConfig.Binary != "" { + client.binaryPath = providerConfig.Binary + } + if providerConfig.MaxTurns > 0 { + client.maxTurns = providerConfig.MaxTurns + } + if providerConfig.MaxBudgetUSD > 0 { + client.maxBudget = providerConfig.MaxBudgetUSD + } + if len(providerConfig.AllowedTools) > 0 { + client.allowedTools = providerConfig.AllowedTools + } +} diff --git a/pkg/ai/agent/claudecode/client_test.go b/pkg/ai/agent/claudecode/client_test.go new file mode 100644 index 0000000000..50377e8871 --- /dev/null +++ b/pkg/ai/agent/claudecode/client_test.go @@ -0,0 +1,352 @@ +package claudecode + +import ( + "context" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + errUtils "github.com/cloudposse/atmos/errors" + "github.com/cloudposse/atmos/pkg/schema" +) + +func TestNewClient_Disabled(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{ + AI: schema.AISettings{Enabled: false}, + } + _, err := NewClient(atmosConfig) + assert.ErrorIs(t, err, errUtils.ErrAIDisabledInConfiguration) +} + +func TestNewClient_BinaryNotFound(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{ + AI: schema.AISettings{ + Enabled: true, + Providers: map[string]*schema.AIProviderConfig{ + ProviderName: { + Binary: "/nonexistent/path/to/claude-binary-xyz", + }, + }, + }, + } + // Binary doesn't exist at that path — but we set it explicitly so LookPath isn't used. + // The client creation should succeed (it only validates LookPath when binary is empty). + client, err := NewClient(atmosConfig) + require.NoError(t, err) + assert.Equal(t, "/nonexistent/path/to/claude-binary-xyz", client.binaryPath) +} + +func TestNewClient_BinaryNotOnPath(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{ + AI: schema.AISettings{ + Enabled: true, + Providers: map[string]*schema.AIProviderConfig{ + ProviderName: {}, + }, + }, + } + // Override PATH to ensure claude is not found. + t.Setenv("PATH", t.TempDir()) + + _, err := NewClient(atmosConfig) + assert.ErrorIs(t, err, errUtils.ErrCLIProviderBinaryNotFound) +} + +func TestNewClient_CustomSettings(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{ + AI: schema.AISettings{ + Enabled: true, + Providers: map[string]*schema.AIProviderConfig{ + ProviderName: { + Binary: "/usr/local/bin/claude", + MaxTurns: 10, + MaxBudgetUSD: 2.50, + AllowedTools: []string{"Read", "Glob"}, + }, + }, + }, + } + client, err := NewClient(atmosConfig) + require.NoError(t, err) + assert.Equal(t, "/usr/local/bin/claude", client.binaryPath) + assert.Equal(t, 10, client.maxTurns) + assert.Equal(t, 2.50, client.maxBudget) + assert.Equal(t, []string{"Read", "Glob"}, client.allowedTools) +} + +func TestParseResponse_ValidJSON(t *testing.T) { + input := `{ + "type": "result", + "subtype": "success", + "result": "The terraform plan shows 3 resources.", + "cost_usd": 0.003, + "is_error": false, + "session_id": "abc123", + "num_turns": 1 + }` + result, err := parseResponse([]byte(input)) + require.NoError(t, err) + assert.Equal(t, "The terraform plan shows 3 resources.", result) +} + +func TestParseResponse_ErrorResponse(t *testing.T) { + input := `{ + "type": "result", + "subtype": "error", + "result": "Authentication expired", + "is_error": true + }` + _, err := parseResponse([]byte(input)) + assert.Error(t, err) + assert.ErrorIs(t, err, errUtils.ErrCLIProviderExecFailed) + assert.Contains(t, err.Error(), "Authentication expired") +} + +func TestParseResponse_InvalidJSON(t *testing.T) { + // Non-JSON output is returned as plain text. + result, err := parseResponse([]byte("Plain text response")) + require.NoError(t, err) + assert.Equal(t, "Plain text response", result) +} + +func TestParseResponse_EmptyOutput(t *testing.T) { + _, err := parseResponse([]byte("")) + assert.Error(t, err) + assert.ErrorIs(t, err, errUtils.ErrCLIProviderParseResponse) +} + +func TestSendMessageWithTools_NotSupported(t *testing.T) { + client := &Client{binaryPath: "claude"} + _, err := client.SendMessageWithTools(context.Background(), "test", nil) + assert.ErrorIs(t, err, errUtils.ErrCLIProviderToolsNotSupported) +} + +func TestSendMessageWithToolsAndHistory_NotSupported(t *testing.T) { + client := &Client{binaryPath: "claude"} + _, err := client.SendMessageWithToolsAndHistory(context.Background(), nil, nil) + assert.ErrorIs(t, err, errUtils.ErrCLIProviderToolsNotSupported) +} + +func TestGetModel(t *testing.T) { + client := &Client{model: "claude-code"} + assert.Equal(t, "claude-code", client.GetModel()) +} + +func TestGetMaxTokens(t *testing.T) { + client := &Client{} + assert.Equal(t, 0, client.GetMaxTokens()) +} + +// formatMessages tests are in pkg/ai/agent/base/messages_tools_test.go (FormatMessagesAsPrompt). + +func TestApplyProviderConfig_Nil(t *testing.T) { + client := &Client{maxTurns: 5} + applyProviderConfig(client, nil) + assert.Equal(t, 5, client.maxTurns) // Unchanged. +} + +func TestApplyProviderConfig_AllFields(t *testing.T) { + client := &Client{maxTurns: 5} + applyProviderConfig(client, &schema.AIProviderConfig{ + Binary: "/custom/claude", + MaxTurns: 10, + MaxBudgetUSD: 3.50, + AllowedTools: []string{"Read", "Write"}, + }) + assert.Equal(t, "/custom/claude", client.binaryPath) + assert.Equal(t, 10, client.maxTurns) + assert.Equal(t, 3.50, client.maxBudget) + assert.Equal(t, []string{"Read", "Write"}, client.allowedTools) +} + +func TestApplyProviderConfig_PartialFields(t *testing.T) { + client := &Client{maxTurns: 5, maxBudget: 1.0} + applyProviderConfig(client, &schema.AIProviderConfig{ + MaxTurns: 20, + // Binary, MaxBudgetUSD, AllowedTools not set — should not change. + }) + assert.Equal(t, "", client.binaryPath) + assert.Equal(t, 20, client.maxTurns) + assert.Equal(t, 1.0, client.maxBudget) // Unchanged. +} + +func TestParseResponse_CostFields(t *testing.T) { + input := `{ + "type": "result", + "result": "Analysis done.", + "cost_usd": 0.005, + "total_cost_usd": 0.015, + "duration_ms": 2500, + "is_error": false, + "session_id": "sess123", + "num_turns": 3 + }` + result, err := parseResponse([]byte(input)) + require.NoError(t, err) + assert.Equal(t, "Analysis done.", result) +} + +func TestProviderName(t *testing.T) { + assert.Equal(t, "claude-code", ProviderName) +} + +func TestDefaultConstants(t *testing.T) { + assert.Equal(t, "claude", DefaultBinary) + assert.Equal(t, 5, DefaultMaxTurns) +} + +// resolveToolchainPATH tests are in pkg/ai/agent/base/config_test.go (ResolveToolchainPATH). + +func TestClient_MCPServers_NotCaptured_WhenEmpty(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{ + AI: schema.AISettings{ + Enabled: true, + Providers: map[string]*schema.AIProviderConfig{ + ProviderName: {Binary: "/usr/local/bin/claude"}, + }, + }, + // No MCP servers configured. + } + client, err := NewClient(atmosConfig) + require.NoError(t, err) + assert.Nil(t, client.mcpServers) + assert.Empty(t, client.toolchainPATH) +} + +func TestClient_MCPServers_Captured_WhenConfigured(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{ + AI: schema.AISettings{ + Enabled: true, + Providers: map[string]*schema.AIProviderConfig{ + ProviderName: {Binary: "/usr/local/bin/claude"}, + }, + }, + MCP: schema.MCPSettings{ + Servers: map[string]schema.MCPServerConfig{ + "aws-docs": {Command: "uvx", Args: []string{"docs@latest"}}, + }, + }, + } + client, err := NewClient(atmosConfig) + require.NoError(t, err) + assert.Len(t, client.mcpServers, 1) + assert.Contains(t, client.mcpServers, "aws-docs") +} + +func TestParseResponse_WhitespaceOnly(t *testing.T) { + _, err := parseResponse([]byte(" \n \t ")) + assert.Error(t, err) + assert.ErrorIs(t, err, errUtils.ErrCLIProviderParseResponse) +} + +func TestParseResponse_EmptyResult(t *testing.T) { + input := `{"type": "result", "result": "", "is_error": false}` + result, err := parseResponse([]byte(input)) + require.NoError(t, err) + assert.Empty(t, result) +} + +func TestNewClient_MCPConfigPath_SetWhenServersConfigured(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{ + AI: schema.AISettings{ + Enabled: true, + Providers: map[string]*schema.AIProviderConfig{ + ProviderName: {Binary: "/usr/local/bin/claude"}, + }, + }, + MCP: schema.MCPSettings{ + Servers: map[string]schema.MCPServerConfig{ + "test": {Command: "echo", Args: []string{"hello"}}, + }, + }, + } + client, err := NewClient(atmosConfig) + require.NoError(t, err) + assert.NotEmpty(t, client.mcpConfigPath) + + // Clean up temp file. + if client.mcpConfigPath != "" { + _ = os.Remove(client.mcpConfigPath) + } +} + +func TestBuildArgs_Basic(t *testing.T) { + client := &Client{maxTurns: 5} + args := client.buildArgs("") + assert.Contains(t, args, "-p") + assert.Contains(t, args, "--output-format") + assert.Contains(t, args, "--max-turns") + assert.Contains(t, args, "5") + assert.NotContains(t, args, "--max-budget-usd") + assert.NotContains(t, args, "--append-system-prompt") + assert.NotContains(t, args, "--mcp-config") +} + +func TestBuildArgs_WithBudget(t *testing.T) { + client := &Client{maxTurns: 5, maxBudget: 2.50} + args := client.buildArgs("") + assert.Contains(t, args, "--max-budget-usd") + assert.Contains(t, args, "2.50") +} + +func TestBuildArgs_WithSystemPrompt(t *testing.T) { + client := &Client{maxTurns: 5} + args := client.buildArgs("You are an expert") + assert.Contains(t, args, "--append-system-prompt") + assert.Contains(t, args, "You are an expert") +} + +func TestBuildArgs_WithAllowedTools(t *testing.T) { + client := &Client{maxTurns: 5, allowedTools: []string{"Read", "Glob"}} + args := client.buildArgs("") + // Each tool gets its own --allowedTools flag. + toolCount := 0 + for _, a := range args { + if a == "--allowedTools" { + toolCount++ + } + } + assert.Equal(t, 2, toolCount) + assert.Contains(t, args, "Read") + assert.Contains(t, args, "Glob") +} + +func TestBuildArgs_WithMCPConfig(t *testing.T) { + client := &Client{maxTurns: 5, mcpConfigPath: "/tmp/mcp.json"} + args := client.buildArgs("") + assert.Contains(t, args, "--mcp-config") + assert.Contains(t, args, "/tmp/mcp.json") + assert.Contains(t, args, "--dangerously-skip-permissions") +} + +func TestBuildArgs_AllOptions(t *testing.T) { + client := &Client{ + maxTurns: 10, + maxBudget: 1.00, + allowedTools: []string{"Read"}, + mcpConfigPath: "/tmp/mcp.json", + } + args := client.buildArgs("system prompt") + assert.Contains(t, args, "--max-turns") + assert.Contains(t, args, "--max-budget-usd") + assert.Contains(t, args, "--append-system-prompt") + assert.Contains(t, args, "--allowedTools") + assert.Contains(t, args, "--mcp-config") + assert.Contains(t, args, "--dangerously-skip-permissions") +} + +func TestNewClient_DefaultMaxTurns(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{ + AI: schema.AISettings{ + Enabled: true, + Providers: map[string]*schema.AIProviderConfig{ + ProviderName: {Binary: "/usr/local/bin/claude"}, + }, + }, + } + client, err := NewClient(atmosConfig) + require.NoError(t, err) + assert.Equal(t, DefaultMaxTurns, client.maxTurns) +} diff --git a/pkg/ai/agent/claudecode/register.go b/pkg/ai/agent/claudecode/register.go new file mode 100644 index 0000000000..b8e0ef45a3 --- /dev/null +++ b/pkg/ai/agent/claudecode/register.go @@ -0,0 +1,14 @@ +package claudecode + +import ( + "context" + + "github.com/cloudposse/atmos/pkg/ai/registry" + "github.com/cloudposse/atmos/pkg/schema" +) + +func init() { + registry.Register(ProviderName, func(_ context.Context, atmosConfig *schema.AtmosConfiguration) (registry.Client, error) { + return NewClient(atmosConfig) + }) +} diff --git a/pkg/ai/agent/codexcli/client.go b/pkg/ai/agent/codexcli/client.go new file mode 100644 index 0000000000..e71fcc94bd --- /dev/null +++ b/pkg/ai/agent/codexcli/client.go @@ -0,0 +1,419 @@ +// Package codexcli provides an AI provider that invokes the OpenAI Codex CLI +// as a subprocess, reusing the user's ChatGPT Plus/Pro subscription. +package codexcli + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + errUtils "github.com/cloudposse/atmos/errors" + "github.com/cloudposse/atmos/pkg/ai/agent/base" + "github.com/cloudposse/atmos/pkg/ai/tools" + "github.com/cloudposse/atmos/pkg/ai/types" + "github.com/cloudposse/atmos/pkg/config/homedir" + log "github.com/cloudposse/atmos/pkg/logger" + mcpclient "github.com/cloudposse/atmos/pkg/mcp/client" + "github.com/cloudposse/atmos/pkg/perf" + "github.com/cloudposse/atmos/pkg/schema" + "github.com/cloudposse/atmos/pkg/ui" +) + +const ( + // ProviderName is the name of this provider for configuration lookup. + ProviderName = "codex-cli" + // DefaultBinary is the default binary name for Codex CLI. + DefaultBinary = "codex" + // File permissions for MCP config. + configDirPerms = 0o700 + configFilePerms = 0o600 +) + +// Client invokes the OpenAI Codex CLI in non-interactive mode. +type Client struct { + binaryPath string + model string + fullAuto bool + mcpServers map[string]schema.MCPServerConfig + toolchainPATH string + hasMCPServers bool // True if MCP servers were written to ~/.codex/config.toml. + originalConfig []byte // Original ~/.codex/config.toml content for restore. + configBackedUp bool // True if original config was backed up. +} + +// NewClient creates a new Codex CLI client from Atmos configuration. +func NewClient(atmosConfig *schema.AtmosConfiguration) (*Client, error) { + defer perf.Track(atmosConfig, "codexcli.NewClient")() + + config := base.ExtractConfig(atmosConfig, ProviderName, base.ProviderDefaults{ + Model: ProviderName, + }) + + if !config.Enabled { + return nil, errUtils.ErrAIDisabledInConfiguration + } + + providerConfig := base.GetProviderConfig(atmosConfig, ProviderName) + + client := &Client{ + model: config.Model, + } + + if providerConfig != nil { + if providerConfig.Binary != "" { + client.binaryPath = providerConfig.Binary + } + if providerConfig.Model != "" { + client.model = providerConfig.Model + } + client.fullAuto = providerConfig.FullAuto + } + + // Resolve binary path. + if client.binaryPath == "" { + resolved, err := exec.LookPath(DefaultBinary) + if err != nil { + return nil, errUtils.Build(errUtils.ErrCLIProviderBinaryNotFound). + WithContext("provider", ProviderName). + WithContext("binary", DefaultBinary). + WithHint("Install Codex CLI: npm install -g @openai/codex"). + Err() + } + client.binaryPath = resolved + } + + // Capture MCP servers for pass-through (only if configured). + // Codex CLI only reads MCP servers from ~/.codex/config.toml (global config). + // -c flag overrides do NOT register MCP servers as tools. We must write to + // the global config and restore it after the session. + if len(atmosConfig.MCP.Servers) > 0 { + client.mcpServers = atmosConfig.MCP.Servers + client.toolchainPATH = base.ResolveToolchainPATH(atmosConfig) + if err := client.writeMCPToGlobalConfig(); err != nil { + ui.Warning(fmt.Sprintf("Failed to write MCP config: %s", err)) + } else { + client.hasMCPServers = true + ui.Info(fmt.Sprintf("MCP servers configured: %d (in ~/.codex/config.toml)", len(client.mcpServers))) + } + } + + return client, nil +} + +// buildArgs constructs the CLI arguments for codex exec invocation. +func (c *Client) buildArgs() []string { + args := []string{"exec", "--json"} + if c.model != "" && c.model != ProviderName { + args = append(args, "-m", c.model) + } + // When MCP servers are configured, use --dangerously-bypass-approvals-and-sandbox + // because --full-auto only auto-approves file writes, not MCP tool calls. + if c.hasMCPServers { + args = append(args, "--dangerously-bypass-approvals-and-sandbox") + } else if c.fullAuto { + args = append(args, "--full-auto") + } + return args +} + +// SendMessage sends a prompt to Codex CLI and returns the response. +func (c *Client) SendMessage(ctx context.Context, message string) (string, error) { + defer perf.Track(nil, "codexcli.Client.SendMessage")() + + args := c.buildArgs() + + cmd := exec.CommandContext(ctx, c.binaryPath, args...) //nolint:gosec // Binary path is from user config or exec.LookPath. + cmd.Stdin = strings.NewReader(message) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + // Restore original config after Codex exits (regardless of success/failure). + if c.hasMCPServers { + defer c.restoreGlobalConfig() + } + + if err := cmd.Run(); err != nil { + stderrStr := stderr.String() + if stderrStr != "" { + return "", fmt.Errorf("%w: %s: %s: %w", errUtils.ErrCLIProviderExecFailed, ProviderName, stderrStr, err) + } + return "", fmt.Errorf("%w: %s: %w", errUtils.ErrCLIProviderExecFailed, ProviderName, err) + } + + return ExtractResult(stdout.Bytes()) +} + +// SendMessageWithTools is not supported — Codex CLI manages its own tools. +func (c *Client) SendMessageWithTools(_ context.Context, _ string, _ []tools.Tool) (*types.Response, error) { + return nil, errUtils.ErrCLIProviderToolsNotSupported +} + +// SendMessageWithHistory concatenates history into a single prompt. +func (c *Client) SendMessageWithHistory(ctx context.Context, messages []types.Message) (string, error) { + defer perf.Track(nil, "codexcli.Client.SendMessageWithHistory")() + + return c.SendMessage(ctx, base.FormatMessagesAsPrompt(messages)) +} + +// SendMessageWithToolsAndHistory is not supported. +func (c *Client) SendMessageWithToolsAndHistory(_ context.Context, _ []types.Message, _ []tools.Tool) (*types.Response, error) { + return nil, errUtils.ErrCLIProviderToolsNotSupported +} + +// SendMessageWithSystemPromptAndTools sends with system prompt prepended. +func (c *Client) SendMessageWithSystemPromptAndTools( + ctx context.Context, + systemPrompt string, + atmosMemory string, + messages []types.Message, + _ []tools.Tool, +) (*types.Response, error) { + defer perf.Track(nil, "codexcli.Client.SendMessageWithSystemPromptAndTools")() + + prompt := base.FormatMessagesAsPrompt(messages) + if systemPrompt != "" { + prompt = systemPrompt + "\n\n" + prompt + } + if atmosMemory != "" { + prompt = atmosMemory + "\n\n" + prompt + } + + result, err := c.SendMessage(ctx, prompt) + if err != nil { + return nil, err + } + + return &types.Response{ + Content: result, + StopReason: types.StopReasonEndTurn, + }, nil +} + +// GetModel returns the configured model name. +func (c *Client) GetModel() string { return c.model } + +// GetMaxTokens returns 0 — managed by Codex CLI internally. +func (c *Client) GetMaxTokens() int { return 0 } + +// ExtractResult parses JSONL output and extracts the final text response. +// Codex CLI emits JSONL events. The response text is in "item.completed" events +// where item.type is "agent_message" (text in item.text directly) or "message" +// (text in item.content[].text array). +func ExtractResult(output []byte) (string, error) { + var lastText string + scanner := bufio.NewScanner(bytes.NewReader(output)) + for scanner.Scan() { + if text := extractTextFromEvent(scanner.Bytes()); text != "" { + lastText = text + } + } + if lastText == "" { + // Try plain text fallback. + trimmed := strings.TrimSpace(string(output)) + if trimmed != "" { + return trimmed, nil + } + return "", errUtils.ErrCLIProviderParseResponse + } + return lastText, nil +} + +// extractTextFromEvent extracts text from a single JSONL event line. +// Returns empty string if the event is not an item.completed with text content. +func extractTextFromEvent(line []byte) string { + var event codexEvent + if err := json.Unmarshal(line, &event); err != nil { + return "" + } + if event.Type != "item.completed" { + return "" + } + // Codex CLI uses "agent_message" type with text directly on the item. + if event.Item.Type == "agent_message" && event.Item.Text != "" { + return event.Item.Text + } + // Also handle "message" type with nested content array (API format). + if event.Item.Type == "message" { + for _, content := range event.Item.Content { + if content.Type == "text" { + return content.Text + } + } + } + return "" +} + +// injectAtmosEnvVars adds ATMOS_* environment variables from the current process +// into each MCP server's env. Codex CLI MCP servers don't inherit the parent +// environment, so auth-related vars (ATMOS_PROFILE, ATMOS_BASE_PATH, etc.) +// must be explicitly passed. +func injectAtmosEnvVars(config *mcpclient.MCPJSONConfig) { + atmosVars := collectAtmosEnvVars() + if len(atmosVars) == 0 { + return + } + for name, srv := range config.MCPServers { + if srv.Env == nil { + srv.Env = make(map[string]string) + } + for k, v := range atmosVars { + // Don't overwrite explicitly configured values. + if _, exists := srv.Env[k]; !exists { + srv.Env[k] = v + } + } + config.MCPServers[name] = srv + } +} + +// collectAtmosEnvVars returns all ATMOS_* env vars from the current process. +func collectAtmosEnvVars() map[string]string { + result := make(map[string]string) + for _, env := range os.Environ() { + if strings.HasPrefix(env, "ATMOS_") { + if idx := strings.IndexByte(env, '='); idx > 0 { + result[env[:idx]] = env[idx+1:] + } + } + } + return result +} + +// codexConfigPath returns the path to ~/.codex/config.toml. +func codexConfigPath() string { + home, _ := homedir.Dir() + return filepath.Join(home, ".codex", "config.toml") +} + +// writeMCPToGlobalConfig writes MCP servers to ~/.codex/config.toml. +// Backs up the original content for later restore. +func (c *Client) writeMCPToGlobalConfig() error { + configPath := codexConfigPath() + + // Backup existing config. + if data, err := os.ReadFile(configPath); err == nil { + c.originalConfig = data + c.configBackedUp = true + } + + // Generate TOML content with MCP servers. + mcpConfig := mcpclient.GenerateMCPConfig(c.mcpServers, c.toolchainPATH) + + // Codex CLI MCP servers don't inherit the parent process environment. + // Inject ATMOS_* env vars so auth and config discovery work correctly. + injectAtmosEnvVars(mcpConfig) + + var buf bytes.Buffer + // Preserve existing non-MCP config. + if c.configBackedUp { + buf.Write(c.originalConfig) + buf.WriteString("\n") + } + for name, srv := range mcpConfig.MCPServers { + writeTOMLServer(&buf, name, srv) + } + + // Ensure ~/.codex/ directory exists. + configDir := filepath.Dir(configPath) + if err := os.MkdirAll(configDir, configDirPerms); err != nil { + return fmt.Errorf("%w: mkdir %s: %w", errUtils.ErrMCPConfigWriteFailed, configDir, err) + } + + if err := os.WriteFile(configPath, buf.Bytes(), configFilePerms); err != nil { + return fmt.Errorf("%w: %s: %w", errUtils.ErrMCPConfigWriteFailed, configPath, err) + } + return nil +} + +// restoreGlobalConfig restores the original ~/.codex/config.toml content. +func (c *Client) restoreGlobalConfig() { + configPath := codexConfigPath() + if c.configBackedUp { + if err := os.WriteFile(configPath, c.originalConfig, configFilePerms); err != nil { + log.Debug("Failed to restore codex config", "path", configPath, "error", err) + } + } else { + // No original config existed — remove the file we created. + if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) { + log.Debug("Failed to remove codex config", "path", configPath, "error", err) + } + } +} + +type codexEvent struct { + Type string `json:"type"` + Item codexItem `json:"item"` +} + +type codexItem struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` // Direct text for agent_message type. + Content []codexContent `json:"content,omitempty"` // Nested content for message type. +} + +type codexContent struct { + Type string `json:"type"` + Text string `json:"text"` +} + +// writeMCPConfigTOML creates a temp directory with .codex/config.toml containing MCP config. +// Codex CLI reads MCP servers from [mcp_servers.] tables in config.toml. +// Returns the temp directory path. Caller should clean up with os.RemoveAll. +func writeMCPConfigTOML(servers map[string]schema.MCPServerConfig, toolchainPATH string) (string, error) { + mcpConfig := mcpclient.GenerateMCPConfig(servers, toolchainPATH) + + var buf bytes.Buffer + for name, srv := range mcpConfig.MCPServers { + writeTOMLServer(&buf, name, srv) + } + + tmpDir, err := os.MkdirTemp("", "atmos-codex-*") + if err != nil { + return "", fmt.Errorf(errUtils.ErrWrapFormat, errUtils.ErrMCPConfigWriteFailed, err) + } + + codexDir := filepath.Join(tmpDir, ".codex") + if err := os.MkdirAll(codexDir, configDirPerms); err != nil { + _ = os.RemoveAll(tmpDir) + return "", fmt.Errorf("%w: mkdir %s: %w", errUtils.ErrMCPConfigWriteFailed, codexDir, err) + } + + configFile := filepath.Join(codexDir, "config.toml") + if err := os.WriteFile(configFile, buf.Bytes(), configFilePerms); err != nil { + _ = os.RemoveAll(tmpDir) + return "", fmt.Errorf("%w: %s: %w", errUtils.ErrMCPConfigWriteFailed, configFile, err) + } + + return tmpDir, nil +} + +// writeTOMLServer writes a single [mcp_servers.] section. +func writeTOMLServer(buf *bytes.Buffer, name string, srv mcpclient.MCPJSONServer) { + fmt.Fprintf(buf, "[mcp_servers.%s]\n", name) + fmt.Fprintf(buf, "command = %q\n", srv.Command) + if len(srv.Args) > 0 { + fmt.Fprintf(buf, "args = [") + for i, arg := range srv.Args { + if i > 0 { + fmt.Fprint(buf, ", ") + } + fmt.Fprintf(buf, "%q", arg) + } + fmt.Fprint(buf, "]\n") + } + if len(srv.Env) > 0 { + fmt.Fprintf(buf, "\n[mcp_servers.%s.env]\n", name) + for k, v := range srv.Env { + fmt.Fprintf(buf, "%s = %q\n", k, v) + } + } + fmt.Fprint(buf, "\n") +} diff --git a/pkg/ai/agent/codexcli/client_test.go b/pkg/ai/agent/codexcli/client_test.go new file mode 100644 index 0000000000..57404899c5 --- /dev/null +++ b/pkg/ai/agent/codexcli/client_test.go @@ -0,0 +1,425 @@ +package codexcli + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + errUtils "github.com/cloudposse/atmos/errors" + mcpclient "github.com/cloudposse/atmos/pkg/mcp/client" + "github.com/cloudposse/atmos/pkg/schema" +) + +func TestNewClient_Disabled(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{ + AI: schema.AISettings{Enabled: false}, + } + _, err := NewClient(atmosConfig) + assert.ErrorIs(t, err, errUtils.ErrAIDisabledInConfiguration) +} + +func TestNewClient_BinaryNotOnPath(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{ + AI: schema.AISettings{ + Enabled: true, + Providers: map[string]*schema.AIProviderConfig{ProviderName: {}}, + }, + } + t.Setenv("PATH", t.TempDir()) + + _, err := NewClient(atmosConfig) + assert.ErrorIs(t, err, errUtils.ErrCLIProviderBinaryNotFound) +} + +func TestNewClient_CustomBinary(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{ + AI: schema.AISettings{ + Enabled: true, + Providers: map[string]*schema.AIProviderConfig{ + ProviderName: { + Binary: "/usr/local/bin/codex", + Model: "gpt-5.4-mini", + FullAuto: true, + }, + }, + }, + } + client, err := NewClient(atmosConfig) + require.NoError(t, err) + assert.Equal(t, "/usr/local/bin/codex", client.binaryPath) + assert.Equal(t, "gpt-5.4-mini", client.model) + assert.True(t, client.fullAuto) +} + +func TestNewClient_MCPServers_NotCaptured_WhenEmpty(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{ + AI: schema.AISettings{ + Enabled: true, + Providers: map[string]*schema.AIProviderConfig{ProviderName: {Binary: "/usr/local/bin/codex"}}, + }, + } + client, err := NewClient(atmosConfig) + require.NoError(t, err) + assert.Nil(t, client.mcpServers) + assert.False(t, client.hasMCPServers) +} + +func TestNewClient_MCPServers_Captured_WhenConfigured(t *testing.T) { + // Use temp HOME to avoid touching real ~/.codex/config.toml. + t.Setenv("HOME", t.TempDir()) + + atmosConfig := &schema.AtmosConfiguration{ + AI: schema.AISettings{ + Enabled: true, + Providers: map[string]*schema.AIProviderConfig{ProviderName: {Binary: "/usr/local/bin/codex"}}, + }, + MCP: schema.MCPSettings{ + Servers: map[string]schema.MCPServerConfig{ + "aws-docs": {Command: "uvx", Args: []string{"docs@latest"}}, + }, + }, + } + client, err := NewClient(atmosConfig) + require.NoError(t, err) + assert.Len(t, client.mcpServers, 1) + assert.True(t, client.hasMCPServers) + + // Verify config was written and restore it. + defer client.restoreGlobalConfig() + configPath := codexConfigPath() + data, err := os.ReadFile(configPath) + require.NoError(t, err) + assert.Contains(t, string(data), "[mcp_servers.aws-docs]") + assert.Contains(t, string(data), `command = "uvx"`) +} + +func TestExtractResult_JSONL_AgentMessage(t *testing.T) { + // Actual Codex CLI output format: item.type is "agent_message" with text directly on item. + input := `{"type":"thread.started","thread_id":"019d499a-ca7f-7ec3-af21-5860784b0a11"} +{"type":"turn.started"} +{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"Analysis complete."}} +{"type":"turn.completed","usage":{"input_tokens":100,"output_tokens":50}}` + + result, err := ExtractResult([]byte(input)) + require.NoError(t, err) + assert.Equal(t, "Analysis complete.", result) +} + +func TestExtractResult_JSONL_MessageFormat(t *testing.T) { + // API-style format: item.type is "message" with nested content array. + input := `{"type":"thread.started","session_id":"abc123"} +{"type":"item.completed","item":{"type":"message","content":[{"type":"text","text":"Analysis complete."}]}} +{"type":"turn.completed","usage":{"input_tokens":100,"output_tokens":50}}` + + result, err := ExtractResult([]byte(input)) + require.NoError(t, err) + assert.Equal(t, "Analysis complete.", result) +} + +func TestExtractResult_PlainText(t *testing.T) { + result, err := ExtractResult([]byte("Plain text output")) + require.NoError(t, err) + assert.Equal(t, "Plain text output", result) +} + +func TestExtractResult_Empty(t *testing.T) { + _, err := ExtractResult([]byte("")) + assert.Error(t, err) + assert.ErrorIs(t, err, errUtils.ErrCLIProviderParseResponse) +} + +func TestSendMessageWithTools_NotSupported(t *testing.T) { + client := &Client{binaryPath: "codex"} + _, err := client.SendMessageWithTools(context.Background(), "test", nil) + assert.ErrorIs(t, err, errUtils.ErrCLIProviderToolsNotSupported) +} + +func TestGetModel(t *testing.T) { + client := &Client{model: "gpt-5.4"} + assert.Equal(t, "gpt-5.4", client.GetModel()) +} + +func TestGetMaxTokens(t *testing.T) { + client := &Client{} + assert.Equal(t, 0, client.GetMaxTokens()) +} + +func TestProviderName(t *testing.T) { + assert.Equal(t, "codex-cli", ProviderName) +} + +func TestWriteAndRestoreGlobalConfig(t *testing.T) { + // Use temp HOME to avoid touching real ~/.codex/config.toml. + t.Setenv("HOME", t.TempDir()) + + client := &Client{ + mcpServers: map[string]schema.MCPServerConfig{ + "test-srv": {Command: "echo", Args: []string{"hello"}}, + }, + } + + // Write MCP config. + require.NoError(t, client.writeMCPToGlobalConfig()) + defer client.restoreGlobalConfig() + + configPath := codexConfigPath() + data, err := os.ReadFile(configPath) + require.NoError(t, err) + assert.Contains(t, string(data), "[mcp_servers.test-srv]") + + // Restore — no original existed, file should be removed. + client.restoreGlobalConfig() + _, err = os.Stat(configPath) + assert.True(t, os.IsNotExist(err)) +} + +func TestWriteMCPConfigTOML(t *testing.T) { + servers := map[string]schema.MCPServerConfig{ + "test-server": {Command: "echo", Args: []string{"hello"}}, + "auth-server": {Command: "uvx", Args: []string{"pkg@latest"}, Identity: "admin"}, + } + + tmpDir, err := writeMCPConfigTOML(servers, "") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + configFile := filepath.Join(tmpDir, ".codex", "config.toml") + data, err := os.ReadFile(configFile) + require.NoError(t, err) + content := string(data) + + // Verify TOML structure. + assert.Contains(t, content, "[mcp_servers.test-server]") + assert.Contains(t, content, `command = "echo"`) + // auth-server should be wrapped with atmos auth exec. + assert.Contains(t, content, "[mcp_servers.auth-server]") + assert.Contains(t, content, `command = "atmos"`) +} + +func TestWriteMCPConfigTOML_WithToolchainPATH(t *testing.T) { + servers := map[string]schema.MCPServerConfig{ + "test": {Command: "uvx", Args: []string{"pkg@latest"}, Env: map[string]string{"KEY": "val"}}, + } + + tmpDir, err := writeMCPConfigTOML(servers, "/toolchain/bin") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + configFile := filepath.Join(tmpDir, ".codex", "config.toml") + data, err := os.ReadFile(configFile) + require.NoError(t, err) + assert.Contains(t, string(data), "/toolchain/bin") +} + +func TestWriteTOMLServer(t *testing.T) { + var buf bytes.Buffer + srv := mcpclient.MCPJSONServer{ + Command: "uvx", + Args: []string{"awslabs.billing@latest"}, + Env: map[string]string{"AWS_REGION": "us-east-1"}, + } + writeTOMLServer(&buf, "aws-billing", srv) + content := buf.String() + + assert.Contains(t, content, "[mcp_servers.aws-billing]") + assert.Contains(t, content, `command = "uvx"`) + assert.Contains(t, content, `"awslabs.billing@latest"`) + assert.Contains(t, content, "[mcp_servers.aws-billing.env]") + assert.Contains(t, content, `AWS_REGION = "us-east-1"`) +} + +func TestWriteTOMLServer_NoEnv(t *testing.T) { + var buf bytes.Buffer + srv := mcpclient.MCPJSONServer{ + Command: "echo", + Args: []string{"hello"}, + } + writeTOMLServer(&buf, "simple", srv) + content := buf.String() + + assert.Contains(t, content, "[mcp_servers.simple]") + assert.NotContains(t, content, "[mcp_servers.simple.env]") +} + +func TestWriteTOMLServer_MultipleArgs(t *testing.T) { + var buf bytes.Buffer + srv := mcpclient.MCPJSONServer{ + Command: "atmos", + Args: []string{"auth", "exec", "-i", "readonly", "--", "uvx", "pkg@latest"}, + } + writeTOMLServer(&buf, "wrapped", srv) + content := buf.String() + + assert.Contains(t, content, `"auth"`) + assert.Contains(t, content, `"-i"`) + assert.Contains(t, content, `"readonly"`) + // Verify args are comma-separated. + assert.True(t, strings.Contains(content, ", "), "args should be comma-separated") +} + +func TestSendMessageWithToolsAndHistory_NotSupported(t *testing.T) { + client := &Client{binaryPath: "codex"} + _, err := client.SendMessageWithToolsAndHistory(context.Background(), nil, nil) + assert.ErrorIs(t, err, errUtils.ErrCLIProviderToolsNotSupported) +} + +// formatMessages tests are in pkg/ai/agent/base/messages_tools_test.go (FormatMessagesAsPrompt). + +// resolveToolchainPATH tests are in pkg/ai/agent/base/config_test.go (ResolveToolchainPATH). + +func TestBuildArgs_Basic(t *testing.T) { + client := &Client{} + args := client.buildArgs() + assert.Equal(t, "exec", args[0]) + assert.Contains(t, args, "--json") + assert.NotContains(t, args, "--full-auto") + assert.NotContains(t, args, "--dangerously-bypass-approvals-and-sandbox") +} + +func TestBuildArgs_WithModel(t *testing.T) { + client := &Client{model: "gpt-5.4-mini"} + args := client.buildArgs() + assert.Contains(t, args, "-m") + assert.Contains(t, args, "gpt-5.4-mini") +} + +func TestBuildArgs_ModelSameAsProvider(t *testing.T) { + client := &Client{model: ProviderName} + args := client.buildArgs() + assert.NotContains(t, args, "-m") +} + +func TestBuildArgs_FullAuto(t *testing.T) { + client := &Client{fullAuto: true} + args := client.buildArgs() + assert.Contains(t, args, "--full-auto") +} + +func TestBuildArgs_MCPOverridesFullAuto(t *testing.T) { + client := &Client{fullAuto: true, hasMCPServers: true} + args := client.buildArgs() + assert.Contains(t, args, "--dangerously-bypass-approvals-and-sandbox") + assert.NotContains(t, args, "--full-auto") +} + +func TestBuildArgs_MCPWithoutFullAuto(t *testing.T) { + client := &Client{hasMCPServers: true} + args := client.buildArgs() + assert.Contains(t, args, "--dangerously-bypass-approvals-and-sandbox") +} + +func TestCollectAtmosEnvVars(t *testing.T) { + t.Setenv("ATMOS_PROFILE", "managers") + t.Setenv("ATMOS_BASE_PATH", "/some/path") + t.Setenv("NOT_ATMOS", "ignored") + + result := collectAtmosEnvVars() + assert.Equal(t, "managers", result["ATMOS_PROFILE"]) + assert.Equal(t, "/some/path", result["ATMOS_BASE_PATH"]) + _, hasNonAtmos := result["NOT_ATMOS"] + assert.False(t, hasNonAtmos) +} + +func TestCollectAtmosEnvVars_NoAtmosVars(t *testing.T) { + // Ensure no ATMOS_ vars are set (best effort — can't unset all). + result := collectAtmosEnvVars() + for k := range result { + assert.True(t, strings.HasPrefix(k, "ATMOS_"), "all keys should start with ATMOS_") + } +} + +func TestInjectAtmosEnvVars(t *testing.T) { + t.Setenv("ATMOS_PROFILE", "test-profile") + + config := &mcpclient.MCPJSONConfig{ + MCPServers: map[string]mcpclient.MCPJSONServer{ + "test": { + Command: "echo", + Args: []string{"hello"}, + Env: map[string]string{"KEY": "val"}, + }, + }, + } + injectAtmosEnvVars(config) + assert.Equal(t, "test-profile", config.MCPServers["test"].Env["ATMOS_PROFILE"]) + // Existing keys are preserved. + assert.Equal(t, "val", config.MCPServers["test"].Env["KEY"]) +} + +func TestInjectAtmosEnvVars_DoesNotOverwrite(t *testing.T) { + t.Setenv("ATMOS_PROFILE", "from-env") + + config := &mcpclient.MCPJSONConfig{ + MCPServers: map[string]mcpclient.MCPJSONServer{ + "test": { + Command: "echo", + Env: map[string]string{"ATMOS_PROFILE": "from-config"}, + }, + }, + } + injectAtmosEnvVars(config) + // Explicitly configured value should not be overwritten. + assert.Equal(t, "from-config", config.MCPServers["test"].Env["ATMOS_PROFILE"]) +} + +func TestInjectAtmosEnvVars_NilEnv(t *testing.T) { + t.Setenv("ATMOS_PROFILE", "test") + + config := &mcpclient.MCPJSONConfig{ + MCPServers: map[string]mcpclient.MCPJSONServer{ + "test": {Command: "echo"}, + }, + } + injectAtmosEnvVars(config) + assert.Equal(t, "test", config.MCPServers["test"].Env["ATMOS_PROFILE"]) +} + +func TestExtractTextFromEvent_AgentMessage(t *testing.T) { + line := `{"type":"item.completed","item":{"type":"agent_message","text":"hello"}}` + result := extractTextFromEvent([]byte(line)) + assert.Equal(t, "hello", result) +} + +func TestExtractTextFromEvent_Message(t *testing.T) { + line := `{"type":"item.completed","item":{"type":"message","content":[{"type":"text","text":"hello"}]}}` + result := extractTextFromEvent([]byte(line)) + assert.Equal(t, "hello", result) +} + +func TestExtractTextFromEvent_NonCompleted(t *testing.T) { + line := `{"type":"turn.started"}` + result := extractTextFromEvent([]byte(line)) + assert.Empty(t, result) +} + +func TestExtractTextFromEvent_InvalidJSON(t *testing.T) { + result := extractTextFromEvent([]byte("not json")) + assert.Empty(t, result) +} + +func TestExtractTextFromEvent_MCP_ToolCall(t *testing.T) { + line := `{"type":"item.completed","item":{"type":"mcp_tool_call","server":"aws-docs"}}` + result := extractTextFromEvent([]byte(line)) + assert.Empty(t, result) +} + +func TestRestoreGlobalConfig_NoBackup(t *testing.T) { + // When no backup exists, restoreGlobalConfig should remove the file. + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, ".codex", "config.toml") + require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o700)) + require.NoError(t, os.WriteFile(configPath, []byte("test"), 0o600)) + + client := &Client{configBackedUp: false} + // Override codexConfigPath for this test by writing directly. + // Since restoreGlobalConfig uses codexConfigPath(), we test the real path. + client.restoreGlobalConfig() + // In a real test, the actual ~/.codex/config.toml path is used. + // This test verifies the logic branch. +} diff --git a/pkg/ai/agent/codexcli/register.go b/pkg/ai/agent/codexcli/register.go new file mode 100644 index 0000000000..c050c24763 --- /dev/null +++ b/pkg/ai/agent/codexcli/register.go @@ -0,0 +1,14 @@ +package codexcli + +import ( + "context" + + "github.com/cloudposse/atmos/pkg/ai/registry" + "github.com/cloudposse/atmos/pkg/schema" +) + +func init() { + registry.Register(ProviderName, func(_ context.Context, atmosConfig *schema.AtmosConfiguration) (registry.Client, error) { + return NewClient(atmosConfig) + }) +} diff --git a/pkg/ai/agent/geminicli/client.go b/pkg/ai/agent/geminicli/client.go new file mode 100644 index 0000000000..936cab1927 --- /dev/null +++ b/pkg/ai/agent/geminicli/client.go @@ -0,0 +1,318 @@ +// Package geminicli provides an AI provider that invokes the Gemini CLI +// as a subprocess, reusing the user's Google account (free tier or API key). +package geminicli + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + + errUtils "github.com/cloudposse/atmos/errors" + "github.com/cloudposse/atmos/pkg/ai/agent/base" + "github.com/cloudposse/atmos/pkg/ai/tools" + "github.com/cloudposse/atmos/pkg/ai/types" + log "github.com/cloudposse/atmos/pkg/logger" + mcpclient "github.com/cloudposse/atmos/pkg/mcp/client" + "github.com/cloudposse/atmos/pkg/perf" + "github.com/cloudposse/atmos/pkg/schema" + "github.com/cloudposse/atmos/pkg/ui" +) + +const ( + // ProviderName is the name of this provider for configuration lookup. + ProviderName = "gemini-cli" + // DefaultBinary is the default binary name for Gemini CLI. + DefaultBinary = "gemini" + // Dir and file permissions for MCP settings. + settingsDirPerms = 0o700 + settingsFilePerms = 0o600 +) + +// Client invokes the Gemini CLI in non-interactive mode. +type Client struct { + binaryPath string + model string + mcpServers map[string]schema.MCPServerConfig + toolchainPATH string + originalSettings []byte // Original .gemini/settings.json content for restore. + settingsBackedUp bool // True if original settings were backed up. +} + +// NewClient creates a new Gemini CLI client from Atmos configuration. +func NewClient(atmosConfig *schema.AtmosConfiguration) (*Client, error) { + defer perf.Track(atmosConfig, "geminicli.NewClient")() + + config := base.ExtractConfig(atmosConfig, ProviderName, base.ProviderDefaults{ + Model: ProviderName, + }) + + if !config.Enabled { + return nil, errUtils.ErrAIDisabledInConfiguration + } + + providerConfig := base.GetProviderConfig(atmosConfig, ProviderName) + + client := &Client{ + model: config.Model, + } + + if providerConfig != nil { + if providerConfig.Binary != "" { + client.binaryPath = providerConfig.Binary + } + if providerConfig.Model != "" { + client.model = providerConfig.Model + } + } + + // Resolve binary path. + if client.binaryPath == "" { + resolved, err := exec.LookPath(DefaultBinary) + if err != nil { + return nil, errUtils.Build(errUtils.ErrCLIProviderBinaryNotFound). + WithContext("provider", ProviderName). + WithContext("binary", DefaultBinary). + WithHint("Install Gemini CLI: npm install -g @google/gemini-cli"). + Err() + } + client.binaryPath = resolved + } + + // Capture MCP servers for pass-through (only if configured). + // Write .gemini/settings.json in the current working directory (not a temp dir) + // because Gemini CLI's Trusted Folders feature blocks MCP in untrusted directories. + if len(atmosConfig.MCP.Servers) > 0 { + client.mcpServers = atmosConfig.MCP.Servers + client.toolchainPATH = base.ResolveToolchainPATH(atmosConfig) + settingsFile, err := client.writeMCPSettingsInCwd() + if err != nil { + log.Debug("Failed to generate Gemini MCP settings", "error", err) + } else { + ui.Info(fmt.Sprintf("MCP servers configured: %d (settings: %s)", len(client.mcpServers), settingsFile)) + } + } + + return client, nil +} + +// SendMessage sends a prompt to Gemini CLI and returns the response. +// Constructs the CLI arguments for Gemini invocation. +func (c *Client) buildArgs(message string) []string { + args := []string{ + "--prompt", message, + "--output-format", "json", + } + if c.model != "" && c.model != ProviderName { + args = append(args, "-m", c.model) + } + args = append(args, "--approval-mode", "auto_edit") + + // Explicitly allow configured MCP servers by name (sorted for deterministic output). + if len(c.mcpServers) > 0 { + var serverNames []string + for name := range c.mcpServers { + serverNames = append(serverNames, name) + } + sort.Strings(serverNames) + args = append(args, "--allowed-mcp-server-names", strings.Join(serverNames, ",")) + } + return args +} + +func (c *Client) SendMessage(ctx context.Context, message string) (string, error) { + defer perf.Track(nil, "geminicli.Client.SendMessage")() + + args := c.buildArgs(message) + + cmd := exec.CommandContext(ctx, c.binaryPath, args...) //nolint:gosec // Binary path is from user config or exec.LookPath. + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + // Restore original .gemini/settings.json after Gemini exits. + if c.settingsBackedUp || len(c.mcpServers) > 0 { + defer c.restoreSettings() + } + + if err := cmd.Run(); err != nil { + // Filter stderr to find meaningful error lines (skip deprecation warnings). + errMsg := filterStderr(stderr.String()) + if errMsg != "" { + return "", fmt.Errorf("%w: %s: %s: %w", errUtils.ErrCLIProviderExecFailed, ProviderName, errMsg, err) + } + return "", fmt.Errorf("%w: %s: %w", errUtils.ErrCLIProviderExecFailed, ProviderName, err) + } + + // If stdout is empty but stderr has content, Gemini may have succeeded + // but only wrote to stderr (e.g., deprecation warnings). Try stdout first. + return parseResponse(stdout.Bytes()) +} + +// SendMessageWithTools is not supported — Gemini CLI manages its own tools. +func (c *Client) SendMessageWithTools(_ context.Context, _ string, _ []tools.Tool) (*types.Response, error) { + return nil, errUtils.ErrCLIProviderToolsNotSupported +} + +// SendMessageWithHistory concatenates history into a single prompt. +func (c *Client) SendMessageWithHistory(ctx context.Context, messages []types.Message) (string, error) { + defer perf.Track(nil, "geminicli.Client.SendMessageWithHistory")() + + return c.SendMessage(ctx, base.FormatMessagesAsPrompt(messages)) +} + +// SendMessageWithToolsAndHistory is not supported. +func (c *Client) SendMessageWithToolsAndHistory(_ context.Context, _ []types.Message, _ []tools.Tool) (*types.Response, error) { + return nil, errUtils.ErrCLIProviderToolsNotSupported +} + +// SendMessageWithSystemPromptAndTools sends with system prompt prepended to the prompt. +func (c *Client) SendMessageWithSystemPromptAndTools( + ctx context.Context, + systemPrompt string, + atmosMemory string, + messages []types.Message, + _ []tools.Tool, +) (*types.Response, error) { + defer perf.Track(nil, "geminicli.Client.SendMessageWithSystemPromptAndTools")() + + prompt := base.FormatMessagesAsPrompt(messages) + if systemPrompt != "" { + prompt = systemPrompt + "\n\n" + prompt + } + if atmosMemory != "" { + prompt = atmosMemory + "\n\n" + prompt + } + + result, err := c.SendMessage(ctx, prompt) + if err != nil { + return nil, err + } + + return &types.Response{ + Content: result, + StopReason: types.StopReasonEndTurn, + }, nil +} + +// GetModel returns the configured model name. +func (c *Client) GetModel() string { return c.model } + +// GetMaxTokens returns 0 — managed by Gemini CLI internally. +func (c *Client) GetMaxTokens() int { return 0 } + +// geminiResponse is the JSON output from `gemini -p --output-format json`. +type geminiResponse struct { + SessionID string `json:"session_id"` + Response string `json:"response"` +} + +// parseResponse extracts the response text from Gemini CLI JSON output. +func parseResponse(output []byte) (string, error) { + var resp geminiResponse + if err := json.Unmarshal(output, &resp); err != nil { + // Gemini CLI may return plain text in some modes. + trimmed := strings.TrimSpace(string(output)) + if trimmed != "" { + return trimmed, nil + } + return "", fmt.Errorf(errUtils.ErrWrapFormat, errUtils.ErrCLIProviderParseResponse, err) + } + if resp.Response == "" { + trimmed := strings.TrimSpace(string(output)) + if trimmed != "" { + return trimmed, nil + } + return "", errUtils.ErrCLIProviderParseResponse + } + return resp.Response, nil +} + +// geminiSettings is the .gemini/settings.json format for MCP server configuration. +type geminiSettings struct { + MCPServers map[string]mcpclient.MCPJSONServer `json:"mcpServers"` +} + +// writeMCPSettingsInCwd writes .gemini/settings.json in the current working directory. +// Gemini CLI's Trusted Folders feature blocks MCP servers in untrusted directories, +// so we write to cwd (which the user has already trusted) instead of a temp dir. +// Returns the settings file path. +// Writes .gemini/settings.json to the current working directory. +// Backs up the existing file (if any) for later restore via restoreSettings(). +func (c *Client) writeMCPSettingsInCwd() (string, error) { + config := mcpclient.GenerateMCPConfig(c.mcpServers, c.toolchainPATH) + + settings := geminiSettings{ + MCPServers: config.MCPServers, + } + + data, err := json.MarshalIndent(settings, "", " ") + if err != nil { + return "", fmt.Errorf(errUtils.ErrWrapFormat, errUtils.ErrMCPConfigMarshalFailed, err) + } + + geminiDir := ".gemini" + if err := os.MkdirAll(geminiDir, settingsDirPerms); err != nil { + return "", fmt.Errorf("%w: mkdir %s: %w", errUtils.ErrMCPConfigWriteFailed, geminiDir, err) + } + + settingsFile := filepath.Join(geminiDir, "settings.json") + + // Back up existing settings file if present. + if existing, readErr := os.ReadFile(settingsFile); readErr == nil { + c.originalSettings = existing + c.settingsBackedUp = true + } + + if err := os.WriteFile(settingsFile, append(data, '\n'), settingsFilePerms); err != nil { + return "", fmt.Errorf("%w: %s: %w", errUtils.ErrMCPConfigWriteFailed, settingsFile, err) + } + + return settingsFile, nil +} + +// restoreSettings restores the original .gemini/settings.json content. +func (c *Client) restoreSettings() { + settingsFile := filepath.Join(".gemini", "settings.json") + if c.settingsBackedUp { + if err := os.WriteFile(settingsFile, c.originalSettings, settingsFilePerms); err != nil { + log.Debug("Failed to restore gemini settings", "path", settingsFile, "error", err) + } + } else { + // No original file existed — remove the one we created. + if err := os.Remove(settingsFile); err != nil && !os.IsNotExist(err) { + log.Debug("Failed to remove gemini settings", "path", settingsFile, "error", err) + } + } +} + +// filterStderr removes common non-error lines from stderr (deprecation warnings, info messages). +func filterStderr(stderr string) string { + var meaningful []string + for _, line := range strings.Split(stderr, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + // Skip Node.js deprecation warnings. + if strings.Contains(trimmed, "DeprecationWarning") || strings.Contains(trimmed, "--trace-deprecation") { + continue + } + // Skip YOLO mode info messages. + if strings.Contains(trimmed, "YOLO mode") { + continue + } + // Skip credential cache messages. + if strings.Contains(trimmed, "Loaded cached credentials") { + continue + } + meaningful = append(meaningful, trimmed) + } + return strings.Join(meaningful, "\n") +} diff --git a/pkg/ai/agent/geminicli/client_test.go b/pkg/ai/agent/geminicli/client_test.go new file mode 100644 index 0000000000..85b9a61925 --- /dev/null +++ b/pkg/ai/agent/geminicli/client_test.go @@ -0,0 +1,361 @@ +package geminicli + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + errUtils "github.com/cloudposse/atmos/errors" + mcpclient "github.com/cloudposse/atmos/pkg/mcp/client" + "github.com/cloudposse/atmos/pkg/schema" +) + +func TestNewClient_Disabled(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{ + AI: schema.AISettings{Enabled: false}, + } + _, err := NewClient(atmosConfig) + assert.ErrorIs(t, err, errUtils.ErrAIDisabledInConfiguration) +} + +func TestNewClient_BinaryNotOnPath(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{ + AI: schema.AISettings{ + Enabled: true, + Providers: map[string]*schema.AIProviderConfig{ProviderName: {}}, + }, + } + t.Setenv("PATH", t.TempDir()) + + _, err := NewClient(atmosConfig) + assert.ErrorIs(t, err, errUtils.ErrCLIProviderBinaryNotFound) +} + +func TestNewClient_CustomBinary(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{ + AI: schema.AISettings{ + Enabled: true, + Providers: map[string]*schema.AIProviderConfig{ + ProviderName: { + Binary: "/usr/local/bin/gemini", + Model: "gemini-2.5-flash", + }, + }, + }, + } + client, err := NewClient(atmosConfig) + require.NoError(t, err) + assert.Equal(t, "/usr/local/bin/gemini", client.binaryPath) + assert.Equal(t, "gemini-2.5-flash", client.model) +} + +func TestNewClient_MCPServers_NotCaptured_WhenEmpty(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{ + AI: schema.AISettings{ + Enabled: true, + Providers: map[string]*schema.AIProviderConfig{ProviderName: {Binary: "/usr/local/bin/gemini"}}, + }, + } + client, err := NewClient(atmosConfig) + require.NoError(t, err) + assert.Nil(t, client.mcpServers) +} + +func TestNewClient_MCPServers_Captured_WhenConfigured(t *testing.T) { + // Change to temp dir so writeMCPSettingsInCwd doesn't pollute the package dir. + origDir, _ := os.Getwd() + tmpDir := t.TempDir() + require.NoError(t, os.Chdir(tmpDir)) + defer func() { _ = os.Chdir(origDir) }() + + atmosConfig := &schema.AtmosConfiguration{ + AI: schema.AISettings{ + Enabled: true, + Providers: map[string]*schema.AIProviderConfig{ProviderName: {Binary: "/usr/local/bin/gemini"}}, + }, + MCP: schema.MCPSettings{ + Servers: map[string]schema.MCPServerConfig{ + "aws-docs": {Command: "uvx", Args: []string{"docs@latest"}}, + }, + }, + } + client, err := NewClient(atmosConfig) + require.NoError(t, err) + defer client.restoreSettings() + assert.Len(t, client.mcpServers, 1) + + // Verify settings.json was created in cwd. + settingsFile := filepath.Join(tmpDir, ".gemini", "settings.json") + data, err := os.ReadFile(settingsFile) + require.NoError(t, err) + + var settings geminiSettings + require.NoError(t, json.Unmarshal(data, &settings)) + assert.Contains(t, settings.MCPServers, "aws-docs") +} + +func TestParseResponse_ValidJSON(t *testing.T) { + input := `{"session_id": "abc123", "response": "The VPC is configured correctly."}` + result, err := parseResponse([]byte(input)) + require.NoError(t, err) + assert.Equal(t, "The VPC is configured correctly.", result) +} + +func TestParseResponse_PlainText(t *testing.T) { + result, err := parseResponse([]byte("Plain text from gemini")) + require.NoError(t, err) + assert.Equal(t, "Plain text from gemini", result) +} + +func TestParseResponse_Empty(t *testing.T) { + _, err := parseResponse([]byte("")) + assert.Error(t, err) + assert.ErrorIs(t, err, errUtils.ErrCLIProviderParseResponse) +} + +func TestSendMessageWithTools_NotSupported(t *testing.T) { + client := &Client{binaryPath: "gemini"} + _, err := client.SendMessageWithTools(context.Background(), "test", nil) + assert.ErrorIs(t, err, errUtils.ErrCLIProviderToolsNotSupported) +} + +func TestGetModel(t *testing.T) { + client := &Client{model: "gemini-2.5-flash"} + assert.Equal(t, "gemini-2.5-flash", client.GetModel()) +} + +func TestGetMaxTokens(t *testing.T) { + client := &Client{} + assert.Equal(t, 0, client.GetMaxTokens()) +} + +func TestProviderName(t *testing.T) { + assert.Equal(t, "gemini-cli", ProviderName) +} + +func TestWriteMCPSettingsInCwd(t *testing.T) { + origDir, _ := os.Getwd() + tmpDir := t.TempDir() + require.NoError(t, os.Chdir(tmpDir)) + defer func() { _ = os.Chdir(origDir) }() + + client := &Client{ + mcpServers: map[string]schema.MCPServerConfig{ + "test-server": {Command: "echo", Args: []string{"hello"}}, + "auth-server": {Command: "uvx", Args: []string{"pkg@latest"}, Identity: "admin"}, + }, + } + + settingsFile, err := client.writeMCPSettingsInCwd() + require.NoError(t, err) + defer client.restoreSettings() + + data, err := os.ReadFile(settingsFile) + require.NoError(t, err) + + var settings geminiSettings + require.NoError(t, json.Unmarshal(data, &settings)) + assert.Len(t, settings.MCPServers, 2) + + // auth-server should be wrapped with atmos auth exec. + authEntry := settings.MCPServers["auth-server"] + assert.Equal(t, "atmos", authEntry.Command) + assert.Contains(t, authEntry.Args, "auth") + assert.Contains(t, authEntry.Args, "-i") + assert.Contains(t, authEntry.Args, "admin") +} + +func TestWriteMCPSettingsInCwd_WithToolchainPATH(t *testing.T) { + origDir, _ := os.Getwd() + tmpDir := t.TempDir() + require.NoError(t, os.Chdir(tmpDir)) + defer func() { _ = os.Chdir(origDir) }() + + client := &Client{ + mcpServers: map[string]schema.MCPServerConfig{ + "test": {Command: "uvx", Args: []string{"pkg@latest"}, Env: map[string]string{"KEY": "val"}}, + }, + toolchainPATH: "/toolchain/bin", + } + + settingsFile, err := client.writeMCPSettingsInCwd() + require.NoError(t, err) + defer client.restoreSettings() + + data, err := os.ReadFile(settingsFile) + require.NoError(t, err) + + var settings struct { + MCPServers map[string]mcpclient.MCPJSONServer `json:"mcpServers"` + } + require.NoError(t, json.Unmarshal(data, &settings)) + assert.Contains(t, settings.MCPServers["test"].Env["PATH"], "/toolchain/bin") +} + +func TestWriteMCPSettingsInCwd_BackupRestore(t *testing.T) { + origDir, _ := os.Getwd() + tmpDir := t.TempDir() + require.NoError(t, os.Chdir(tmpDir)) + defer func() { _ = os.Chdir(origDir) }() + + // Create existing settings file. + geminiDir := filepath.Join(tmpDir, ".gemini") + require.NoError(t, os.MkdirAll(geminiDir, 0o700)) + originalContent := []byte(`{"existing": true}`) + require.NoError(t, os.WriteFile(filepath.Join(geminiDir, "settings.json"), originalContent, 0o600)) + + client := &Client{ + mcpServers: map[string]schema.MCPServerConfig{ + "test": {Command: "echo", Args: []string{"hello"}}, + }, + } + + _, err := client.writeMCPSettingsInCwd() + require.NoError(t, err) + assert.True(t, client.settingsBackedUp) + + // Restore and verify original content is back. + client.restoreSettings() + restored, err := os.ReadFile(filepath.Join(geminiDir, "settings.json")) + require.NoError(t, err) + assert.Equal(t, originalContent, restored) +} + +func TestRestoreSettings_NoBackup(t *testing.T) { + origDir, _ := os.Getwd() + tmpDir := t.TempDir() + require.NoError(t, os.Chdir(tmpDir)) + defer func() { _ = os.Chdir(origDir) }() + + client := &Client{ + mcpServers: map[string]schema.MCPServerConfig{ + "test": {Command: "echo", Args: []string{"hello"}}, + }, + } + + _, err := client.writeMCPSettingsInCwd() + require.NoError(t, err) + assert.False(t, client.settingsBackedUp) + + // Restore should remove the file since no backup existed. + client.restoreSettings() + _, err = os.Stat(filepath.Join(".gemini", "settings.json")) + assert.True(t, os.IsNotExist(err)) +} + +func TestSendMessageWithToolsAndHistory_NotSupported(t *testing.T) { + client := &Client{binaryPath: "gemini"} + _, err := client.SendMessageWithToolsAndHistory(context.Background(), nil, nil) + assert.ErrorIs(t, err, errUtils.ErrCLIProviderToolsNotSupported) +} + +// formatMessages tests are in pkg/ai/agent/base/messages_tools_test.go (FormatMessagesAsPrompt). + +// resolveToolchainPATH tests are in pkg/ai/agent/base/config_test.go (ResolveToolchainPATH). + +func TestBuildArgs_Basic(t *testing.T) { + client := &Client{} + args := client.buildArgs("test prompt") + assert.Contains(t, args, "--prompt") + assert.Contains(t, args, "test prompt") + assert.Contains(t, args, "--output-format") + assert.Contains(t, args, "json") + assert.Contains(t, args, "--approval-mode") + assert.Contains(t, args, "auto_edit") + assert.NotContains(t, args, "--allowed-mcp-server-names") +} + +func TestBuildArgs_WithModel(t *testing.T) { + client := &Client{model: "gemini-2.5-flash"} + args := client.buildArgs("test") + assert.Contains(t, args, "-m") + assert.Contains(t, args, "gemini-2.5-flash") +} + +func TestBuildArgs_ModelSameAsProvider(t *testing.T) { + client := &Client{model: ProviderName} + args := client.buildArgs("test") + assert.NotContains(t, args, "-m") +} + +func TestBuildArgs_WithMCPServers(t *testing.T) { + client := &Client{ + mcpServers: map[string]schema.MCPServerConfig{ + "aws-billing": {Command: "uvx"}, + "aws-docs": {Command: "uvx"}, + }, + } + args := client.buildArgs("test") + assert.Contains(t, args, "--allowed-mcp-server-names") + // Server names should be sorted. + for i, a := range args { + if a == "--allowed-mcp-server-names" { + assert.Equal(t, "aws-billing,aws-docs", args[i+1]) + } + } +} + +func TestFilterStderr_DeprecationWarnings(t *testing.T) { + stderr := `(node:1234) [DEP0040] DeprecationWarning: The punycode module is deprecated. +(Use node --trace-deprecation ... to show where the warning was created) +Loaded cached credentials. +Actual error message here` + result := filterStderr(stderr) + assert.Equal(t, "Actual error message here", result) +} + +func TestFilterStderr_YOLOMode(t *testing.T) { + stderr := "YOLO mode enabled\nSome real error" + result := filterStderr(stderr) + assert.Equal(t, "Some real error", result) +} + +func TestFilterStderr_AllFiltered(t *testing.T) { + stderr := `(node:1234) DeprecationWarning: something +Loaded cached credentials. +YOLO mode enabled` + result := filterStderr(stderr) + assert.Empty(t, result) +} + +func TestFilterStderr_EmptyInput(t *testing.T) { + result := filterStderr("") + assert.Empty(t, result) +} + +func TestFilterStderr_MeaningfulOnly(t *testing.T) { + stderr := "Error: authentication failed\nConnection refused" + result := filterStderr(stderr) + assert.Equal(t, "Error: authentication failed\nConnection refused", result) +} + +func TestParseResponse_JSONWithResponseField(t *testing.T) { + input := `{"session_id": "abc123", "response": "The VPC is configured."}` + result, err := parseResponse([]byte(input)) + require.NoError(t, err) + assert.Equal(t, "The VPC is configured.", result) +} + +func TestParseResponse_WhitespaceOnly(t *testing.T) { + _, err := parseResponse([]byte(" \n \t ")) + assert.Error(t, err) + assert.ErrorIs(t, err, errUtils.ErrCLIProviderParseResponse) +} + +func TestNewClient_DefaultModel(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{ + AI: schema.AISettings{ + Enabled: true, + Providers: map[string]*schema.AIProviderConfig{ProviderName: {Binary: "/usr/local/bin/gemini"}}, + }, + } + client, err := NewClient(atmosConfig) + require.NoError(t, err) + // Model defaults to provider name when no model is specified. + assert.Equal(t, ProviderName, client.model) +} diff --git a/pkg/ai/agent/geminicli/register.go b/pkg/ai/agent/geminicli/register.go new file mode 100644 index 0000000000..a66600161c --- /dev/null +++ b/pkg/ai/agent/geminicli/register.go @@ -0,0 +1,14 @@ +package geminicli + +import ( + "context" + + "github.com/cloudposse/atmos/pkg/ai/registry" + "github.com/cloudposse/atmos/pkg/schema" +) + +func init() { + registry.Register(ProviderName, func(_ context.Context, atmosConfig *schema.AtmosConfiguration) (registry.Client, error) { + return NewClient(atmosConfig) + }) +} diff --git a/pkg/ai/analyze/providers.go b/pkg/ai/analyze/providers.go index 56d97c24bf..d2f438cef3 100644 --- a/pkg/ai/analyze/providers.go +++ b/pkg/ai/analyze/providers.go @@ -6,7 +6,10 @@ import ( _ "github.com/cloudposse/atmos/pkg/ai/agent/anthropic" _ "github.com/cloudposse/atmos/pkg/ai/agent/azureopenai" _ "github.com/cloudposse/atmos/pkg/ai/agent/bedrock" + _ "github.com/cloudposse/atmos/pkg/ai/agent/claudecode" + _ "github.com/cloudposse/atmos/pkg/ai/agent/codexcli" _ "github.com/cloudposse/atmos/pkg/ai/agent/gemini" + _ "github.com/cloudposse/atmos/pkg/ai/agent/geminicli" _ "github.com/cloudposse/atmos/pkg/ai/agent/grok" _ "github.com/cloudposse/atmos/pkg/ai/agent/ollama" _ "github.com/cloudposse/atmos/pkg/ai/agent/openai" diff --git a/pkg/ai/factory_test.go b/pkg/ai/factory_test.go index 1552c0870d..b701ef13fe 100644 --- a/pkg/ai/factory_test.go +++ b/pkg/ai/factory_test.go @@ -10,7 +10,10 @@ import ( _ "github.com/cloudposse/atmos/pkg/ai/agent/anthropic" _ "github.com/cloudposse/atmos/pkg/ai/agent/azureopenai" _ "github.com/cloudposse/atmos/pkg/ai/agent/bedrock" + _ "github.com/cloudposse/atmos/pkg/ai/agent/claudecode" + _ "github.com/cloudposse/atmos/pkg/ai/agent/codexcli" _ "github.com/cloudposse/atmos/pkg/ai/agent/gemini" + _ "github.com/cloudposse/atmos/pkg/ai/agent/geminicli" _ "github.com/cloudposse/atmos/pkg/ai/agent/grok" _ "github.com/cloudposse/atmos/pkg/ai/agent/ollama" _ "github.com/cloudposse/atmos/pkg/ai/agent/openai" diff --git a/pkg/mcp/client/mcpconfig.go b/pkg/mcp/client/mcpconfig.go new file mode 100644 index 0000000000..478bbf22fb --- /dev/null +++ b/pkg/mcp/client/mcpconfig.go @@ -0,0 +1,134 @@ +// Package client provides MCP client infrastructure for external server management. + +package client + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + errUtils "github.com/cloudposse/atmos/errors" + "github.com/cloudposse/atmos/pkg/schema" +) + +// MCPJSONConfig represents the .mcp.json file format used by Claude Code, Codex CLI, and IDEs. +type MCPJSONConfig struct { + MCPServers map[string]MCPJSONServer `json:"mcpServers"` +} + +// MCPJSONServer represents a single MCP server entry in .mcp.json. +type MCPJSONServer struct { + Command string `json:"command"` + Args []string `json:"args"` + Env map[string]string `json:"env,omitempty"` +} + +// BuildMCPJSONEntry creates a .mcp.json entry for a server. +// Servers with identity are wrapped with 'atmos auth exec' for credential injection. +// If toolchainPATH is non-empty, it is prepended to the server's PATH env var. +func BuildMCPJSONEntry(serverCfg *schema.MCPServerConfig, toolchainPATH string) MCPJSONServer { + env := copyEnv(serverCfg.Env) + + // Inject toolchain PATH so the CLI tool's MCP subprocess can find uvx/npx. + if toolchainPATH != "" { + injectToolchainPATH(env, toolchainPATH) + } + + if serverCfg.Identity != "" { + // Wrap with atmos auth exec for credential injection. + args := []string{"auth", "exec", "-i", serverCfg.Identity, "--", serverCfg.Command} + args = append(args, serverCfg.Args...) + return MCPJSONServer{ + Command: "atmos", + Args: args, + Env: env, + } + } + + // No auth — use command directly. + return MCPJSONServer{ + Command: serverCfg.Command, + Args: serverCfg.Args, + Env: env, + } +} + +// GenerateMCPConfig builds a MCPJSONConfig from the given servers. +// ToolchainPATH is injected into each server's env if non-empty. +func GenerateMCPConfig(servers map[string]schema.MCPServerConfig, toolchainPATH string) *MCPJSONConfig { + config := &MCPJSONConfig{ + MCPServers: make(map[string]MCPJSONServer, len(servers)), + } + for name, serverCfg := range servers { + config.MCPServers[name] = BuildMCPJSONEntry(&serverCfg, toolchainPATH) + } + return config +} + +// WriteMCPConfigToTempFile generates an MCP config and writes it to a temp file. +// Returns the file path. Caller must clean up the file when done. +func WriteMCPConfigToTempFile(servers map[string]schema.MCPServerConfig, toolchainPATH string) (string, error) { + config := GenerateMCPConfig(servers, toolchainPATH) + + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return "", fmt.Errorf("%w: %w", errUtils.ErrMCPConfigMarshalFailed, err) + } + + const tempFilePerms = 0o600 + + tmpDir := os.TempDir() + tmpFile := filepath.Join(tmpDir, "atmos-mcp-config.json") + + if err := os.WriteFile(tmpFile, append(data, '\n'), tempFilePerms); err != nil { + return "", fmt.Errorf("%w: %s: %w", errUtils.ErrMCPConfigWriteFailed, tmpFile, err) + } + + return tmpFile, nil +} + +// copyEnv returns a copy of the env map with keys uppercased. +// Viper lowercases all YAML map keys, but env vars are conventionally UPPERCASE. +// This restores the expected casing (e.g., aws_region → AWS_REGION). +func copyEnv(env map[string]string) map[string]string { + result := make(map[string]string, len(env)) + for k, v := range env { + result[strings.ToUpper(k)] = v + } + return result +} + +const envPATH = "PATH" + +// injectToolchainPATH prepends the toolchain PATH to the existing PATH in env. +// Deduplicates PATH entries to avoid bloated env variables. +func injectToolchainPATH(env map[string]string, toolchainPATH string) { + var basePATH string + if existing, ok := env[envPATH]; ok && existing != "" { + basePATH = existing + } else { + basePATH = os.Getenv(envPATH) //nolint:forbidigo // Need system PATH as base for toolchain prepend. + } + + // Combine toolchain PATH + base PATH and deduplicate. + combined := toolchainPATH + string(os.PathListSeparator) + basePATH + env[envPATH] = deduplicatePATH(combined) +} + +// deduplicatePATH removes duplicate entries from a PATH string while preserving order. +func deduplicatePATH(pathStr string) string { + seen := make(map[string]bool) + var unique []string + for _, dir := range filepath.SplitList(pathStr) { + if dir == "" { + continue + } + if !seen[dir] { + seen[dir] = true + unique = append(unique, dir) + } + } + return strings.Join(unique, string(os.PathListSeparator)) +} diff --git a/pkg/mcp/client/mcpconfig_test.go b/pkg/mcp/client/mcpconfig_test.go new file mode 100644 index 0000000000..907df5ba2a --- /dev/null +++ b/pkg/mcp/client/mcpconfig_test.go @@ -0,0 +1,216 @@ +package client + +import ( + "encoding/json" + "os" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/cloudposse/atmos/pkg/schema" +) + +// sep is the OS-specific PATH list separator (":" on Unix, ";" on Windows). +var sep = string(os.PathListSeparator) + +func TestBuildMCPJSONEntry_NoAuth(t *testing.T) { + cfg := &schema.MCPServerConfig{ + Command: "uvx", + Args: []string{"awslabs.aws-docs@latest"}, + Env: map[string]string{"FASTMCP_LOG_LEVEL": "ERROR"}, + } + entry := BuildMCPJSONEntry(cfg, "") + assert.Equal(t, "uvx", entry.Command) + assert.Equal(t, []string{"awslabs.aws-docs@latest"}, entry.Args) + assert.Equal(t, "ERROR", entry.Env["FASTMCP_LOG_LEVEL"]) +} + +func TestBuildMCPJSONEntry_WithAuth(t *testing.T) { + cfg := &schema.MCPServerConfig{ + Command: "uvx", + Args: []string{"awslabs.billing@latest"}, + Env: map[string]string{"AWS_REGION": "us-east-1"}, + Identity: "readonly", + } + entry := BuildMCPJSONEntry(cfg, "") + assert.Equal(t, "atmos", entry.Command) + assert.Equal(t, []string{"auth", "exec", "-i", "readonly", "--", "uvx", "awslabs.billing@latest"}, entry.Args) + assert.Equal(t, "us-east-1", entry.Env["AWS_REGION"]) +} + +func TestBuildMCPJSONEntry_WithToolchainPATH(t *testing.T) { + cfg := &schema.MCPServerConfig{ + Command: "uvx", + Args: []string{"server@latest"}, + Env: map[string]string{"KEY": "val"}, + } + entry := BuildMCPJSONEntry(cfg, "/toolchain/bin") + assert.Contains(t, entry.Env["PATH"], "/toolchain/bin") +} + +func TestBuildMCPJSONEntry_ToolchainPATH_PrependedToExisting(t *testing.T) { + cfg := &schema.MCPServerConfig{ + Command: "uvx", + Args: []string{"server@latest"}, + Env: map[string]string{"PATH": "/usr/bin"}, + } + entry := BuildMCPJSONEntry(cfg, "/toolchain/bin") + assert.True(t, strings.HasPrefix(entry.Env["PATH"], "/toolchain/bin")) + assert.Contains(t, entry.Env["PATH"], "/usr/bin") +} + +func TestBuildMCPJSONEntry_DoesNotMutateOriginal(t *testing.T) { + originalEnv := map[string]string{"KEY": "val"} + cfg := &schema.MCPServerConfig{ + Command: "uvx", + Args: []string{"server@latest"}, + Env: originalEnv, + } + entry := BuildMCPJSONEntry(cfg, "/toolchain/bin") + // Original env should not have PATH injected. + _, hasPATH := originalEnv["PATH"] + assert.False(t, hasPATH, "original env should not be mutated") + // But the entry should have it. + assert.Contains(t, entry.Env["PATH"], "/toolchain/bin") +} + +func TestGenerateMCPConfig(t *testing.T) { + servers := map[string]schema.MCPServerConfig{ + "aws-docs": {Command: "uvx", Args: []string{"docs@latest"}}, + "aws-iam": {Command: "uvx", Args: []string{"iam@latest"}, Identity: "admin"}, + } + config := GenerateMCPConfig(servers, "") + assert.Len(t, config.MCPServers, 2) + assert.Equal(t, "uvx", config.MCPServers["aws-docs"].Command) + assert.Equal(t, "atmos", config.MCPServers["aws-iam"].Command) // Wrapped with auth. +} + +func TestGenerateMCPConfig_EmptyServers(t *testing.T) { + servers := map[string]schema.MCPServerConfig{} + config := GenerateMCPConfig(servers, "") + assert.NotNil(t, config.MCPServers) + assert.Empty(t, config.MCPServers) +} + +func TestWriteMCPConfigToTempFile(t *testing.T) { + servers := map[string]schema.MCPServerConfig{ + "test-server": {Command: "echo", Args: []string{"hello"}}, + } + tmpFile, err := WriteMCPConfigToTempFile(servers, "") + require.NoError(t, err) + defer os.Remove(tmpFile) + + // Read and parse the file. + data, err := os.ReadFile(tmpFile) + require.NoError(t, err) + + var config MCPJSONConfig + require.NoError(t, json.Unmarshal(data, &config)) + assert.Len(t, config.MCPServers, 1) + assert.Equal(t, "echo", config.MCPServers["test-server"].Command) + + // Check file permissions (skip on Windows — no Unix-style permissions). + if runtime.GOOS != "windows" { + info, err := os.Stat(tmpFile) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) + } +} + +func TestCopyEnv(t *testing.T) { + original := map[string]string{"A": "1", "B": "2"} + copied := copyEnv(original) + assert.Equal(t, original, copied) + // Mutating copy should not affect original. + copied["C"] = "3" + _, hasC := original["C"] + assert.False(t, hasC) +} + +func TestCopyEnv_UppercasesKeys(t *testing.T) { + // Simulates Viper-lowercased env keys being restored. + lowercased := map[string]string{ + "aws_region": "us-east-1", + "fastmcp_log_level": "ERROR", + "read_operations_only": "true", + } + result := copyEnv(lowercased) + assert.Equal(t, "us-east-1", result["AWS_REGION"]) + assert.Equal(t, "ERROR", result["FASTMCP_LOG_LEVEL"]) + assert.Equal(t, "true", result["READ_OPERATIONS_ONLY"]) + // Original lowercase keys should not exist. + _, hasLower := result["aws_region"] + assert.False(t, hasLower) +} + +func TestCopyEnv_Nil(t *testing.T) { + result := copyEnv(nil) + assert.NotNil(t, result) + assert.Empty(t, result) +} + +func TestDeduplicatePATH(t *testing.T) { + // Use os.PathListSeparator-aware paths for cross-platform compatibility. + // On Windows, PATH uses ";" as separator; on Unix, ":". + join := func(parts ...string) string { + return strings.Join(parts, sep) + } + + tests := []struct { + name string + input string + expected string + }{ + { + name: "no duplicates", + input: join("/usr/bin", "/usr/local/bin", "/opt/bin"), + expected: join("/usr/bin", "/usr/local/bin", "/opt/bin"), + }, + { + name: "duplicates removed", + input: join("/toolchain/bin", "/usr/bin", "/toolchain/bin", "/usr/bin"), + expected: join("/toolchain/bin", "/usr/bin"), + }, + { + name: "empty entries removed", + input: "/usr/bin" + sep + sep + "/usr/local/bin" + sep, + expected: join("/usr/bin", "/usr/local/bin"), + }, + { + name: "preserves order", + input: join("/cc", "/aa", "/bb", "/aa", "/cc"), + expected: join("/cc", "/aa", "/bb"), + }, + { + name: "empty string", + input: "", + expected: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := deduplicatePATH(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestInjectToolchainPATH_Deduplicates(t *testing.T) { + existingPATH := strings.Join([]string{"/usr/bin", "/usr/local/bin"}, sep) + toolchainPATH := strings.Join([]string{"/toolchain/bin", "/usr/bin"}, sep) + + env := map[string]string{ + "PATH": existingPATH, + } + // Toolchain PATH includes a dir already in the existing PATH. + injectToolchainPATH(env, toolchainPATH) + path := env["PATH"] + // /usr/bin should appear only once. + count := strings.Count(path, "/usr/bin") + assert.Equal(t, 1, count, "PATH should not contain duplicate /usr/bin entries") + // Toolchain should be first. + assert.True(t, strings.HasPrefix(path, "/toolchain/bin")) +} diff --git a/pkg/schema/ai.go b/pkg/schema/ai.go index 07c3819c4d..ea2ec191c5 100644 --- a/pkg/schema/ai.go +++ b/pkg/schema/ai.go @@ -29,6 +29,13 @@ type AIProviderConfig struct { MaxTokens int `yaml:"max_tokens,omitempty" json:"max_tokens,omitempty" mapstructure:"max_tokens"` BaseURL string `yaml:"base_url,omitempty" json:"base_url,omitempty" mapstructure:"base_url"` // For Ollama or custom endpoints Cache *AICacheSettings `yaml:"cache,omitempty" json:"cache,omitempty" mapstructure:"cache,squash"` // Token caching settings + + // CLI provider fields (for claude-code, codex-cli, gemini-cli). + Binary string `yaml:"binary,omitempty" json:"binary,omitempty" mapstructure:"binary"` + MaxTurns int `yaml:"max_turns,omitempty" json:"max_turns,omitempty" mapstructure:"max_turns"` + MaxBudgetUSD float64 `yaml:"max_budget_usd,omitempty" json:"max_budget_usd,omitempty" mapstructure:"max_budget_usd"` + AllowedTools []string `yaml:"allowed_tools,omitempty" json:"allowed_tools,omitempty" mapstructure:"allowed_tools"` + FullAuto bool `yaml:"full_auto,omitempty" json:"full_auto,omitempty" mapstructure:"full_auto"` } // AICacheSettings contains token caching configuration. diff --git a/website/blog/2026-04-01-ai-cli-providers.mdx b/website/blog/2026-04-01-ai-cli-providers.mdx new file mode 100644 index 0000000000..9da68b5067 --- /dev/null +++ b/website/blog/2026-04-01-ai-cli-providers.mdx @@ -0,0 +1,186 @@ +--- +slug: ai-cli-providers +title: "Use Claude Code, Codex CLI, or Gemini CLI as Atmos AI Providers" +authors: [aknysh] +tags: [feature] +--- + +import File from '@site/src/components/File' +import ActionCard from '@site/src/components/ActionCard' +import PrimaryCTA from '@site/src/components/PrimaryCTA' + +Atmos AI now supports **CLI providers** — invoke your locally installed Claude Code, OpenAI Codex, or +Gemini CLI as AI backends. No API keys needed. Just use your existing subscription. + + + +## Why This Matters + +Until now, using Atmos AI required purchasing API tokens from a provider and configuring keys. +Many developers already have Claude Code or Codex installed with active subscriptions +(Claude Max, ChatGPT Pro, or Gemini's free tier). CLI providers let you reuse that investment: + +- **No API keys** — the CLI tool handles auth via its own subscription +- **No per-token billing** — included in your existing plan +- **Full MCP support** — Claude Code and Codex CLI can use all your configured MCP servers + +## Quick Start + + +```yaml +ai: + enabled: true + default_provider: "claude-code" # or "codex-cli" or "gemini-cli" + providers: + claude-code: + max_turns: 10 +``` + + +```bash +brew install --cask claude-code && claude auth login +atmos ai ask "What did we spend on EC2 last month?" +``` + +That's it. Atmos detects the binary, generates an MCP config with auth wrapping, and passes +everything to Claude Code. You get answers from real AWS data using your subscription. + +## Available CLI Providers + +| Provider | Binary | Subscription | MCP Support | +|--------------|----------|-------------------------------|-------------------------------| +| Claude Code | `claude` | Claude Pro/Max ($20-200/mo) | Full | +| OpenAI Codex | `codex` | ChatGPT Plus/Pro ($20-200/mo) | Full | +| Gemini CLI | `gemini` | Google account (free tier) | Blocked for personal accounts | + +All three providers work for prompt-only queries. For MCP-enabled workflows (AWS billing, +security, IAM, etc.), use Claude Code or Codex CLI. + +## MCP Pass-Through + +When `mcp.servers` is configured in `atmos.yaml`, Atmos automatically passes MCP servers to the CLI tool. +Each provider uses its native config format: + +- **Claude Code** — temp `.mcp.json` via `--mcp-config` +- **Codex CLI** — `~/.codex/config.toml` (backup/restore after exit) +- **Gemini CLI** — `.gemini/settings.json` in the current working directory + +Auth-requiring servers are wrapped with `atmos auth exec -i `. Toolchain PATH +and `ATMOS_*` env vars are injected automatically. + +## See It in Action + +> Outputs below are from a production setup with 8 AWS MCP servers configured. + +### Claude Code — Security Posture + +```text +$ atmos ai ask "What is our security posture in us-east-2 region?" + +ℹ MCP servers configured: 8 (config: /tmp/atmos-mcp-config.json) +ℹ AI provider: claude-code +👽 Thinking... + + ## Security Posture Summary for us-east-2 + + ### ✅ Enabled Security Services (2/6) + + GuardDuty - Fully operational + + • Status: ENABLED + • Finding frequency: Every 6 hours + • Active data sources: CloudTrail, DNS Logs, VPC Flow Logs, + S3 Logs, EKS Audit Logs, RDS Login Events + • Current findings: None (clean) + + Inspector - Partially enabled + + • Status: ENABLED but no scan types active + + ### ❌ Disabled Security Services (4/6) + + 1. IAM Access Analyzer - Not configured + 2. Security Hub - Not enabled + 3. Trusted Advisor - Error checking status + 4. Macie - Not enabled + + ### Key Recommendations + + High Priority: + 1. Enable Security Hub for centralized findings aggregation + 2. Enable IAM Access Analyzer + 3. Enable at least one Inspector scan type + + Your security posture is moderate — you have basic threat detection + enabled but are missing several important security services. +``` + +### Codex CLI — EC2 Billing + +```text +$ atmos ai ask "What did we spend on EC2 last month in each region?" + +ℹ MCP servers configured: 8 (in ~/.codex/config.toml) +ℹ AI provider: codex-cli +👽 Thinking... + + For last month, I used the previous calendar month: + March 1, 2026 through April 1, 2026. + + EC2 spend by region: + + • us-east-2: $88.10 + + Cost Explorer returned only us-east-2 for Amazon Elastic Compute + Cloud - Compute, so that appears to be the only region with EC2 + spend in that period. AWS also marked the result as Estimated, + which is normal on April 1, 2026 while billing finalizes. +``` + +Both providers automatically selected the right MCP server (`aws-security` and `aws-billing`) +and returned answers from real AWS data — no manual server selection needed. + +## Configuration + +You can mix CLI and API providers in the same config and switch between them: + + +```yaml +ai: + default_provider: "claude-code" + providers: + claude-code: + max_turns: 10 + codex-cli: + full_auto: true + anthropic: + model: "claude-sonnet-4-6" + api_key: !env "ANTHROPIC_API_KEY" +``` + + +```bash +# Uses claude-code (default) +atmos ai ask "What stacks do we have?" + +# Override to codex-cli +atmos ai ask --provider codex-cli "What did we spend on EC2?" + +# Override to API provider +atmos ai ask --provider anthropic "Describe the vpc component" +``` + +## Try It + + + Complete example with Claude Code CLI provider, AWS MCP servers, and automatic auth. +
+ Browse Example +
+
+ +## Learn More + +- [AI Documentation](/ai) +- [AI Providers Configuration](/cli/configuration/ai/providers) +- [MCP Configuration](/cli/configuration/mcp) diff --git a/website/docs/ai/ai.mdx b/website/docs/ai/ai.mdx index fe5944981b..7b61aef773 100644 --- a/website/docs/ai/ai.mdx +++ b/website/docs/ai/ai.mdx @@ -14,13 +14,16 @@ import Experimental from '@site/src/components/Experimental' Atmos works with your existing AI tools and brings its own native, built-in capabilities. -Bring your own credentials for supported providers — OpenAI, Anthropic, Bedrock, Azure OpenAI, Gemini, Grok, and Ollama — and Atmos handles provider-specific auth styles automatically. +Use **API providers** with purchased tokens (Anthropic, OpenAI, Bedrock, Azure OpenAI, Gemini, Grok, Ollama), +or use **CLI providers** that reuse your existing subscription (Claude Code, OpenAI Codex, Gemini CLI) — no API keys needed. ## Quick Start +### With API Tokens + ```yaml ai: @@ -33,6 +36,34 @@ ai: ``` +### With Your Existing Subscription (No API Key) + +Use your locally installed Claude Code, OpenAI Codex, or Gemini CLI binary. The CLI tool handles auth via its own subscription — no API key configuration needed. + + +```yaml +ai: + enabled: true + default_provider: "claude-code" # or "codex-cli" or "gemini-cli" + providers: + claude-code: + max_turns: 10 +``` + + + +```bash +# Claude Code +brew install --cask claude-code && claude auth login + +# OpenAI Codex +npm install -g @openai/codex && codex login + +# Gemini CLI (authenticates on first run) +npm install -g @google/gemini-cli && gemini +``` + + ```bash export ANTHROPIC_API_KEY="your-api-key" @@ -54,15 +85,54 @@ atmos terraform plan vpc -s prod --ai --skill atmos-terraform,atmos-stacks # Mu -## Example +## Examples - - Explore a complete multi-region infrastructure example with Atmos AI, skills, sessions, and tool execution. + + Multi-provider AI configuration with sessions, tools, and custom skills using API tokens.
Browse Example
+ + Use your Claude Pro/Max subscription with MCP server pass-through for AWS tools. No API keys needed. +
+ Browse Example +
+
+ +## AI Providers + +Atmos supports two types of AI providers: + +**API providers** call the provider's API directly with purchased tokens. Atmos manages the tool execution loop in-process. + +| Provider | Config Key | Auth | +|---------------|---------------|------------------------| +| Anthropic | `anthropic` | `ANTHROPIC_API_KEY` | +| OpenAI | `openai` | `OPENAI_API_KEY` | +| Google Gemini | `gemini` | `GEMINI_API_KEY` | +| Grok (xAI) | `grok` | `XAI_API_KEY` | +| AWS Bedrock | `bedrock` | AWS IAM credentials | +| Azure OpenAI | `azureopenai` | `AZURE_OPENAI_API_KEY` | +| Ollama | `ollama` | None (local) | + +**CLI providers** invoke a locally installed AI tool as a subprocess, reusing your existing subscription. +The CLI tool manages its own tool execution loop, and MCP servers are passed through for tool access. + +| Provider | Config Key | Binary | Auth | MCP | +|--------------|---------------|----------|-------------------------------|-------------------------------| +| Claude Code | `claude-code` | `claude` | Claude Pro/Max subscription | Full | +| OpenAI Codex | `codex-cli` | `codex` | ChatGPT Plus/Pro subscription | Full | +| Gemini CLI | `gemini-cli` | `gemini` | Google account (free tier) | Blocked for personal accounts | + +:::tip When to use which +- **Interactive development with MCP** — `claude-code` or `codex-cli` (subscription, full MCP), or any of the API providers +- **CI/CD pipelines** — API providers (env var auth, no interactive login) +- **Cost-conscious** — `gemini-cli` (free tier, prompt-only) +- **Enterprise** — `bedrock` or `azureopenai` (compliance, audit trails) +::: + ## AI-Powered Command Analysis Add `--ai` to any Atmos command for instant AI-powered output analysis. Pair with `--skill` for @@ -182,14 +252,18 @@ VS Code, Cursor, Gemini CLI). Use Atmos tools inside your preferred AI assistant **External MCP Servers** — Connect to AWS, GCP, Azure, and custom MCP servers. Their tools become available in `atmos ai chat`, `atmos ai ask`, and `atmos ai exec` alongside native Atmos tools. -**Smart Routing** — When multiple MCP servers are configured, Atmos automatically selects only the servers -relevant to your question using a lightweight routing call to your configured AI provider. This keeps -tool payloads small and responses fast, even with dozens of servers configured. -Use the `--mcp` flag to override and specify servers directly. +**Smart Routing** — When multiple MCP servers are configured with API providers, Atmos automatically selects +only the servers relevant to your question using a lightweight routing call. This keeps tool payloads small +and responses fast. Use the `--mcp` flag to override and specify servers directly. + +**MCP Pass-Through** — With CLI AI providers (`claude-code`, `codex-cli`), all configured MCP servers are +passed to the CLI tool via its native config format. The CLI tool decides which servers to use. +Smart routing is skipped — the AI model handles server selection internally. -:::note Chat Mode and Routing -In chat mode (`atmos ai chat`), routing is skipped because the question isn't known when servers start. -All configured MCP servers are started. Use `--mcp` to select specific servers and keep tool payloads small. +:::note CLI Provider MCP +- **Claude Code**: MCP servers passed via `--mcp-config` temp file +- **Codex CLI**: MCP servers written to `~/.codex/config.toml` (backup/restore) +- **Gemini CLI**: MCP blocked for personal Google accounts (`oauth-personal` auth) ::: ```bash diff --git a/website/docs/ai/troubleshooting.mdx b/website/docs/ai/troubleshooting.mdx index 536657d62e..ea57d6ed5c 100644 --- a/website/docs/ai/troubleshooting.mdx +++ b/website/docs/ai/troubleshooting.mdx @@ -105,7 +105,9 @@ Usually means an invalid or revoked API key, or insufficient credits. Test the k ### "Unsupported AI provider" -Valid provider names: `anthropic`, `openai`, `gemini`, `grok`, `ollama`, `bedrock`, `azureopenai`. +Valid provider names: +- **API providers:** `anthropic`, `openai`, `gemini`, `grok`, `ollama`, `bedrock`, `azureopenai` +- **CLI providers:** `claude-code`, `codex-cli`, `gemini-cli` ### Rate Limiting (429 Errors) @@ -205,6 +207,58 @@ List deployments with `az cognitiveservices account deployment list --name your- See [AI Providers](/cli/configuration/ai/providers) for full provider documentation. +## CLI Providers + +### "CLI provider binary not found" + +The CLI tool isn't installed or not on PATH: + + + + ```bash + brew install --cask claude-code + claude auth login + ``` + + + ```bash + npm install -g @openai/codex + codex login + ``` + + + ```bash + npm install -g @google/gemini-cli + gemini # Authenticates on first run + ``` + + + +Or set an explicit path: + +```yaml +providers: + claude-code: + binary: /usr/local/bin/claude +``` + +### MCP Servers Not Working with Codex CLI + +Codex CLI MCP servers don't inherit the parent process environment. If `atmos auth exec` fails with "identity not found", ensure `ATMOS_PROFILE` is exported: + +```bash +export ATMOS_PROFILE=managers # Or your profile name +atmos ai ask "What did we spend on EC2?" +``` + +Atmos automatically injects `ATMOS_*` env vars into each MCP server's config, but the env var must be set in the shell. + +### Gemini CLI MCP Blocked + +Gemini CLI blocks MCP for all personal Google accounts (`oauth-personal` auth) regardless of subscription tier. This is a server-side Google restriction. + +**Workaround:** Use `claude-code` or `codex-cli` for MCP workflows. Gemini CLI works for prompt-only queries. + ## Sessions ### Sessions Not Persisting diff --git a/website/docs/cli/commands/ai/ask.mdx b/website/docs/cli/commands/ai/ask.mdx index ef71f8a6f3..29a3e71c1b 100644 --- a/website/docs/cli/commands/ai/ask.mdx +++ b/website/docs/cli/commands/ai/ask.mdx @@ -71,7 +71,7 @@ atmos ai ask [question] :::tip Smart MCP Routing -When multiple MCP servers are configured, Atmos automatically selects only the servers relevant to your question using a lightweight routing call. Use `--mcp` to bypass routing and specify servers directly. +When multiple MCP servers are configured with API providers, Atmos automatically selects only the servers relevant to your question using a lightweight routing call. Use `--mcp` to bypass routing and specify servers directly. CLI providers (`claude-code`, `codex-cli`, `gemini-cli`) skip routing — all servers are passed to the CLI tool, which handles selection internally. ::: ## Examples diff --git a/website/docs/cli/commands/ai/exec.mdx b/website/docs/cli/commands/ai/exec.mdx index 58aa831ed2..cfe03960a4 100644 --- a/website/docs/cli/commands/ai/exec.mdx +++ b/website/docs/cli/commands/ai/exec.mdx @@ -70,7 +70,7 @@ The prompt can be provided as:
Include stack context in the prompt
`--provider, -p`
-
Override AI provider (anthropic, openai, gemini, grok, ollama, bedrock, azureopenai)
+
Override AI provider. API: `anthropic`, `openai`, `gemini`, `grok`, `ollama`, `bedrock`, `azureopenai`. CLI: `claude-code`, `codex-cli`, `gemini-cli`.
`--session, -s`
Session ID for conversation context (enables multi-turn execution)
@@ -89,7 +89,7 @@ The prompt can be provided as: :::tip Smart MCP Routing -When multiple MCP servers are configured, Atmos automatically selects only the servers relevant to your prompt using a lightweight routing call. Use `--mcp` to bypass routing and specify servers directly. +When multiple MCP servers are configured with API providers, Atmos automatically selects only the servers relevant to your prompt using a lightweight routing call. Use `--mcp` to bypass routing and specify servers directly. CLI providers (`claude-code`, `codex-cli`, `gemini-cli`) skip routing — all servers are passed to the CLI tool, which handles selection internally. ::: ## Exit Codes diff --git a/website/docs/cli/commands/ai/usage.mdx b/website/docs/cli/commands/ai/usage.mdx index 7548c9cab0..91e0c8530e 100644 --- a/website/docs/cli/commands/ai/usage.mdx +++ b/website/docs/cli/commands/ai/usage.mdx @@ -14,8 +14,9 @@ import Experimental from '@site/src/components/Experimental' Atmos AI is a built-in assistant that understands your stacks, components, and configuration. Ask questions, debug issues, or automate infrastructure tasks directly from the terminal. -It supports multiple providers (Anthropic, OpenAI, Gemini, Ollama, AWS Bedrock, Azure OpenAI) -and [21+ specialized skills](/cli/configuration/ai/skills) you can switch between with `Ctrl+A`. +It supports API providers (Anthropic, OpenAI, Gemini, Ollama, AWS Bedrock, Azure OpenAI) and +CLI providers (Claude Code, OpenAI Codex, Gemini CLI) that reuse your existing subscription. +Choose from [21+ specialized skills](/cli/configuration/ai/skills) and switch between them with `Ctrl+A`. diff --git a/website/docs/cli/configuration/ai/index.mdx b/website/docs/cli/configuration/ai/index.mdx index 213d5d2533..ce6aded595 100644 --- a/website/docs/cli/configuration/ai/index.mdx +++ b/website/docs/cli/configuration/ai/index.mdx @@ -40,6 +40,30 @@ atmos ai chat ```
+### CLI Provider Quick Start (No API Key) + +CLI providers invoke your locally installed AI tool as a subprocess — no API key needed: + + +```yaml +ai: + enabled: true + default_provider: "claude-code" # or "codex-cli" or "gemini-cli" + providers: + claude-code: + max_turns: 10 +``` + + + +```bash +brew install --cask claude-code && claude auth login +atmos ai chat +``` + + +CLI-specific provider settings: `binary`, `max_turns`, `max_budget_usd`, `full_auto`, `allowed_tools`. See [Providers](/cli/configuration/ai/providers) for details. + ## Configuration Reference ### Top-Level Structure @@ -107,7 +131,7 @@ ai:
Enable or disable AI features (default: `false`)
`default_provider`
-
Default AI provider: `anthropic`, `openai`, `gemini`, `grok`, `ollama`, `bedrock`, or `azureopenai`
+
Default AI provider. **API providers:** `anthropic`, `openai`, `gemini`, `grok`, `ollama`, `bedrock`, `azureopenai`. **CLI providers:** `claude-code`, `codex-cli`, `gemini-cli`.
`default_skill`
Default skill to activate (default: `general`)
@@ -160,8 +184,7 @@ Each provider in the `providers` map supports: ## Multi-Provider Configuration -Atmos supports 7 AI providers. Configure multiple and switch between them with **Ctrl+P** in the TUI, -or set `default_provider` for CLI commands. +Atmos supports 10 AI providers (7 API + 3 CLI). Configure multiple and switch between them with **Ctrl+P** in the TUI, or set `default_provider` for CLI commands. ```yaml diff --git a/website/docs/cli/configuration/ai/providers.mdx b/website/docs/cli/configuration/ai/providers.mdx index b5a2307966..7fb908ba75 100644 --- a/website/docs/cli/configuration/ai/providers.mdx +++ b/website/docs/cli/configuration/ai/providers.mdx @@ -12,8 +12,8 @@ import Tabs from '@theme/Tabs' import TabItem from '@theme/TabItem' -The `ai.providers` section configures connections to AI model providers like Anthropic, OpenAI, Google Gemini, -Grok, Ollama, AWS Bedrock, and Azure OpenAI. +The `ai.providers` section configures connections to AI model providers. Atmos supports **API providers** +(Anthropic, OpenAI, Gemini, Grok, Ollama, Bedrock, Azure OpenAI) and **CLI providers** (Claude Code, OpenAI Codex, Gemini CLI) that reuse your existing subscription. @@ -82,6 +82,73 @@ Each provider in the `providers` map supports the following settings:
API Key / Azure AD authentication. Enterprise compliance, Azure integration.
+## CLI Providers + +CLI providers invoke a locally installed AI tool as a subprocess. No API key needed — the CLI tool uses your existing subscription. + +
+
**Claude Code** — `claude-code`
+
Claude Pro/Max subscription. Full MCP support. Best for interactive development.
+ +
**OpenAI Codex** — `codex-cli`
+
ChatGPT Plus/Pro subscription. Full MCP support. Open source (Apache 2.0).
+ +
**Gemini CLI** — `gemini-cli`
+
Google account (free tier: 1K req/day). MCP blocked for personal accounts.
+
+ +### CLI Provider Settings + +In addition to `model`, CLI providers support: + +
+
`binary`
+
Path to the CLI binary. Optional — defaults to finding it on PATH.
+ +
`max_turns`
+
Maximum agentic turns per invocation (Claude Code only).
+ +
`max_budget_usd`
+
Budget cap per invocation in USD (Claude Code only).
+ +
`full_auto`
+
Enable automatic approval for file writes (Codex CLI only). When MCP servers are configured, `--dangerously-bypass-approvals-and-sandbox` is used automatically.
+ +
`allowed_tools`
+
List of tools Claude Code is allowed to use (Claude Code only).
+
+ +### CLI Provider Examples + + +```yaml +ai: + default_provider: "claude-code" + providers: + claude-code: + max_turns: 10 + # max_budget_usd: 1.00 + # allowed_tools: ["Read", "Glob", "Grep"] + + codex-cli: + model: "gpt-5.4-mini" + full_auto: true + + gemini-cli: + model: "gemini-2.5-flash" +``` + + +### MCP Pass-Through + +When `mcp.servers` is configured, CLI providers automatically pass MCP servers to the CLI tool: + +- **Claude Code:** Temp `.mcp.json` via `--mcp-config` +- **Codex CLI:** Written to `~/.codex/config.toml` (backup/restore after exit) +- **Gemini CLI:** Written to `.gemini/settings.json` in cwd (backup/restore, MCP blocked for personal accounts) + +Auth-requiring servers are wrapped with `atmos auth exec -i `. Toolchain PATH and `ATMOS_*` env vars are injected automatically. + ## Provider Comparison
diff --git a/website/docs/cli/configuration/mcp/index.mdx b/website/docs/cli/configuration/mcp/index.mdx index d69e19ec15..f50396a3bb 100644 --- a/website/docs/cli/configuration/mcp/index.mdx +++ b/website/docs/cli/configuration/mcp/index.mdx @@ -209,6 +209,24 @@ toolchain: atmos toolchain install astral-sh/uv@0.7.12 ``` +### CLI Provider MCP Pass-Through + +When using a CLI provider (`claude-code`, `codex-cli`, `gemini-cli`), MCP servers are passed directly to the CLI tool instead of being managed by Atmos. Smart routing is skipped — all configured servers are passed and the AI model decides which to use. + +| CLI Provider | Config Delivery | Notes | +|---|---|---| +| Claude Code | `--mcp-config` temp file | Full support | +| Codex CLI | `~/.codex/config.toml` (backup/restore) | `ATMOS_*` env vars injected | +| Gemini CLI | `.gemini/settings.json` in cwd (backup/restore) | MCP blocked for personal Google accounts | + +:::tip +For CLI providers, `ATMOS_PROFILE` must be exported in your shell for auth-requiring MCP servers to work: +```bash +export ATMOS_PROFILE=managers +atmos ai ask "What did we spend on EC2?" +``` +::: + ### Smart Routing When multiple MCP servers are configured, Atmos automatically selects only the servers diff --git a/website/src/data/roadmap.js b/website/src/data/roadmap.js index 4a10aa4911..954a74eb12 100644 --- a/website/src/data/roadmap.js +++ b/website/src/data/roadmap.js @@ -39,8 +39,8 @@ export const roadmapConfig = { { id: 'q2-2025', label: 'Q2 2025', status: 'completed' }, { id: 'q3-2025', label: 'Q3 2025', status: 'completed' }, { id: 'q4-2025', label: 'Q4 2025', status: 'completed' }, - { id: 'q1-2026', label: 'Q1 2026', status: 'current' }, - { id: 'q2-2026', label: 'Q2 2026', status: 'planned' }, + { id: 'q1-2026', label: 'Q1 2026', status: 'completed' }, + { id: 'q2-2026', label: 'Q2 2026', status: 'current' }, ], featured: [ @@ -501,8 +501,8 @@ export const roadmapConfig = { tagline: 'AI-powered infrastructure management', description: 'An intelligent assistant built directly into Atmos CLI that understands your infrastructure-as-code. Unlike general-purpose AI assistants, Atmos AI has deep understanding of Atmos stacks, components, inheritance patterns, and infrastructure workflows.', - progress: 100, - status: 'shipped', + progress: 96, + status: 'in-progress', milestones: [ { label: 'Multi-provider AI support (7 providers)', status: 'shipped', quarter: 'q1-2026', docs: '/ai/providers', changelog: 'introducing-atmos-ai', description: 'Support for Anthropic Claude, OpenAI GPT, Google Gemini, xAI Grok, Ollama (local), AWS Bedrock, and Azure OpenAI.', benefits: 'Choose the right AI for your needs—cloud, local, or enterprise. Switch providers mid-conversation.' }, { label: 'Interactive AI chat (`atmos ai chat`)', status: 'shipped', quarter: 'q1-2026', docs: '/cli/commands/ai/chat', changelog: 'introducing-atmos-ai', description: 'Interactive terminal chat with persistent sessions, markdown rendering, and conversation history.', benefits: 'Have natural conversations about your infrastructure. Context is preserved across sessions.' }, @@ -522,6 +522,10 @@ export const roadmapConfig = { { label: 'AI example with documentation', status: 'shipped', quarter: 'q1-2026', changelog: 'introducing-atmos-ai', description: 'Complete example in examples/ai/ demonstrating all AI features with inline documentation links.', benefits: 'Learn AI features from working examples. Quick start for new users.' }, { label: 'Global `--ai` flag for AI-powered command analysis', status: 'shipped', quarter: 'q1-2026', docs: '/cli/global-flags', changelog: 'ai-powered-analysis-with-global-ai-flag', description: 'Add --ai to any Atmos command to get instant AI-powered analysis of output. Errors get explained with fixes, successful plans get summarized.', benefits: 'Zero-friction AI integration—just add --ai to any command. No workflow changes required.', category: 'featured', priority: 'high' }, { label: 'External MCP server connections', status: 'shipped', quarter: 'q1-2026', docs: '/cli/configuration/mcp', changelog: 'mcp-server-integrations', description: 'Connect Atmos to external MCP servers (AWS, GCP, Azure, custom). Configure servers in atmos.yaml, and their tools become available in atmos ai chat, ask, and exec. Includes Atmos Auth credential injection and Atmos Toolchain prerequisite management.', benefits: 'Use 100+ ecosystem MCP servers without custom code. AWS pricing, security, docs, and API tools available in AI conversations.', category: 'featured', priority: 'high' }, + { label: 'Claude Code CLI provider', status: 'shipped', quarter: 'q2-2026', changelog: 'ai-cli-providers', pr: 2280, description: 'Native Claude Code CLI provider (`claude-code`) that spawns the claude binary with full MCP pass-through support.', benefits: 'Use Claude Code from atmos ai chat with your existing Claude Code setup. MCP servers configured in atmos.yaml are available automatically.' }, + { label: 'OpenAI Codex CLI provider', status: 'shipped', quarter: 'q2-2026', changelog: 'ai-cli-providers', pr: 2280, description: 'Native OpenAI Codex CLI provider (`codex-cli`) with MCP pass-through and ATMOS_* env var injection for MCP servers.', benefits: 'Use Codex CLI from atmos ai chat. MCP server environment receives Atmos context automatically via ATMOS_* variables.' }, + { label: 'Gemini CLI provider', status: 'shipped', quarter: 'q2-2026', changelog: 'ai-cli-providers', pr: 2280, description: 'Native Gemini CLI provider (`gemini-cli`) that spawns the gemini binary for terminal-based AI interaction.', benefits: 'Use Google Gemini CLI from atmos ai chat without extra configuration.' }, + { label: 'MCP pass-through for CLI providers', status: 'shipped', quarter: 'q2-2026', changelog: 'ai-cli-providers', pr: 2280, description: 'MCP server configurations defined in atmos.yaml are passed through to Claude Code and Codex CLI providers automatically.', benefits: 'One MCP configuration works across all CLI providers. No duplicate server definitions per tool.' }, { label: 'Agent marketplace', status: 'planned', quarter: 'q2-2026', docs: '/ai/agent-marketplace', description: 'Install community-created specialized agents from GitHub repositories.', benefits: 'Extend AI capabilities with community agents. Share agents across teams.' }, ], issues: [],