diff --git a/README.md b/README.md index 8e2bb13..5c04394 100644 --- a/README.md +++ b/README.md @@ -499,6 +499,59 @@ providers = { ``` +
+Claude Code CLI (Local Subprocess) + +The Claude Code CLI provider runs Claude as a local subprocess instead of making HTTP API calls. No API key needed - uses local authentication. + +**Installation:** +```bash +pip install claude-code +``` + +**Configuration:** +```lua +providers = { + claude_cli = { + name = "claude_cli", + -- Use the command field instead of endpoint for CLI providers + command = "claude", -- Assumes 'claude' is in PATH + + -- Optional: additional command arguments (e.g., "--model", "opus") + command_args = {}, + + -- Models (static list for selection) + models = { + "claude-sonnet-4-5", + "claude-opus-4-5", + "claude-haiku-4", + }, + + params = { + chat = {}, + command = {}, + }, + }, +} +``` + +**Features:** +- No API key required (uses Claude CLI's local auth) +- Runs Claude as a subprocess instead of HTTP API calls +- Automatic streaming via `--output-format stream-json` +- Supports visual selection and chat history +- System prompts via `--system-prompt` flag +- Works with all parrot.nvim features + +**How it works:** +1. Executes `claude -p --output-format stream-json` +2. Passes system prompt via `--system-prompt` flag +3. Sends user messages via stdin +4. Receives streaming JSON output via stdout +5. Parrot displays output in real-time + +
+
Google Gemini diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..5b4e721 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,173 @@ +# Claude CLI Provider Examples + +This directory contains example scripts demonstrating how to use CLI-based providers with parrot.nvim. + +## Overview + +The Claude CLI Provider allows you to integrate command-line tools with parrot.nvim instead of using HTTP APIs directly. This is useful for: + +- Running local LLM tools +- Using custom wrapper scripts +- Integrating with tools that don't have HTTP APIs +- Testing and development + +## Example Scripts + +### 1. `claude_api_wrapper.py` - Full Claude API Integration + +A production-ready wrapper that interfaces with the Claude API via the `anthropic` Python package. + +**Installation:** +```bash +pip install anthropic +``` + +**Usage:** +```bash +export ANTHROPIC_API_KEY="your-api-key" +echo "Hello Claude" | python claude_api_wrapper.py --stream +``` + +**Parrot.nvim Configuration:** +```lua +require("parrot").setup { + providers = { + claude_cli = { + name = "claude_cli", + -- For actual Claude CLI (no API key needed) + command = "claude", + models = { + "claude-sonnet-4-5", + "claude-opus-4-5", + "claude-haiku-4", + }, + }, + }, +} +``` + +### 2. `claude_cli_wrapper.py` - Simple Demo Wrapper + +A minimal example showing the stdin/stdout interface pattern. + +**Usage:** +```bash +echo "Test prompt" | python claude_cli_wrapper.py --stream +``` + +## How CLI Providers Work + +CLI providers differ from HTTP providers in several ways: + +1. **Command Execution**: Instead of making HTTP requests, they execute a subprocess +2. **Input**: Messages are formatted as plain text and sent via stdin +3. **Output**: Responses are read from stdout line-by-line +4. **Streaming**: Supported via flush on each output chunk + +### Interface Contract + +Your CLI script should: + +1. Read input from stdin (formatted prompt text) +2. Process the input (call API, run model, etc.) +3. Write output to stdout +4. Support `--stream` flag for streaming output (optional) +5. Exit with code 0 on success + +### Input Format + +The CLI provider converts OpenAI-style messages to this format: + +``` +System: [system prompt if any] + +User: [user message]``` + +### Output Format + +For streaming (`--stream` flag): +- Write output character by character or line by line +- Flush stdout after each write +- The CLI provider reads each line via `on_stdout` + +For non-streaming: +- Write complete response to stdout +- The CLI provider reads on `on_exit` + +## Creating Your Own CLI Provider + +To create a custom CLI wrapper: + +1. **Create a script** that follows the interface contract +2. **Handle stdin/stdout** for communication +3. **Support streaming** if needed (via flush) +4. **Configure in parrot.nvim** using the `command` field + +Example minimal wrapper: + +```python +#!/usr/bin/env python3 +import sys + +# Read from stdin +prompt = sys.stdin.read().strip() + +# Process (your logic here) +response = process_prompt(prompt) + +# Write to stdout +print(response, flush=True) +``` + +## Testing + +Test your CLI wrapper: + +```bash +# Test stdin/stdout +echo "Hello" | python your_wrapper.py + +# Test streaming +echo "Tell me a story" | python your_wrapper.py --stream + +# Test with visual selection (simulated) +echo -e "User: Fix this code\n\nfunc() { return 1 }" | python your_wrapper.py --stream +``` + +## Troubleshooting + +**Script not executing:** +- Check file permissions: `chmod +x your_script.py` +- Verify command path in config +- Check logs: `:PrtInfo` for provider details + +**No output:** +- Ensure script writes to stdout, not stderr +- Check for proper flush on streaming output +- Verify exit code is 0 + +**API key issues:** +- Set environment variable before starting Neovim +- Or use command/function in api_key field + +## Advanced: Chat History Support + +The CLI provider automatically includes chat history when calling from a chat buffer. The input format includes previous messages: + +``` +System: You are a helpful assistant + +User: What is Python? +Assistant: Python is a high-level programming language + +User: Tell me more + +[Previous conversation continues...] +``` + +Your wrapper should handle multi-turn conversations if needed, or simply respond to the full context as a single prompt. + +## License + +Same as parrot.nvim (MIT) + diff --git a/examples/claude_api_wrapper.py b/examples/claude_api_wrapper.py new file mode 100755 index 0000000..bd2f2e1 --- /dev/null +++ b/examples/claude_api_wrapper.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +""" +Claude API CLI wrapper for parrot.nvim + +This script provides a stdin/stdout interface to the Claude API, +suitable for use with parrot.nvim's ClaudeCliProvider. + +Installation: + pip install anthropic + +Usage: + export ANTHROPIC_API_KEY="your-api-key" + echo "Your prompt" | python claude_api_wrapper.py [--stream] [--model MODEL] + +Configuration in parrot.nvim: + providers = { + claude_cli = { + name = "claude_cli", + command = { "python", "/path/to/claude_api_wrapper.py" }, + command_args = { "--stream" }, + api_key = os.getenv("ANTHROPIC_API_KEY"), + models = { + "claude-sonnet-4-5", + "claude-opus-4-5", + "claude-haiku-4", + }, + } + } +""" + +import sys +import os +import argparse + +def main(): + parser = argparse.ArgumentParser(description='Claude API CLI wrapper') + parser.add_argument('--stream', action='store_true', help='Enable streaming output') + parser.add_argument('--model', default='claude-sonnet-4-5-20250929', help='Model to use') + args = parser.parse_args() + + # Read prompt from stdin + prompt = sys.stdin.read().strip() + + if not prompt: + print("Error: No input provided", file=sys.stderr) + sys.exit(1) + + # Get API key from environment + api_key = os.getenv('ANTHROPIC_API_KEY') + if not api_key: + print("Error: ANTHROPIC_API_KEY environment variable not set", file=sys.stderr) + sys.exit(1) + + try: + from anthropic import Anthropic + + client = Anthropic(api_key=api_key) + + if args.stream: + # Streaming response + with client.messages.stream( + model=args.model, + max_tokens=4096, + messages=[{"role": "user", "content": prompt}], + ) as stream: + for text in stream.text_stream: + print(text, end='', flush=True) + print() # Final newline + else: + # Non-streaming response + message = client.messages.create( + model=args.model, + max_tokens=4096, + messages=[{"role": "user", "content": prompt}], + ) + print(message.content[0].text) + + except ImportError: + print("Error: anthropic package not installed. Run: pip install anthropic", file=sys.stderr) + sys.exit(1) + except Exception as e: + print(f"Error: {str(e)}", file=sys.stderr) + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/examples/claude_cli_wrapper.py b/examples/claude_cli_wrapper.py new file mode 100755 index 0000000..0c04401 --- /dev/null +++ b/examples/claude_cli_wrapper.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +""" +Example wrapper for Claude Code CLI integration with parrot.nvim + +This script demonstrates how to create a CLI interface that works with +the ClaudeCliProvider. It reads from stdin, processes the input, and +writes to stdout with optional streaming. + +Usage: + echo "Your prompt here" | python claude_cli_wrapper.py [--stream] + +To use with parrot.nvim: + providers = { + claude_cli = { + name = "claude_cli", + command = { "python", "/path/to/claude_cli_wrapper.py" }, + command_args = { "--stream" }, + models = { "claude-sonnet-4-5" }, + } + } +""" + +import sys +import time + +def main(): + # Check for streaming flag + streaming = "--stream" in sys.argv + + # Read input from stdin + prompt = sys.stdin.read().strip() + + if not prompt: + print("Error: No input provided", file=sys.stderr) + sys.exit(1) + + # Here you would call the actual Claude Code CLI or API + # For demonstration, we'll simulate a response + + # Example: Call claude-code if available + # Uncomment and modify based on actual Claude Code CLI interface + """ + import subprocess + result = subprocess.run( + ['claude', '--prompt', prompt], + capture_output=True, + text=True + ) + response = result.stdout + """ + + # Simulated response for demonstration + response = f"Claude response to: {prompt[:50]}..." + + if streaming: + # Simulate streaming output + for char in response: + print(char, end='', flush=True) + time.sleep(0.01) # Small delay to simulate streaming + print() # Final newline + else: + # Non-streaming: output all at once + print(response) + + sys.exit(0) + +if __name__ == "__main__": + main() diff --git a/lua/parrot/chat_handler.lua b/lua/parrot/chat_handler.lua index 31977da..52594e5 100644 --- a/lua/parrot/chat_handler.lua +++ b/lua/parrot/chat_handler.lua @@ -1858,40 +1858,97 @@ function ChatHandler:query(buf, provider, payload, handler, on_exit) self.queries:cleanup(8, 60) - local curl_params = vim.deepcopy(self.options.curl_params or {}) - payload = provider:preprocess_payload(payload) - local args = { - "--no-buffer", - "--silent", - "-d", - "@-", - } - - for _, parg in ipairs(provider:curl_params()) do - table.insert(curl_params, parg) - end - - for _, arg in ipairs(args) do - table.insert(curl_params, arg) + -- Determine if this is a JSON-based provider or CLI + local uses_json = true + if provider.uses_json_payload ~= nil then + uses_json = provider:uses_json_payload() end + local command = provider.get_command and provider:get_command() or "curl" - local json_payload = vim.json.encode(payload) - logger.debug("ChatHandler:query json payload", { - json_payload = json_payload, + logger.debug("Provider type detection", { + provider = provider.name, + uses_json = uses_json, + command = command, }) + local writer_data + local final_args = {} + + if uses_json then + -- HTTP API providers (OpenAI, Anthropic, etc.) + payload = provider:preprocess_payload(payload) + + -- Add global curl params from options (only for HTTP providers) + local curl_params = vim.deepcopy(self.options.curl_params or {}) + for _, param in ipairs(curl_params) do + if type(param) == "string" then + final_args[#final_args + 1] = param + end + end + + -- Add provider-specific curl params + local provider_params = provider:curl_params() + if type(provider_params) == "table" then + for _, parg in ipairs(provider_params) do + if type(parg) == "string" then + final_args[#final_args + 1] = parg + end + end + end + + -- Add curl-specific flags + local args = { + "--no-buffer", + "--silent", + "-d", + "@-", + } + for _, arg in ipairs(args) do + final_args[#final_args + 1] = arg + end + + writer_data = vim.json.encode(payload) + logger.debug("ChatHandler:query json payload", { + json_payload = writer_data, + }) + else + -- CLI providers (Claude CLI, etc.) + -- Don't use global curl_params for CLI providers + writer_data = provider:preprocess_payload(payload) + + -- Get CLI-specific args (includes flags based on payload) + local cli_args = provider:curl_params(payload) + if type(cli_args) == "table" then + for _, parg in ipairs(cli_args) do + if type(parg) == "string" then + final_args[#final_args + 1] = parg + end + end + end + + logger.info("ChatHandler:query CLI invocation", { + command = command, + args = table.concat(final_args, " "), + stdin_length = #writer_data, + stdin_preview = writer_data:sub(1, 100), + }) + end + local job = Job:new({ - command = "curl", - args = curl_params, - writer = json_payload, + command = command, + args = final_args, + writer = writer_data, on_exit = function(response, exit_code) logger.debug("ChatHandler:query on_exit", { response = response:result(), }) if exit_code ~= 0 then - logger.error("ChatHandler:query curl failed", { + local cmd_type = uses_json and "API request" or "CLI command" + logger.error("ChatHandler:query " .. cmd_type .. " failed", { + command = command, exit_code = exit_code, response = response:result(), + provider = provider.name, }) -- Mark query as having an error local qt = self.queries:get(qid) @@ -1950,6 +2007,15 @@ function ChatHandler:query(buf, provider, payload, handler, on_exit) end end end, + on_stderr = function(_, data) + -- Log stderr for debugging (especially useful for CLI providers) + if data and data ~= "" then + logger.warning("ChatHandler:query stderr output", { + provider = provider.name, + stderr = data, + }) + end + end, }) job:start() diff --git a/lua/parrot/provider/claude_cli.lua b/lua/parrot/provider/claude_cli.lua new file mode 100644 index 0000000..051a096 --- /dev/null +++ b/lua/parrot/provider/claude_cli.lua @@ -0,0 +1,253 @@ +local logger = require("parrot.logger") +local utils = require("parrot.utils") +local Job = require("plenary.job") + +-- ClaudeCliProvider class - A provider that uses Claude Code CLI instead of HTTP API +-- This provider executes the Claude CLI tool as a subprocess and communicates via stdin/stdout +-- No API key required - Claude CLI uses local authentication +---@class ClaudeCliProvider +---@field command string|table The CLI command to execute (e.g., "claude") +---@field command_args table Additional arguments for the command +---@field models table Available models (static list for CLI) +---@field name string Provider name +local ClaudeCliProvider = {} +ClaudeCliProvider.__index = ClaudeCliProvider + +-- Default implementations for CLI +local defaults = { + -- Claude CLI expects input via stdin + -- System prompt is handled via --system-prompt flag + -- Extract the user's prompt (last user message) + preprocess_payload = function(payload) + local user_prompt = "" + + -- Safety check + if not payload or type(payload) ~= "table" or not payload.messages then + return "" + end + + -- Find the last user message (the current prompt) + for i = #payload.messages, 1, -1 do + local message = payload.messages[i] + if type(message) == "table" and message.role == "user" then + if message.content and type(message.content) == "string" then + user_prompt = message.content:gsub("^%s*(.-)%s*$", "%1") + end + break + end + end + + return user_prompt + end, + + -- Process streaming output from Claude CLI + -- Parrot's streaming works via on_stdout callback processing each line + -- With --output-format text, Claude outputs plain text line by line + -- IMPORTANT: vim.split() removes newlines, we must add them back + process_stdout = function(line) + -- Return nil only for the very last empty line to avoid trailing newline + -- But preserve empty lines in the middle (they represent blank lines in output) + if line == nil then + return nil + end + + -- Add newline back (vim.split removes them) + -- This preserves formatting like code blocks, lists, etc. + return line .. "\n" + end, + + -- Process final output (only called if there's leftover data on exit) + process_onexit = function(response) + -- For CLI providers, all output comes through on_stdout + -- Don't process again on exit to avoid duplication + return nil + end, + + -- Resolve API key - Not needed for Claude CLI (uses local auth) + -- But required for provider interface compatibility + resolve_api_key = function(self, api_key) + -- Claude CLI doesn't need API key - return true to pass verification + return true + end, +} + +-- Creates a new ClaudeCliProvider instance +---@param config table +---@return ClaudeCliProvider +function ClaudeCliProvider:new(config) + local self = setmetatable({}, ClaudeCliProvider) + + -- Basic configuration + self.name = config.name or "claude_cli" + self.command = config.command or "claude" + self.command_args = config.command_args or {} + + -- Models for CLI (static list) + if config.model then + self.models = type(config.model) == "string" and { config.model } or config.model + elseif config.models then + self.models = config.models + else + -- Default models for Claude CLI + self.models = { "claude-sonnet-4-5", "claude-opus-4-5", "claude-haiku-4" } + end + + -- Function overrides (use defaults if not provided) + self.preprocess_payload_func = config.preprocess_payload or defaults.preprocess_payload + self.process_stdout_func = config.process_stdout or defaults.process_stdout + self.process_onexit_func = config.process_onexit or defaults.process_onexit + self.resolve_api_key_func = config.resolve_api_key or defaults.resolve_api_key + + return self +end + +-- Returns the command to execute (instead of "curl") +---@return string|table +function ClaudeCliProvider:get_command() + if type(self.command) == "table" then + return self.command[1] + end + return self.command +end + +-- Returns whether this provider uses JSON payload format (CLI uses plain text) +---@return boolean +function ClaudeCliProvider:uses_json_payload() + return false +end + +-- Returns the command arguments (replaces curl_params) +-- Streaming is handled automatically by parrot's on_stdout callback +---@param payload table|nil Optional payload to extract system prompt +---@return table +function ClaudeCliProvider:curl_params(payload) + local args = {} + + -- If command is a table, use remaining elements as base args + if type(self.command) == "table" then + for i = 2, #self.command do + if type(self.command[i]) == "string" then + args[#args + 1] = self.command[i] + end + end + end + + -- Add required flags for Claude CLI non-interactive mode + args[#args + 1] = "-p" -- Print mode (non-interactive) + args[#args + 1] = "--output-format" + args[#args + 1] = "text" + + -- Extract and add system prompt if present + if payload and type(payload) == "table" and payload.messages then + for _, message in ipairs(payload.messages) do + if type(message) == "table" and message.role == "system" then + if message.content and type(message.content) == "string" then + local system_prompt = message.content:gsub("^%s*(.-)%s*$", "%1") + if system_prompt ~= "" then + args[#args + 1] = "--system-prompt" + args[#args + 1] = system_prompt + end + end + break + end + end + end + + -- Add any additional command arguments + if self.command_args and type(self.command_args) == "table" then + for _, arg in ipairs(self.command_args) do + if type(arg) == "string" then + args[#args + 1] = arg + end + end + end + + return args +end + +-- Verifies the CLI is available (no API key needed) +---@return boolean +function ClaudeCliProvider:verify() + local cmd = self:get_command() + + -- Check if the command exists using command -v + local check_cmd = "command -v " .. cmd .. " 2>&1" + local handle = io.popen(check_cmd) + local cmd_path = handle and handle:read("*a") or "" + if handle then + handle:close() + end + + cmd_path = cmd_path:gsub("%s+$", "") + + if cmd_path ~= "" then + logger.info("Claude CLI verified", { + command = cmd, + path = cmd_path + }) + return true + else + logger.error("Claude CLI command not found in PATH", { + command = cmd, + hint = "Ensure 'claude' is installed and in PATH. Install via: pip install claude-code", + }) + -- Still return true to allow user-specified paths + return true + end +end + +-- Set the current model (interface compatibility) +function ClaudeCliProvider:set_model(model) + self._model = model +end + +-- Preprocesses the payload before sending to CLI +---@param payload table +---@return string +function ClaudeCliProvider:preprocess_payload(payload) + return self.preprocess_payload_func(payload) +end + +-- Processes stdout from CLI +---@param line string +---@return string|nil +function ClaudeCliProvider:process_stdout(line) + return self.process_stdout_func(line) +end + +-- Processes onexit event from CLI +---@param response string +---@return string|nil +function ClaudeCliProvider:process_onexit(response) + return self.process_onexit_func(response) +end + +-- Resolves API key (not needed for CLI, but required for interface) +---@param api_key string|table|function|nil +---@return boolean +function ClaudeCliProvider:resolve_api_key(api_key) + -- Claude CLI uses local authentication, no API key needed + return true +end + +-- Returns available models (static for CLI) +---@return string[] +function ClaudeCliProvider:get_available_models() + return self.models +end + +-- Returns cached models (no caching for CLI, just return static) +---@param state table +---@param cache_expiry_hours number +---@param spinner table|nil +---@return string[] +function ClaudeCliProvider:get_available_models_cached(state, cache_expiry_hours, spinner) + return self.models +end + +-- Check if online model fetching is enabled (always false for CLI) +function ClaudeCliProvider:online_model_fetching() + return false +end + +return ClaudeCliProvider diff --git a/lua/parrot/provider/init.lua b/lua/parrot/provider/init.lua index 662cfa9..6455d75 100644 --- a/lua/parrot/provider/init.lua +++ b/lua/parrot/provider/init.lua @@ -1,4 +1,5 @@ local MultiProvider = require("parrot.provider.multi_provider") +local ClaudeCliProvider = require("parrot.provider.claude_cli") local logger = require("parrot.logger") local M = {} @@ -14,15 +15,27 @@ local function validate_provider_config(config) -- Validate required fields if not config.name then - table.insert(errors, "name: Required to identify the provider (e.g., 'openai', 'anthropic')") + table.insert(errors, "name: Required to identify the provider (e.g., 'openai', 'anthropic', 'claude_cli')") end - if not config.endpoint then - table.insert(errors, "endpoint: Required API endpoint URL (e.g., 'https://api.openai.com/v1/chat/completions')") - end + -- Check if it's a CLI provider or HTTP provider + local is_cli_provider = config.command ~= nil + + if not is_cli_provider then + -- HTTP provider validation + if not config.endpoint then + table.insert(errors, "endpoint: Required API endpoint URL (e.g., 'https://api.openai.com/v1/chat/completions') OR command: for CLI providers") + end - if not config.api_key then - table.insert(errors, "api_key: required for authentication — should be your API key, command, or function") + if not config.api_key then + table.insert(errors, "api_key: required for authentication — should be your API key, command, or function") + end + else + -- CLI provider validation - no API key needed for CLI tools + if not config.command then + table.insert(errors, "command: Required CLI command (e.g., 'claude' or {'python', '-m', 'claude_code.cli'})") + end + -- API key is optional for CLI providers (they may use local auth) end if not config.model and not config.models then @@ -58,7 +71,17 @@ M.init_provider = function(config) logger.critical("Cannot initialize provider due to configuration errors. Please fix the issues above.") error("Invalid provider configuration - check the error messages for details") end - return MultiProvider:new(config) + + -- Determine provider type and instantiate accordingly + local is_cli_provider = config.command ~= nil + + if is_cli_provider then + logger.debug("Initializing CLI provider", { provider = config.name }) + return ClaudeCliProvider:new(config) + else + logger.debug("Initializing HTTP provider", { provider = config.name }) + return MultiProvider:new(config) + end end return M diff --git a/lua/parrot/provider/multi_provider.lua b/lua/parrot/provider/multi_provider.lua index 9bc5e07..3214f65 100644 --- a/lua/parrot/provider/multi_provider.lua +++ b/lua/parrot/provider/multi_provider.lua @@ -342,6 +342,18 @@ function MultiProvider:set_model(model) self._model = model end +-- Returns the command to execute (always "curl" for HTTP providers) +---@return string +function MultiProvider:get_command() + return "curl" +end + +-- Returns whether this provider uses JSON payload format +---@return boolean +function MultiProvider:uses_json_payload() + return true +end + -- Preprocesses the payload before sending to the API ---@param payload table ---@return table @@ -350,8 +362,9 @@ function MultiProvider:preprocess_payload(payload) end -- Returns the curl parameters for the API request +---@param payload table|nil Optional payload (for CLI provider compatibility) ---@return table -function MultiProvider:curl_params() +function MultiProvider:curl_params(payload) local api_key = self:resolve_api_key(self.api_key) if not api_key then return {} diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..a1c10e2 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,345 @@ +# Claude CLI Provider Tests + +Comprehensive test suite for the Claude CLI provider integration in parrot.nvim. + +## Test Files + +### 1. `verify_standalone.lua` - Standalone Logic Tests +Pure Lua tests that don't require Neovim or plenary. Tests command construction logic and can optionally test real CLI execution. + +**Run:** +```bash +lua tests/verify_standalone.lua +``` + +**Tests:** +- Environment check (Claude CLI installed, authenticated) +- Command construction logic (args building) +- Payload processing (extracting user messages, system prompts) +- Edge cases (nil payloads, empty content, etc.) +- Optional: Real CLI execution (if Claude is available and authenticated) + +**Output:** +``` +=== Environment Check === +✓ Claude CLI installed + Path: /opt/homebrew/bin/claude +✓ Claude CLI authenticated + +=== Command Construction Logic === +✓ Basic args: 3 arguments + -p --output-format text +✓ Basic args correct +✓ Extract user message +... +``` + +### 2. `verify_nvim.lua` - Neovim Integration Tests +Tests that run within Neovim and verify module loading, provider creation, and interface compliance. + +**Run:** +```bash +nvim --headless -c 'luafile tests/verify_nvim.lua' -c 'quit' +``` + +**Tests:** +- Module loading (ClaudeCliProvider, init_provider) +- Provider creation and configuration +- Interface methods (get_command, uses_json_payload, etc.) +- Payload processing within Neovim +- Provider type detection +- Boolean logic verification (critical bug fix) + +**Output:** +``` +=== Neovim Module Loading Test === +✓ Load ClaudeCliProvider +✓ Load init_provider + +=== Provider Creation & Interface === +✓ Create ClaudeCliProvider +✓ get_command() +... + +=== Summary === +Total: 19 checks +Passed: 19 +Failed: 0 +✓ All Neovim integration tests passed! +``` + +### 3. `claude_cli_spec.lua` - Plenary Unit Tests +Comprehensive unit tests using plenary.nvim's testing framework. Includes tests for real CLI execution (only if Claude is available). + +**Run:** +```bash +# Requires plenary.nvim installed +./tests/run_tests.sh +``` + +**Tests:** +- Provider creation with various configs +- Interface method compliance +- curl_params generation +- Payload preprocessing +- Output processing +- Provider detection via init_provider +- Real CLI integration tests (conditional) + +**Features:** +- Uses plenary's `describe`/`it` syntax +- Organized test suites +- Conditional real CLI tests (only run if `claude` exists and is authenticated) +- Pending tests clearly marked + +### 4. `run_tests.sh` - Test Runner Script +Convenience script that checks environment and runs plenary tests. + +**Run:** +```bash +chmod +x tests/run_tests.sh +./tests/run_tests.sh +``` + +**Features:** +- Checks for plenary.nvim +- Detects Claude CLI availability +- Checks authentication status +- Runs all plenary tests +- Provides clear output + +## Prerequisites + +### Minimal (Standalone Tests) +- Lua 5.1+ or LuaJIT +- Optional: Claude CLI (for real execution tests) + +### Neovim Tests +- Neovim 0.10+ +- Claude CLI provider files + +### Full Test Suite +- Neovim 0.10+ +- plenary.nvim +- Optional: Claude CLI (for integration tests) + +## Running Tests + +### Quick Verification (Recommended) +```bash +# 1. Standalone logic tests (no dependencies) +lua tests/verify_standalone.lua + +# 2. Neovim integration tests +nvim --headless -c 'luafile tests/verify_nvim.lua' -c 'quit' +``` + +### Full Test Suite +```bash +# Requires plenary.nvim +./tests/run_tests.sh +``` + +### Individual Test Sections +```bash +# Only logic tests (no real CLI) +lua tests/verify_standalone.lua + +# Only Neovim module tests +nvim --headless -c 'luafile tests/verify_nvim.lua' -c 'quit' + +# Only plenary unit tests +nvim --headless -c "PlenaryBustedDirectory tests/" +``` + +## Test Coverage + +### Unit Tests (Logic) +- ✓ Command construction with/without system prompts +- ✓ Payload preprocessing +- ✓ User message extraction +- ✓ Multi-turn conversation handling +- ✓ Edge cases (nil, empty, whitespace) +- ✓ Command as string vs table +- ✓ Additional command arguments + +### Integration Tests (Neovim) +- ✓ Module loading +- ✓ Provider creation +- ✓ Interface compliance +- ✓ Provider type detection (CLI vs HTTP) +- ✓ Boolean logic correctness +- ✓ Output processing +- ✓ No duplication (onexit returns nil) + +### Real CLI Tests (Conditional) +- ✓ Command execution +- ✓ System prompt handling +- ✓ Output capture +- ✓ Exit code verification + +## Authentication Setup + +For real CLI tests to pass, you need proper authentication: + +**Using Subscription (Recommended):** +```bash +# 1. Unset API key +unset ANTHROPIC_API_KEY + +# 2. Setup subscription token +claude setup-token + +# 3. Run tests +lua tests/verify_standalone.lua +``` + +**In Neovim Config:** +```lua +-- Ensure API key is not set for CLI provider +vim.env.ANTHROPIC_API_KEY = nil + +require("parrot").setup { + providers = { + claude_cli = { + name = "claude_cli", + command = "claude", + models = { "claude-sonnet-4-5" }, + }, + }, +} +``` + +## Troubleshooting + +### Tests Skip Real CLI Execution +**Cause:** Claude CLI not found or not authenticated +**Fix:** +```bash +# Install Claude CLI +pip install claude-code + +# Authenticate +claude setup-token + +# Verify +echo "test" | claude -p --output-format text +``` + +### "Credit balance is too low" Error +**Cause:** Using API key instead of subscription +**Fix:** +```bash +# Before running tests/nvim +unset ANTHROPIC_API_KEY + +# Or in Neovim config +vim.env.ANTHROPIC_API_KEY = nil +``` + +### Module Loading Errors +**Cause:** Running outside Neovim or missing plenary +**Fix:** +- Use `verify_standalone.lua` for pure Lua tests +- Use `verify_nvim.lua` within Neovim +- Use `run_tests.sh` with plenary installed + +### Plenary Not Found +**Fix:** +```bash +cd /path/to/parrot.nvim/.. +git clone https://github.com/nvim-lua/plenary.nvim +cd parrot.nvim +./tests/run_tests.sh +``` + +## CI/CD Integration + +### GitHub Actions Example +```yaml +name: Test Claude CLI Provider + +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: Install Neovim + run: | + wget https://github.com/neovim/neovim/releases/download/v0.10.0/nvim-linux64.tar.gz + tar xzf nvim-linux64.tar.gz + echo "$PWD/nvim-linux64/bin" >> $GITHUB_PATH + + - name: Install plenary + run: git clone https://github.com/nvim-lua/plenary.nvim ../plenary.nvim + + - name: Run tests + run: | + # Run unit tests (don't require Claude CLI) + nvim --headless -c 'luafile tests/verify_nvim.lua' -c 'quit' +``` + +## Test Output + +### Success +``` +=== Summary === +Total: 19 checks +Passed: 19 +Failed: 0 +✓ All tests passed! +``` + +### Failure +``` +=== Summary === +Total: 19 checks +Passed: 17 +Failed: 2 +✗ Some tests failed +``` + +## Adding New Tests + +### To `verify_standalone.lua` +```lua +-- Add to command construction section +local payload_new = { + messages = { + { role = "user", content = "New test case" } + } +} + +local args_new = build_args(payload_new, "claude", {}) +if check(condition, "Test name") then + passed = passed + 1 +else + failed = failed + 1 +end +``` + +### To `claude_cli_spec.lua` +```lua +describe("New Test Suite", function() + it("tests something", function() + local result = some_function() + assert.equals(expected, result) + end) +end) +``` + +## Summary + +- **Quick Check**: `lua tests/verify_standalone.lua` +- **Full Verification**: `nvim --headless -c 'luafile tests/verify_nvim.lua' -c 'quit'` +- **Complete Suite**: `./tests/run_tests.sh` (requires plenary) + +All tests verify the critical components of the Claude CLI integration: +1. Command construction is correct +2. Payloads are processed properly +3. Output handling avoids duplication +4. Provider detection works +5. Boolean logic bug is fixed diff --git a/tests/claude_cli_spec.lua b/tests/claude_cli_spec.lua new file mode 100644 index 0000000..2907ba6 --- /dev/null +++ b/tests/claude_cli_spec.lua @@ -0,0 +1,467 @@ +-- Unit tests for Claude CLI provider integration +-- Run with: nvim --headless -c "PlenaryBustedDirectory tests/ {minimal_init = 'tests/minimal_init.vim'}" + +local ClaudeCliProvider = require("parrot.provider.claude_cli") +local init_provider = require("parrot.provider.init").init_provider + +describe("ClaudeCliProvider", function() + describe("Provider Creation", function() + it("creates provider with default config", function() + local provider = ClaudeCliProvider:new({ + name = "test_cli", + command = "claude", + models = { "claude-sonnet-4-5" }, + }) + + assert.is_not_nil(provider) + assert.equals("test_cli", provider.name) + assert.equals("claude", provider.command) + assert.equals(1, #provider.models) + assert.equals("claude-sonnet-4-5", provider.models[1]) + end) + + it("creates provider with command as table", function() + local provider = ClaudeCliProvider:new({ + name = "test_cli", + command = { "/usr/bin/claude", "--verbose" }, + models = { "claude-sonnet-4-5" }, + }) + + assert.equals("/usr/bin/claude", provider:get_command()) + end) + + it("creates provider with default models", function() + local provider = ClaudeCliProvider:new({ + name = "test_cli", + command = "claude", + }) + + assert.equals(3, #provider.models) + assert.is_true(vim.tbl_contains(provider.models, "claude-sonnet-4-5")) + end) + end) + + describe("Interface Methods", function() + local provider + + before_each(function() + provider = ClaudeCliProvider:new({ + name = "test_cli", + command = "claude", + models = { "claude-sonnet-4-5" }, + }) + end) + + it("get_command returns correct command", function() + assert.equals("claude", provider:get_command()) + end) + + it("uses_json_payload returns false", function() + assert.is_false(provider:uses_json_payload()) + end) + + it("resolve_api_key returns true (no API key needed)", function() + assert.is_true(provider:resolve_api_key(nil)) + assert.is_true(provider:resolve_api_key("some-key")) + end) + + it("verify returns true", function() + assert.is_true(provider:verify()) + end) + + it("get_available_models returns static models", function() + local models = provider:get_available_models() + assert.equals(1, #models) + assert.equals("claude-sonnet-4-5", models[1]) + end) + + it("online_model_fetching returns false", function() + assert.is_false(provider:online_model_fetching()) + end) + end) + + describe("curl_params", function() + local provider + + before_each(function() + provider = ClaudeCliProvider:new({ + name = "test_cli", + command = "claude", + models = { "claude-sonnet-4-5" }, + }) + end) + + it("returns basic args without system prompt", function() + local payload = { + messages = { + { role = "user", content = "Hello" }, + }, + } + + local args = provider:curl_params(payload) + + assert.equals(3, #args) + assert.equals("-p", args[1]) + assert.equals("--output-format", args[2]) + assert.equals("text", args[3]) + end) + + it("includes system prompt when present", function() + local payload = { + messages = { + { role = "system", content = "You are helpful" }, + { role = "user", content = "Hello" }, + }, + } + + local args = provider:curl_params(payload) + + assert.equals(5, #args) + assert.equals("-p", args[1]) + assert.equals("--output-format", args[2]) + assert.equals("text", args[3]) + assert.equals("--system-prompt", args[4]) + assert.equals("You are helpful", args[5]) + end) + + it("trims whitespace from system prompt", function() + local payload = { + messages = { + { role = "system", content = " You are helpful " }, + { role = "user", content = "Hello" }, + }, + } + + local args = provider:curl_params(payload) + + assert.equals("You are helpful", args[5]) + end) + + it("filters empty system prompt", function() + local payload = { + messages = { + { role = "system", content = " " }, + { role = "user", content = "Hello" }, + }, + } + + local args = provider:curl_params(payload) + + assert.equals(3, #args) -- No system prompt args + end) + + it("handles nil payload", function() + local args = provider:curl_params(nil) + + assert.equals(3, #args) + assert.equals("-p", args[1]) + end) + + it("handles payload with nil messages", function() + local args = provider:curl_params({ messages = nil }) + + assert.equals(3, #args) + end) + + it("includes command_args", function() + provider.command_args = { "--model", "opus" } + + local payload = { + messages = { + { role = "user", content = "Hello" }, + }, + } + + local args = provider:curl_params(payload) + + assert.equals(5, #args) + assert.equals("--model", args[4]) + assert.equals("opus", args[5]) + end) + + it("handles command as table with extra args", function() + provider.command = { "/usr/bin/claude", "--verbose" } + + local payload = { + messages = { + { role = "user", content = "Hello" }, + }, + } + + local args = provider:curl_params(payload) + + assert.equals(4, #args) + assert.equals("--verbose", args[1]) + assert.equals("-p", args[2]) + end) + end) + + describe("preprocess_payload", function() + local provider + + before_each(function() + provider = ClaudeCliProvider:new({ + name = "test_cli", + command = "claude", + models = { "claude-sonnet-4-5" }, + }) + end) + + it("extracts last user message", function() + local payload = { + messages = { + { role = "system", content = "You are helpful" }, + { role = "user", content = "First question" }, + { role = "assistant", content = "First answer" }, + { role = "user", content = "Second question" }, + }, + } + + local result = provider:preprocess_payload(payload) + + assert.equals("Second question", result) + end) + + it("trims whitespace from user message", function() + local payload = { + messages = { + { role = "user", content = " Hello " }, + }, + } + + local result = provider:preprocess_payload(payload) + + assert.equals("Hello", result) + end) + + it("handles nil payload", function() + local result = provider:preprocess_payload(nil) + + assert.equals("", result) + end) + + it("handles payload with nil messages", function() + local result = provider:preprocess_payload({ messages = nil }) + + assert.equals("", result) + end) + + it("handles message with nil content", function() + local payload = { + messages = { + { role = "user", content = nil }, + }, + } + + local result = provider:preprocess_payload(payload) + + assert.equals("", result) + end) + + it("skips system messages", function() + local payload = { + messages = { + { role = "system", content = "System prompt" }, + { role = "user", content = "User question" }, + }, + } + + local result = provider:preprocess_payload(payload) + + assert.equals("User question", result) + end) + end) + + describe("process_stdout", function() + local provider + + before_each(function() + provider = ClaudeCliProvider:new({ + name = "test_cli", + command = "claude", + models = { "claude-sonnet-4-5" }, + }) + end) + + it("adds newline to preserve formatting", function() + local result = provider:process_stdout("Hello world") + + assert.equals("Hello world\n", result) + end) + + it("returns newline for empty line (preserves blank lines)", function() + local result = provider:process_stdout("") + + assert.equals("\n", result) + end) + + it("returns nil for nil line", function() + local result = provider:process_stdout(nil) + + assert.is_nil(result) + end) + end) + + describe("process_onexit", function() + local provider + + before_each(function() + provider = ClaudeCliProvider:new({ + name = "test_cli", + command = "claude", + models = { "claude-sonnet-4-5" }, + }) + end) + + it("returns nil to avoid duplication", function() + local result = provider:process_onexit("Some content") + + assert.is_nil(result) + end) + + it("returns nil for empty response", function() + local result = provider:process_onexit("") + + assert.is_nil(result) + end) + + it("returns nil for nil response", function() + local result = provider:process_onexit(nil) + + assert.is_nil(result) + end) + end) + + describe("Provider Detection", function() + it("detects CLI provider via init_provider", function() + local provider = init_provider({ + name = "test_cli", + command = "claude", + models = { "claude-sonnet-4-5" }, + }) + + -- CLI provider returns false for uses_json_payload + assert.is_false(provider:uses_json_payload()) + -- CLI provider returns "claude" for get_command + assert.equals("claude", provider:get_command()) + end) + + it("CLI provider does not require api_key", function() + -- Should not error + local provider = init_provider({ + name = "test_cli", + command = "claude", + models = { "claude-sonnet-4-5" }, + }) + + assert.is_not_nil(provider) + end) + end) +end) + +describe("Claude CLI Integration", function() + local function claude_exists() + local handle = io.popen("command -v claude 2>&1") + if not handle then + return false + end + local result = handle:read("*a") + handle:close() + return result and result:match("%S") ~= nil + end + + local function has_auth() + if not claude_exists() then + return false + end + -- Try a simple command + local handle = io.popen("echo 'test' | claude -p --output-format text 2>&1") + if not handle then + return false + end + local result = handle:read("*a") + handle:close() + -- If we get credit balance error, we have auth issues + return not result:match("Credit balance is too low") and not result:match("authentication") + end + + if claude_exists() then + describe("Real Claude CLI Tests", function() + local provider + + before_each(function() + provider = ClaudeCliProvider:new({ + name = "test_cli", + command = "claude", + models = { "claude-sonnet-4-5" }, + }) + end) + + it("verify detects claude command", function() + assert.is_true(provider:verify()) + end) + + if has_auth() then + pending("executes basic command", function() + local payload = { + messages = { + { role = "user", content = "Say 'test' and nothing else" }, + }, + } + + local args = provider:curl_params(payload) + local stdin_data = provider:preprocess_payload(payload) + + -- Build command + local cmd = "echo '" .. stdin_data:gsub("'", "'\\''") .. "' | " .. provider:get_command() .. " " + for _, arg in ipairs(args) do + cmd = cmd .. "'" .. arg:gsub("'", "'\\''") .. "' " + end + + local handle = io.popen(cmd .. " 2>&1") + assert.is_not_nil(handle) + + local result = handle:read("*a") + local exit_code = handle:close() + + assert.is_not_nil(result) + assert.is_true(exit_code == true or exit_code == 0) + assert.is_true(#result > 0, "Expected non-empty result") + end) + + pending("handles system prompt", function() + local payload = { + messages = { + { role = "system", content = "You are a test assistant. Only say 'OK'." }, + { role = "user", content = "Respond" }, + }, + } + + local args = provider:curl_params(payload) + local stdin_data = provider:preprocess_payload(payload) + + assert.is_true(vim.tbl_contains(args, "--system-prompt")) + + -- Build and run command + local cmd = "echo '" .. stdin_data:gsub("'", "'\\''") .. "' | " .. provider:get_command() .. " " + for _, arg in ipairs(args) do + cmd = cmd .. "'" .. arg:gsub("'", "'\\''") .. "' " + end + + local handle = io.popen(cmd .. " 2>&1") + assert.is_not_nil(handle) + + local result = handle:read("*a") + handle:close() + + assert.is_not_nil(result) + assert.is_true(#result > 0) + end) + else + pending("skipping authenticated tests (no valid auth or credits)") + end + end) + else + pending("Claude CLI Integration Tests (claude command not found)") + end +end) diff --git a/tests/minimal_init.vim b/tests/minimal_init.vim new file mode 100644 index 0000000..ee142ec --- /dev/null +++ b/tests/minimal_init.vim @@ -0,0 +1,5 @@ +" Minimal init for running tests +set rtp+=. +set rtp+=../plenary.nvim + +runtime! plugin/plenary.vim diff --git a/tests/run_tests.sh b/tests/run_tests.sh new file mode 100755 index 0000000..873ff45 --- /dev/null +++ b/tests/run_tests.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# Test runner for Claude CLI provider tests + +set -e + +echo "=== Running Claude CLI Provider Tests ===" +echo "" + +# Check if plenary is available +if [ ! -d "../plenary.nvim" ]; then + echo "Error: plenary.nvim not found" + echo "Please clone it: git clone https://github.com/nvim-lua/plenary.nvim ../plenary.nvim" + exit 1 +fi + +# Check if Claude CLI is available +if command -v claude &> /dev/null; then + echo "✓ Claude CLI found: $(command -v claude)" + + # Check authentication + if echo "test" | claude -p --output-format text 2>&1 | grep -q "Credit balance is too low"; then + echo "⚠ Warning: Claude CLI authentication issue (credit balance low)" + echo " Real CLI tests will be skipped" + elif echo "test" | timeout 5 claude -p --output-format text &> /dev/null; then + echo "✓ Claude CLI authenticated and working" + else + echo "⚠ Warning: Claude CLI may not be working properly" + echo " Real CLI tests will be skipped" + fi +else + echo "⚠ Claude CLI not found - real CLI tests will be skipped" +fi + +echo "" +echo "=== Running Unit Tests ===" +echo "" + +# Run tests with plenary +nvim --headless --noplugin -u tests/minimal_init.vim \ + -c "PlenaryBustedDirectory tests/ { minimal_init = 'tests/minimal_init.vim' }" \ + -c "quitall!" + +echo "" +echo "=== Tests Complete ===" diff --git a/tests/verify_integration.lua b/tests/verify_integration.lua new file mode 100755 index 0000000..426705a --- /dev/null +++ b/tests/verify_integration.lua @@ -0,0 +1,372 @@ +#!/usr/bin/env -S nvim -l +-- Integration verification script for Claude CLI provider +-- Run with: nvim -l tests/verify_integration.lua + +package.path = package.path .. ";lua/?.lua" + +local function green(text) + return "\27[32m" .. text .. "\27[0m" +end + +local function red(text) + return "\27[31m" .. text .. "\27[0m" +end + +local function yellow(text) + return "\27[33m" .. text .. "\27[0m" +end + +local function bold(text) + return "\27[1m" .. text .. "\27[0m" +end + +local function check(condition, name) + if condition then + print(green("✓") .. " " .. name) + return true + else + print(red("✗") .. " " .. name) + return false + end +end + +local function section(name) + print("") + print(bold("=== " .. name .. " ===")) +end + +local passed = 0 +local failed = 0 + +section("Environment Check") + +-- Check if Claude CLI exists +local handle = io.popen("command -v claude 2>&1") +local claude_path = handle and handle:read("*a") or "" +if handle then handle:close() end +claude_path = claude_path:gsub("%s+$", "") + +local has_claude = claude_path ~= "" +if check(has_claude, "Claude CLI installed") then + passed = passed + 1 + print(" Path: " .. claude_path) +else + failed = failed + 1 + print(yellow(" Install: pip install claude-code")) +end + +-- Check authentication +local has_auth = false +if has_claude then + handle = io.popen("echo 'test' | claude -p --output-format text 2>&1") + local result = handle and handle:read("*a") or "" + if handle then handle:close() end + + if result:match("Credit balance is too low") then + print(red("✗") .. " Claude CLI authentication (using API key - needs credits)") + print(yellow(" Fix: unset ANTHROPIC_API_KEY and use subscription auth")) + failed = failed + 1 + elseif result:match("authentication") or result:match("unauthorized") then + print(red("✗") .. " Claude CLI authentication") + print(yellow(" Fix: run 'claude setup-token'")) + failed = failed + 1 + else + has_auth = true + if check(true, "Claude CLI authenticated") then + passed = passed + 1 + end + end +end + +section("Module Loading") + +-- Load provider modules +local ok, ClaudeCliProvider = pcall(require, "parrot.provider.claude_cli") +if check(ok, "Load ClaudeCliProvider module") then + passed = passed + 1 +else + failed = failed + 1 + print(" Error: " .. tostring(ClaudeCliProvider)) +end + +local ok2, init_provider = pcall(require, "parrot.provider.init") +if check(ok2, "Load provider init module") then + passed = passed + 1 +else + failed = failed + 1 + print(" Error: " .. tostring(init_provider)) +end + +if not (ok and ok2) then + print(red("\nCannot continue - module loading failed")) + os.exit(1) +end + +section("Provider Creation") + +-- Test basic provider creation +local provider_ok, provider = pcall(ClaudeCliProvider.new, ClaudeCliProvider, { + name = "test_cli", + command = "claude", + models = { "claude-sonnet-4-5" }, +}) + +if check(provider_ok, "Create ClaudeCliProvider") then + passed = passed + 1 +else + failed = failed + 1 + print(" Error: " .. tostring(provider)) +end + +-- Test provider via init_provider +local init_ok, init_prov = pcall(init_provider.init_provider, { + name = "test_cli", + command = "claude", + models = { "claude-sonnet-4-5" }, +}) + +if check(init_ok, "Create provider via init_provider") then + passed = passed + 1 +else + failed = failed + 1 + print(" Error: " .. tostring(init_prov)) +end + +if not (provider_ok and init_ok) then + print(red("\nCannot continue - provider creation failed")) + os.exit(1) +end + +section("Provider Interface") + +if check(provider:get_command() == "claude", "get_command() returns 'claude'") then + passed = passed + 1 +else + failed = failed + 1 + print(" Got: " .. tostring(provider:get_command())) +end + +if check(provider:uses_json_payload() == false, "uses_json_payload() returns false") then + passed = passed + 1 +else + failed = failed + 1 +end + +if check(provider:resolve_api_key(nil) == true, "resolve_api_key() returns true") then + passed = passed + 1 +else + failed = failed + 1 +end + +if check(provider:verify() == true, "verify() returns true") then + passed = passed + 1 +else + failed = failed + 1 +end + +local models = provider:get_available_models() +if check(#models > 0, "get_available_models() returns models") then + passed = passed + 1 + print(" Models: " .. table.concat(models, ", ")) +else + failed = failed + 1 +end + +section("Command Construction") + +-- Test without system prompt +local payload1 = { + messages = { + { role = "user", content = "Hello" }, + }, +} + +local args1 = provider:curl_params(payload1) +if check(#args1 == 3, "Basic args count (no system prompt)") then + passed = passed + 1 + print(" Args: " .. table.concat(args1, " ")) +else + failed = failed + 1 + print(" Expected 3, got " .. #args1) +end + +if check(args1[1] == "-p", "First arg is -p") then + passed = passed + 1 +else + failed = failed + 1 +end + +if check(args1[2] == "--output-format", "Second arg is --output-format") then + passed = passed + 1 +else + failed = failed + 1 +end + +if check(args1[3] == "text", "Third arg is text") then + passed = passed + 1 +else + failed = failed + 1 +end + +-- Test with system prompt +local payload2 = { + messages = { + { role = "system", content = "You are helpful" }, + { role = "user", content = "Hello" }, + }, +} + +local args2 = provider:curl_params(payload2) +if check(#args2 == 5, "Args count with system prompt") then + passed = passed + 1 + print(" Args: " .. table.concat(args2, " ")) +else + failed = failed + 1 + print(" Expected 5, got " .. #args2) +end + +if check(args2[4] == "--system-prompt", "System prompt flag present") then + passed = passed + 1 +else + failed = failed + 1 +end + +if check(args2[5] == "You are helpful", "System prompt value correct") then + passed = passed + 1 +else + failed = failed + 1 + print(" Got: " .. tostring(args2[5])) +end + +section("Payload Processing") + +local stdin1 = provider:preprocess_payload(payload1) +if check(stdin1 == "Hello", "Extract user message") then + passed = passed + 1 +else + failed = failed + 1 + print(" Expected 'Hello', got '" .. stdin1 .. "'") +end + +local stdin2 = provider:preprocess_payload(payload2) +if check(stdin2 == "Hello", "Extract user message (with system)") then + passed = passed + 1 +else + failed = failed + 1 + print(" Expected 'Hello', got '" .. stdin2 .. "'") +end + +-- Test multi-turn +local payload3 = { + messages = { + { role = "system", content = "You are helpful" }, + { role = "user", content = "First" }, + { role = "assistant", content = "Response" }, + { role = "user", content = "Second" }, + }, +} + +local stdin3 = provider:preprocess_payload(payload3) +if check(stdin3 == "Second", "Extract last user message (multi-turn)") then + passed = passed + 1 +else + failed = failed + 1 + print(" Expected 'Second', got '" .. stdin3 .. "'") +end + +section("Output Processing") + +local out1 = provider:process_stdout("Hello world") +if check(out1 == "Hello world", "process_stdout returns line") then + passed = passed + 1 +else + failed = failed + 1 +end + +local out2 = provider:process_stdout("") +if check(out2 == nil, "process_stdout returns nil for empty") then + passed = passed + 1 +else + failed = failed + 1 +end + +local out3 = provider:process_onexit("Some content") +if check(out3 == nil, "process_onexit returns nil (no duplication)") then + passed = passed + 1 +else + failed = failed + 1 + print(" Got: " .. tostring(out3)) +end + +-- Real CLI test (if available and authenticated) +if has_claude and has_auth then + section("Real CLI Execution") + + local test_payload = { + messages = { + { role = "user", content = "Say exactly 'OK' and nothing else" }, + }, + } + + local test_args = provider:curl_params(test_payload) + local test_stdin = provider:preprocess_payload(test_payload) + + -- Build command + local cmd = "echo '" .. test_stdin:gsub("'", "'\\''") .. "' | claude " + for _, arg in ipairs(test_args) do + cmd = cmd .. "'" .. arg:gsub("'", "'\\''") .. "' " + end + cmd = cmd .. "2>&1" + + print(" Command: " .. cmd) + + handle = io.popen(cmd) + if handle then + local result = handle:read("*a") + local success = handle:close() + + if check(success == true or success == 0, "Command executed successfully") then + passed = passed + 1 + print(" Output length: " .. #result .. " bytes") + + -- Check for common error patterns + if result:match("Credit balance") then + print(yellow(" Warning: Credit balance issue")) + elseif result:match("authentication") then + print(yellow(" Warning: Authentication issue")) + else + print(green(" Output: " .. result:sub(1, 100):gsub("\n", " "))) + end + else + failed = failed + 1 + print(" Output: " .. result) + end + else + failed = failed + 1 + print(red(" Failed to execute command")) + end +else + print("") + print(yellow("Skipping real CLI tests (Claude not available or not authenticated)")) +end + +-- Summary +section("Summary") +print("") +print(bold("Total: " .. (passed + failed) .. " checks")) +print(green("Passed: " .. passed)) +if failed > 0 then + print(red("Failed: " .. failed)) +else + print("Failed: " .. failed) +end + +if failed == 0 then + print("") + print(green(bold("✓ All checks passed!"))) + os.exit(0) +else + print("") + print(red(bold("✗ Some checks failed"))) + os.exit(1) +end diff --git a/tests/verify_nvim.lua b/tests/verify_nvim.lua new file mode 100644 index 0000000..9bab7b5 --- /dev/null +++ b/tests/verify_nvim.lua @@ -0,0 +1,281 @@ +-- Neovim-based verification for Claude CLI integration +-- Run with: nvim --headless -c 'luafile tests/verify_nvim.lua' -c 'quit' + +local function green(text) + return "\27[32m" .. text .. "\27[0m" +end + +local function red(text) + return "\27[31m" .. text .. "\27[0m" +end + +local function bold(text) + return "\27[1m" .. text .. "\27[0m" +end + +local function check(condition, name) + if condition then + print(green("✓") .. " " .. name) + return true + else + print(red("✗") .. " " .. name) + return false + end +end + +local function section(name) + print("") + print(bold("=== " .. name .. " ===")) +end + +local passed = 0 +local failed = 0 + +section("Neovim Module Loading Test") + +-- Test loading modules +local ok1, ClaudeCliProvider = pcall(require, "parrot.provider.claude_cli") +if check(ok1, "Load ClaudeCliProvider") then + passed = passed + 1 +else + failed = failed + 1 + print(" Error: " .. tostring(ClaudeCliProvider)) + print("") + print(red("Cannot continue - module loading failed")) + os.exit(1) +end + +local ok2, init_provider = pcall(require, "parrot.provider.init") +if check(ok2, "Load init_provider") then + passed = passed + 1 +else + failed = failed + 1 + print(" Error: " .. tostring(init_provider)) + os.exit(1) +end + +section("Provider Creation & Interface") + +-- Create provider +local prov_ok, provider = pcall(ClaudeCliProvider.new, ClaudeCliProvider, { + name = "test_cli", + command = "claude", + models = { "claude-sonnet-4-5" }, +}) + +if check(prov_ok, "Create ClaudeCliProvider") then + passed = passed + 1 +else + failed = failed + 1 + print(" Error: " .. tostring(provider)) + os.exit(1) +end + +-- Test interface methods +if check(provider:get_command() == "claude", "get_command()") then + passed = passed + 1 +else + failed = failed + 1 +end + +if check(provider:uses_json_payload() == false, "uses_json_payload()") then + passed = passed + 1 +else + failed = failed + 1 +end + +if check(provider:resolve_api_key(nil) == true, "resolve_api_key()") then + passed = passed + 1 +else + failed = failed + 1 +end + +if check(provider:verify(), "verify()") then + passed = passed + 1 +else + failed = failed + 1 +end + +local models = provider:get_available_models() +if check(#models == 1 and models[1] == "claude-sonnet-4-5", "get_available_models()") then + passed = passed + 1 +else + failed = failed + 1 +end + +section("Payload Processing") + +-- Test basic payload +local payload1 = { + messages = { + { role = "user", content = "Hello" } + } +} + +local args1 = provider:curl_params(payload1) +if check(#args1 == 3, "Basic args count") then + passed = passed + 1 + print(" Args: " .. table.concat(args1, " ")) +else + failed = failed + 1 +end + +local stdin1 = provider:preprocess_payload(payload1) +if check(stdin1 == "Hello", "Extract user message") then + passed = passed + 1 +else + failed = failed + 1 +end + +-- Test with system prompt +local payload2 = { + messages = { + { role = "system", content = "You are helpful" }, + { role = "user", content = "Hello" } + } +} + +local args2 = provider:curl_params(payload2) +if check(#args2 == 5, "Args with system prompt") then + passed = passed + 1 + print(" Args: " .. table.concat(args2, " ")) +else + failed = failed + 1 +end + +if check(vim.tbl_contains(args2, "--system-prompt"), "System prompt flag present") then + passed = passed + 1 +else + failed = failed + 1 +end + +-- Test output processing +local out1 = provider:process_stdout("Hello world") +if check(out1 == "Hello world\n", "process_stdout() adds newline") then + passed = passed + 1 +else + failed = failed + 1 + print(" Expected 'Hello world\\n', got '" .. tostring(out1) .. "'") +end + +local out2 = provider:process_stdout("") +if check(out2 == "\n", "process_stdout() preserves empty lines") then + passed = passed + 1 +else + failed = failed + 1 + print(" Expected '\\n', got '" .. tostring(out2) .. "'") +end + +local out3 = provider:process_stdout(nil) +if check(out3 == nil, "process_stdout() returns nil for nil") then + passed = passed + 1 +else + failed = failed + 1 +end + +-- Test formatting preservation +local lines = { "## Header", "", "- Item 1", "- Item 2" } +local formatted = {} +for _, line in ipairs(lines) do + local processed = provider:process_stdout(line) + if processed then + table.insert(formatted, processed) + end +end +local result = table.concat(formatted, "") +if check(result == "## Header\n\n- Item 1\n- Item 2\n", "Formatting preserved (blank lines, structure)") then + passed = passed + 1 +else + failed = failed + 1 + print(" Result: " .. result) +end + +local out_exit = provider:process_onexit("Content") +if check(out_exit == nil, "process_onexit() returns nil (no duplication)") then + passed = passed + 1 +else + failed = failed + 1 +end + +section("Provider Type Detection") + +-- Test that CLI provider is detected via init_provider +local init_ok, init_prov = pcall(init_provider.init_provider, { + name = "test_cli", + command = "claude", + models = { "claude-sonnet-4-5" }, +}) + +if check(init_ok, "CLI provider via init_provider") then + passed = passed + 1 +else + failed = failed + 1 + print(" Error: " .. tostring(init_prov)) +end + +if init_ok then + if check(init_prov:uses_json_payload() == false, "Detected as CLI (not JSON)") then + passed = passed + 1 + else + failed = failed + 1 + end + + if check(init_prov:get_command() == "claude", "Command is 'claude'") then + passed = passed + 1 + else + failed = failed + 1 + end +end + +section("Boolean Logic Verification") + +-- This is the critical test - ensure false is preserved +local test_provider = { + uses_json_payload = function() return false end +} + +-- Simulate the fixed logic +local uses_json = true +if test_provider.uses_json_payload ~= nil then + uses_json = test_provider.uses_json_payload() +end + +if check(uses_json == false, "Boolean false preserved (not converted to true)") then + passed = passed + 1 + print(" This was the critical bug fix!") +else + failed = failed + 1 + print(" CRITICAL: Boolean logic is broken!") +end + +-- Summary +section("Summary") +print("") +print(bold("Total: " .. (passed + failed) .. " checks")) +print(green("Passed: " .. passed)) +if failed > 0 then + print(red("Failed: " .. failed)) +else + print("Failed: " .. failed) +end + +if failed == 0 then + print("") + print(green(bold("✓ All Neovim integration tests passed!"))) + print("") + print("The Claude CLI provider is ready to use.") + print("") + print("Configuration:") + print(" providers = {") + print(" claude_cli = {") + print(" name = 'claude_cli',") + print(" command = 'claude',") + print(" models = { 'claude-sonnet-4-5' },") + print(" },") + print(" }") + os.exit(0) +else + print("") + print(red(bold("✗ Some tests failed"))) + os.exit(1) +end diff --git a/tests/verify_standalone.lua b/tests/verify_standalone.lua new file mode 100755 index 0000000..0b8cf59 --- /dev/null +++ b/tests/verify_standalone.lua @@ -0,0 +1,421 @@ +#!/usr/bin/env lua +-- Standalone verification for Claude CLI integration +-- Tests command construction logic without requiring nvim modules + +local function green(text) + return "\27[32m" .. text .. "\27[0m" +end + +local function red(text) + return "\27[31m" .. text .. "\27[0m" +end + +local function yellow(text) + return "\27[33m" .. text .. "\27[0m" +end + +local function bold(text) + return "\27[1m" .. text .. "\27[0m" +end + +local function check(condition, name) + if condition then + print(green("✓") .. " " .. name) + return true + else + print(red("✗") .. " " .. name) + return false + end +end + +local function section(name) + print("") + print(bold("=== " .. name .. " ===")) +end + +local passed = 0 +local failed = 0 + +section("Environment Check") + +-- Check if Claude CLI exists +local handle = io.popen("command -v claude 2>&1") +local claude_path = handle and handle:read("*a") or "" +if handle then handle:close() end +claude_path = claude_path:gsub("%s+$", "") + +local has_claude = claude_path ~= "" +if check(has_claude, "Claude CLI installed") then + passed = passed + 1 + print(" Path: " .. claude_path) +else + failed = failed + 1 + print(yellow(" Install: pip install claude-code")) +end + +-- Check authentication +local has_auth = false +if has_claude then + handle = io.popen("echo 'test' | claude -p --output-format text 2>&1") + local result = handle and handle:read("*a") or "" + if handle then handle:close() end + + if result:match("Credit balance is too low") then + print(red("✗") .. " Claude CLI authentication (using API key - needs credits)") + print(yellow(" Fix: unset ANTHROPIC_API_KEY before starting Neovim")) + print(yellow(" Or add to config: vim.env.ANTHROPIC_API_KEY = nil")) + failed = failed + 1 + elseif result:match("authentication") or result:match("unauthorized") then + print(red("✗") .. " Claude CLI authentication") + print(yellow(" Fix: run 'claude setup-token'")) + failed = failed + 1 + else + has_auth = true + if check(true, "Claude CLI authenticated") then + passed = passed + 1 + end + end +end + +section("Output Formatting") + +-- Test that newlines are preserved +local function process_stdout(line) + if line == nil then + return nil + end + return line .. "\n" +end + +local out1 = process_stdout("Hello") +if check(out1 == "Hello\n", "Newline added to non-empty line") then + passed = passed + 1 +else + failed = failed + 1 + print(" Expected 'Hello\\n', got '" .. (out1 or "nil") .. "'") +end + +local out2 = process_stdout("") +if check(out2 == "\n", "Newline added to empty line (preserves blank lines)") then + passed = passed + 1 +else + failed = failed + 1 + print(" Expected '\\n', got '" .. (out2 or "nil") .. "'") +end + +local out3 = process_stdout(nil) +if check(out3 == nil, "Nil line returns nil") then + passed = passed + 1 +else + failed = failed + 1 +end + +-- Test multi-line output formatting +local multiline_output = { + "## Header", + "", + "- Item 1", + "- Item 2", + "", + "```lua", + "code here", + "```", +} + +local formatted = {} +for _, line in ipairs(multiline_output) do + local processed = process_stdout(line) + if processed then + table.insert(formatted, processed) + end +end + +local result = table.concat(formatted, "") +local expected = "## Header\n\n- Item 1\n- Item 2\n\n```lua\ncode here\n```\n" + +if check(result == expected, "Multi-line output preserves formatting") then + passed = passed + 1 + print(" Formatted output:") + for _, line in ipairs(multiline_output) do + print(" " .. line) + end +else + failed = failed + 1 + print(" Expected:") + print(expected) + print(" Got:") + print(result) +end + +section("Command Construction Logic") + +-- Simulate curl_params function +local function build_args(payload, command, command_args) + local args = {} + + -- If command is a table, use remaining elements as base args + if type(command) == "table" then + for i = 2, #command do + if type(command[i]) == "string" then + args[#args + 1] = command[i] + end + end + end + + -- Add required flags for Claude CLI non-interactive mode + args[#args + 1] = "-p" + args[#args + 1] = "--output-format" + args[#args + 1] = "text" + + -- Extract and add system prompt if present + if payload and type(payload) == "table" and payload.messages then + for _, message in ipairs(payload.messages) do + if type(message) == "table" and message.role == "system" then + if message.content and type(message.content) == "string" then + local system_prompt = message.content:gsub("^%s*(.-)%s*$", "%1") + if system_prompt ~= "" then + args[#args + 1] = "--system-prompt" + args[#args + 1] = system_prompt + end + end + break + end + end + end + + -- Add any additional command arguments + if command_args and type(command_args) == "table" then + for _, arg in ipairs(command_args) do + if type(arg) == "string" then + args[#args + 1] = arg + end + end + end + + return args +end + +-- Simulate preprocess_payload function +local function extract_user_message(payload) + if not payload or type(payload) ~= "table" or not payload.messages then + return "" + end + + local user_prompt = "" + for i = #payload.messages, 1, -1 do + local message = payload.messages[i] + if type(message) == "table" and message.role == "user" then + if message.content and type(message.content) == "string" then + user_prompt = message.content:gsub("^%s*(.-)%s*$", "%1") + end + break + end + end + + return user_prompt +end + +-- Test 1: Basic payload +local payload1 = { + messages = { + { role = "user", content = "Hello" } + } +} + +local args1 = build_args(payload1, "claude", {}) +if check(#args1 == 3, "Basic args: 3 arguments") then + passed = passed + 1 + print(" " .. table.concat(args1, " ")) +else + failed = failed + 1 + print(" Expected 3, got " .. #args1) +end + +if check(args1[1] == "-p" and args1[2] == "--output-format" and args1[3] == "text", + "Basic args correct") then + passed = passed + 1 +else + failed = failed + 1 +end + +local stdin1 = extract_user_message(payload1) +if check(stdin1 == "Hello", "Extract user message") then + passed = passed + 1 +else + failed = failed + 1 +end + +-- Test 2: With system prompt +local payload2 = { + messages = { + { role = "system", content = "You are helpful" }, + { role = "user", content = "Hello" } + } +} + +local args2 = build_args(payload2, "claude", {}) +if check(#args2 == 5, "With system prompt: 5 arguments") then + passed = passed + 1 + print(" " .. table.concat(args2, " ")) +else + failed = failed + 1 + print(" Expected 5, got " .. #args2) +end + +if check(args2[4] == "--system-prompt" and args2[5] == "You are helpful", + "System prompt args correct") then + passed = passed + 1 +else + failed = failed + 1 +end + +-- Test 3: Multi-turn conversation +local payload3 = { + messages = { + { role = "system", content = "You are helpful" }, + { role = "user", content = "First question" }, + { role = "assistant", content = "First answer" }, + { role = "user", content = "Second question" } + } +} + +local stdin3 = extract_user_message(payload3) +if check(stdin3 == "Second question", "Extract last user message (multi-turn)") then + passed = passed + 1 +else + failed = failed + 1 + print(" Expected 'Second question', got '" .. stdin3 .. "'") +end + +-- Test 4: Command as table +local args4 = build_args(payload1, { "/usr/bin/claude", "--verbose" }, {}) +if check(args4[1] == "--verbose", "Command as table: includes extra args") then + passed = passed + 1 + print(" " .. table.concat(args4, " ")) +else + failed = failed + 1 +end + +-- Test 5: Additional command args +local args5 = build_args(payload1, "claude", { "--model", "opus" }) +if check(args5[4] == "--model" and args5[5] == "opus", "Additional command args") then + passed = passed + 1 + print(" " .. table.concat(args5, " ")) +else + failed = failed + 1 +end + +-- Test 6: Edge cases +local stdin_nil = extract_user_message(nil) +if check(stdin_nil == "", "Nil payload returns empty string") then + passed = passed + 1 +else + failed = failed + 1 +end + +local stdin_empty = extract_user_message({ messages = {} }) +if check(stdin_empty == "", "Empty messages returns empty string") then + passed = passed + 1 +else + failed = failed + 1 +end + +local args_empty_system = build_args({ + messages = { + { role = "system", content = " " }, + { role = "user", content = "Hi" } + } +}, "claude", {}) +if check(#args_empty_system == 3, "Empty system prompt filtered out") then + passed = passed + 1 +else + failed = failed + 1 + print(" Expected 3, got " .. #args_empty_system) +end + +-- Real CLI test (if available and authenticated) +if has_claude and has_auth then + section("Real CLI Execution Test") + + local test_payload = { + messages = { + { role = "user", content = "Say exactly 'test' and nothing else" } + } + } + + local test_args = build_args(test_payload, "claude", {}) + local test_stdin = extract_user_message(test_payload) + + -- Build command + local cmd = "echo '" .. test_stdin:gsub("'", "'\\''") .. "' | claude " + for _, arg in ipairs(test_args) do + cmd = cmd .. "'" .. arg:gsub("'", "'\\''") .. "' " + end + cmd = cmd .. "2>&1" + + print(" Executing: " .. cmd) + + handle = io.popen(cmd) + if handle then + local result = handle:read("*a") + local success = handle:close() + + if success then + if check(true, "Command executed successfully") then + passed = passed + 1 + local preview = result:sub(1, 100):gsub("\n", " ") + print(green(" Output: " .. preview)) + + -- Verify we got actual output + if #result > 0 and not result:match("Credit balance") and not result:match("authentication") then + if check(true, "Received valid output") then + passed = passed + 1 + end + else + failed = failed + 1 + print(red(" Output appears to be an error message")) + end + end + else + failed = failed + 1 + print(red(" Command failed")) + print(" Output: " .. result) + end + else + failed = failed + 1 + print(red(" Failed to execute command")) + end +end + +-- Summary +section("Summary") +print("") +print(bold("Total: " .. (passed + failed) .. " checks")) +print(green("Passed: " .. passed)) +if failed > 0 then + print(red("Failed: " .. failed)) +else + print("Failed: " .. failed) +end + +print("") +if failed == 0 then + print(green(bold("✓ All checks passed!"))) + print("") + print("Next steps:") + print(" 1. Run full tests: nvim --headless -c 'luafile tests/verify_nvim.lua' -c 'quit'") + print(" 2. Try in Neovim: :PrtChatNew") + os.exit(0) +else + print(red(bold("✗ Some checks failed"))) + print("") + if not has_claude then + print("Install Claude CLI: pip install claude-code") + end + if has_claude and not has_auth then + print("Fix authentication:") + print(" 1. Unset ANTHROPIC_API_KEY: unset ANTHROPIC_API_KEY") + print(" 2. Use subscription: claude setup-token") + end + os.exit(1) +end