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