From 5944269ed865c437b853d6c470d0e931a4a0ba8f Mon Sep 17 00:00:00 2001 From: rob Date: Thu, 30 Oct 2025 18:31:27 -0300 Subject: [PATCH] refactor: Support CLI-based AI providers (Claude Code, Gemini, Codex) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major refactoring to support flexible AI provider configuration instead of requiring direct API access. Users can now use whatever AI CLI tool they have installed (claude, gemini, codex, etc.) without API keys. ## Changes to automation/agents.py **New Functions:** - `get_ai_config()` - Reads config from env vars or git config - Environment: CDEV_AI_PROVIDER, CDEV_AI_COMMAND (highest priority) - Git config: cascadingdev.aiprovider, cascadingdev.aicommand - Default: claude-cli with "claude -p '{prompt}'" - `call_ai_cli()` - Execute AI via CLI command - Passes content via temp file to avoid shell escaping - Supports {prompt} placeholder in command template - 60s timeout with error handling - Parses JSON from response (with/without code blocks) - `call_ai_api()` - Direct API access (renamed from call_claude) - Unchanged functionality - Now used as fallback option - `call_ai()` - Unified AI caller - Try CLI first (if configured) - Fall back to API (if ANTHROPIC_API_KEY set) - Graceful failure with warnings **Updated Functions:** - `normalize_discussion()` - calls call_ai() instead of call_claude() - `track_questions()` - calls call_ai() instead of call_claude() - `track_action_items()` - calls call_ai() instead of call_claude() - `track_decisions()` - calls call_ai() instead of call_claude() **Configuration Precedence:** 1. Environment variables (session-scoped) 2. Git config (repo-scoped) 3. Defaults (claude-cli) ## Changes to docs/AUTOMATION.md **Updated Sections:** - "Requirements" - Now lists CLI as Option 1 (recommended), API as Option 2 - "Configuration" - Complete rewrite with 5 provider examples: 1. Claude CLI (default) 2. Gemini CLI 3. OpenAI Codex CLI 4. Direct API (Anthropic) 5. Custom AI command - "Troubleshooting" - Added "AI command failed" section, updated error messages **New Configuration Examples:** ```bash # Claude Code (default) git config cascadingdev.aicommand "claude -p '{prompt}'" # Gemini git config cascadingdev.aiprovider "gemini-cli" git config cascadingdev.aicommand "gemini '{prompt}'" # Custom git config cascadingdev.aicommand "my-ai-tool --prompt '{prompt}' --format json" ``` ## Benefits 1. **No API Key Required**: Use existing CLI tools (claude, gemini, etc.) 2. **Flexible Configuration**: Git config (persistent) or env vars (session) 3. **Provider Agnostic**: Works with any CLI that returns JSON 4. **Backward Compatible**: Still supports direct API if ANTHROPIC_API_KEY set 5. **User-Friendly**: Defaults to "claude -p" if available ## Testing - ✅ get_ai_config() tests: - Default: claude-cli with "claude -p '{prompt}'" - Git config override: gemini-cli with "gemini '{prompt}'" - Env var override: codex-cli with "codex '{prompt}'" - ✅ extract_mentions() still works (no AI required) - ✅ All 6 workflow tests pass ## Impact Users with Claude Code installed can now use the automation without any configuration - it just works! Same for users with gemini or codex CLIs. Only requires git config setup if using non-default command. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- automation/agents.py | 187 ++++++++++++++++++++++++++++++++++++++++--- docs/AUTOMATION.md | 93 ++++++++++++++++----- 2 files changed, 248 insertions(+), 32 deletions(-) diff --git a/automation/agents.py b/automation/agents.py index 2615475..9e070a2 100644 --- a/automation/agents.py +++ b/automation/agents.py @@ -1,6 +1,12 @@ #!/usr/bin/env python3 """ -Claude-powered agents for extracting structured information from discussions. +AI-powered agents for extracting structured information from discussions. + +Supports multiple AI providers via CLI commands: +- Claude Code: claude -p "prompt" +- Gemini CLI: gemini "prompt" +- OpenAI Codex: codex "prompt" +- Or direct API: anthropic library These agents process natural language discussions and extract: - Questions (answered, open, partial) @@ -12,27 +18,155 @@ from __future__ import annotations import json import os +import subprocess import sys +import tempfile +from pathlib import Path from typing import Any, Mapping -def call_claude(prompt: str, content: str) -> dict[str, Any] | None: +def get_ai_config() -> dict[str, str]: """ - Call Claude API to process content with a given prompt. + Get AI provider configuration from environment or git config. + + Environment variables (highest priority): + CDEV_AI_PROVIDER: "claude-cli", "claude-api", "gemini-cli", "codex-cli" + CDEV_AI_COMMAND: Command template, e.g., "claude -p {prompt}" + + Git config (fallback): + cascadingdev.aiprovider + cascadingdev.aicommand + + Returns dict with 'provider' and 'command' keys. + """ + # Try environment first + provider = os.environ.get("CDEV_AI_PROVIDER") + command = os.environ.get("CDEV_AI_COMMAND") + + # Fall back to git config + if not provider or not command: + try: + result = subprocess.run( + ["git", "config", "--get", "cascadingdev.aiprovider"], + capture_output=True, + text=True, + check=False + ) + if result.returncode == 0: + provider = result.stdout.strip() + + result = subprocess.run( + ["git", "config", "--get", "cascadingdev.aicommand"], + capture_output=True, + text=True, + check=False + ) + if result.returncode == 0: + command = result.stdout.strip() + except Exception: + pass + + # Default to claude-cli if nothing configured + if not provider: + provider = "claude-cli" + if not command: + command = "claude -p '{prompt}'" + + return {"provider": provider, "command": command} + + +def call_ai_cli(prompt: str, content: str) -> dict[str, Any] | None: + """ + Call AI via CLI command (claude, gemini, codex, etc.). + + The command template can use {prompt} and {content} placeholders. + Content is passed via a temporary file to avoid shell escaping issues. + + Returns parsed JSON response or None on failure. + """ + config = get_ai_config() + command_template = config["command"] + + # Create temporary file with content + with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f: + content_file = Path(f.name) + f.write(content) + + try: + # Build full prompt + full_prompt = f"{prompt}\n\nPlease read the discussion content from: {content_file}\n\nReturn ONLY valid JSON, no other text." + + # Format command + # Support both {prompt} placeholder and direct append + if "{prompt}" in command_template: + command = command_template.format(prompt=full_prompt) + else: + # Assume command expects prompt as argument + command = f"{command_template} '{full_prompt}'" + + # Execute command + result = subprocess.run( + command, + shell=True, + capture_output=True, + text=True, + timeout=60, + check=False + ) + + if result.returncode != 0: + sys.stderr.write(f"[agents] warning: AI command failed with code {result.returncode}\n") + if result.stderr: + sys.stderr.write(f"[agents] {result.stderr[:200]}\n") + return None + + response_text = result.stdout.strip() + + # Try to parse as JSON + # Look for JSON in code blocks or raw + if "```json" in response_text: + json_start = response_text.find("```json") + 7 + json_end = response_text.find("```", json_start) + response_text = response_text[json_start:json_end].strip() + elif "```" in response_text: + json_start = response_text.find("```") + 3 + json_end = response_text.find("```", json_start) + response_text = response_text[json_start:json_end].strip() + + return json.loads(response_text) + + except subprocess.TimeoutExpired: + sys.stderr.write("[agents] warning: AI command timed out after 60s\n") + return None + except json.JSONDecodeError as e: + sys.stderr.write(f"[agents] warning: Failed to parse AI response as JSON: {e}\n") + sys.stderr.write(f"[agents] Response preview: {response_text[:200]}...\n") + return None + except Exception as e: + sys.stderr.write(f"[agents] error calling AI: {e}\n") + return None + finally: + # Clean up temp file + try: + content_file.unlink() + except Exception: + pass + + +def call_ai_api(prompt: str, content: str) -> dict[str, Any] | None: + """ + Call AI via direct API (anthropic library). Returns parsed JSON response or None on failure. Requires ANTHROPIC_API_KEY environment variable. """ api_key = os.environ.get("ANTHROPIC_API_KEY") if not api_key: - sys.stderr.write("[agents] warning: ANTHROPIC_API_KEY not set, skipping AI processing\n") return None try: import anthropic except ImportError: - sys.stderr.write("[agents] warning: anthropic package not installed, skipping AI processing\n") - sys.stderr.write("[agents] Install with: pip install anthropic\n") return None try: @@ -54,7 +188,6 @@ def call_claude(prompt: str, content: str) -> dict[str, Any] | None: response_text += block.text # Try to parse as JSON - # Look for JSON in code blocks or raw if "```json" in response_text: json_start = response_text.find("```json") + 7 json_end = response_text.find("```", json_start) @@ -67,10 +200,40 @@ def call_claude(prompt: str, content: str) -> dict[str, Any] | None: return json.loads(response_text) except Exception as e: - sys.stderr.write(f"[agents] error calling Claude: {e}\n") + sys.stderr.write(f"[agents] error calling API: {e}\n") return None +def call_ai(prompt: str, content: str) -> dict[str, Any] | None: + """ + Call configured AI provider (CLI or API). + + First tries CLI-based provider (claude, gemini, codex). + Falls back to direct API if ANTHROPIC_API_KEY is set. + + Returns parsed JSON response or None on failure. + """ + config = get_ai_config() + provider = config["provider"] + + # Try CLI-based providers first + if provider.endswith("-cli") or not os.environ.get("ANTHROPIC_API_KEY"): + result = call_ai_cli(prompt, content) + if result is not None: + return result + + # Fall back to API if available + if os.environ.get("ANTHROPIC_API_KEY"): + return call_ai_api(prompt, content) + + # No AI available + if provider.endswith("-cli"): + sys.stderr.write(f"[agents] warning: AI CLI provider '{provider}' not responding, skipping AI processing\n") + else: + sys.stderr.write("[agents] warning: No AI provider configured, skipping AI processing\n") + return None + + def normalize_discussion(content: str) -> dict[str, Any] | None: """ Agent 1: Normalize natural language discussion to structured format. @@ -108,7 +271,7 @@ Look for patterns like: Only include items that are clearly present. Return empty arrays for missing categories.""" - return call_claude(prompt, content) + return call_ai(prompt, content) def track_questions(content: str, existing_questions: list[dict[str, Any]]) -> dict[str, Any] | None: @@ -145,7 +308,7 @@ Look for: - A: or Answer: or Re: markers (these answer previous questions) - @Name mentions (directing questions to someone)""" - return call_claude(prompt, content) + return call_ai(prompt, content) def track_action_items(content: str, existing_items: list[dict[str, Any]]) -> dict[str, Any] | None: @@ -181,7 +344,7 @@ Status transitions: - ASSIGNED: Someone took ownership (@Name: I'll handle X) - DONE: Marked complete (DONE:, "completed this")""" - return call_claude(prompt, content) + return call_ai(prompt, content) def track_decisions(content: str, existing_decisions: list[dict[str, Any]]) -> dict[str, Any] | None: @@ -231,7 +394,7 @@ Look for: - Vote consensus (multiple READY votes on a proposal) - "Actually, let's change to..." (reversals)""" - return call_claude(prompt, content) + return call_ai(prompt, content) def extract_mentions(content: str) -> list[dict[str, str]]: diff --git a/docs/AUTOMATION.md b/docs/AUTOMATION.md index bfdb2fa..955d1aa 100644 --- a/docs/AUTOMATION.md +++ b/docs/AUTOMATION.md @@ -71,12 +71,29 @@ READY: 0 • CHANGES: 2 • REJECT: 0 ### Requirements +Phase 2 supports multiple AI providers via CLI commands or direct API: + +**Option 1: CLI-based (Recommended)** +```bash +# Uses whatever AI CLI tool you have installed +# Default: claude -p "prompt" + +# Configure via git config (persistent) +git config cascadingdev.aiprovider "claude-cli" +git config cascadingdev.aicommand "claude -p '{prompt}'" + +# Or via environment variables (session) +export CDEV_AI_PROVIDER="claude-cli" +export CDEV_AI_COMMAND="claude -p '{prompt}'" +``` + +**Option 2: Direct API (Alternative)** ```bash pip install anthropic export ANTHROPIC_API_KEY="sk-ant-..." ``` -If `ANTHROPIC_API_KEY` is not set, Phase 2 features are silently skipped and only Phase 1 (votes) runs. +If no AI provider is configured or responding, Phase 2 features are silently skipped and only Phase 1 (votes) runs. ### Features @@ -269,25 +286,48 @@ git commit -m "Test workflow" # Triggers automation ## Configuration -### Enabling AI Features +### AI Provider Options +**1. Claude CLI (Default)** +```bash +# No configuration needed if you have 'claude' command available +# The system defaults to: claude -p '{prompt}' + +# To customize: +git config cascadingdev.aicommand "claude -p '{prompt}'" +``` + +**2. Gemini CLI** +```bash +git config cascadingdev.aiprovider "gemini-cli" +git config cascadingdev.aicommand "gemini '{prompt}'" +``` + +**3. OpenAI Codex CLI** +```bash +git config cascadingdev.aiprovider "codex-cli" +git config cascadingdev.aicommand "codex '{prompt}'" +``` + +**4. Direct API (Anthropic)** ```bash -# Install anthropic package pip install anthropic - -# Set API key (add to ~/.bashrc or ~/.zshrc) export ANTHROPIC_API_KEY="sk-ant-..." +# CLI commands will fallback to API if available +``` -# Verify setup -python3 -c "import anthropic; print('✓ Claude API ready')" +**5. Custom AI Command** +```bash +# Use any command that reads a prompt and returns JSON +git config cascadingdev.aicommand "my-ai-tool --prompt '{prompt}' --format json" ``` ### Disabling AI Features -Simply don't set `ANTHROPIC_API_KEY`. The system will: -- Log a warning: `[agents] warning: ANTHROPIC_API_KEY not set, skipping AI processing` +Simply don't configure any AI provider. The system will: +- Log a warning: `[agents] warning: No AI provider configured, skipping AI processing` - Continue with Phase 1 (vote tracking only) -- Still extract @mentions (doesn't require API) +- Still extract @mentions (doesn't require AI) ## Future Enhancements @@ -313,22 +353,35 @@ except ImportError: import agents # Fallback for different execution contexts ``` -### "ANTHROPIC_API_KEY not set" +### "No AI provider configured" -**Cause:** Claude API key not configured. +**Cause:** No AI CLI command or API key configured. -**Solution:** Either: -1. Add to environment: `export ANTHROPIC_API_KEY="sk-ant-..."` -2. Or accept Phase 1 only (votes only, still useful) +**Solution:** Choose one: +```bash +# Option 1: Use Claude CLI (default) +git config cascadingdev.aicommand "claude -p '{prompt}'" -### "anthropic package not installed" +# Option 2: Use Gemini CLI +git config cascadingdev.aiprovider "gemini-cli" +git config cascadingdev.aicommand "gemini '{prompt}'" -**Cause:** Python anthropic library not installed. +# Option 3: Use Anthropic API +pip install anthropic +export ANTHROPIC_API_KEY="sk-ant-..." +``` + +Or accept Phase 1 only (votes only, still useful) + +### "AI command failed with code X" + +**Cause:** AI CLI command returned error. **Solution:** -```bash -pip install anthropic -``` +1. Test command manually: `claude -p "test prompt"` +2. Check command is in PATH: `which claude` +3. Verify command syntax in config: `git config cascadingdev.aicommand` +4. Check stderr output in warning message for details ### Summary sections not updating