diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7fede86 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,72 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +ContextKit is a CLI tool and MCP (Model Context Protocol) client for creating spec files for AI coding agents. It uses reusable spec templates with context variables that can be automatically filled from various MCP sources (ticketing systems, databases, design tools, etc.). + +## Architecture + +- **Entry point**: `cxk.py` - Main CLI with argparse-based command routing +- **State management**: `state.py` - Handles project initialization, config loading, and git repo detection +- **Command handlers**: `commands/` directory contains handlers for each CLI command: + - `init.py` - Project initialization (creates `.cxk/` config directory) + - `mcp.py` - MCP server management (add-sse, add-stdio, add-http) + - `create_spec.py` - Template rendering with variable collection +- **Template engine**: `engine/` - Jinja2-based template processing +- **MCP configuration**: `util/mcp/config.py` - Pydantic models for MCP server configs +- **User prompts**: `prompt/` - Interactive variable collection + +## Core Concepts + +1. **Project state**: Must be initialized with `cxk init` (creates `.cxk/mcp.json`) +2. **MCP servers**: Configured via CLI commands, stored in `.cxk/mcp.json` +3. **Spec templates**: Jinja2 templates with variables that get filled from MCP resources +4. **Context variables**: Can be automatic MCP resources or user-provided values + +## Common Commands + +### Development +```bash +# Install dependencies +uv sync + +# Run tests +uv run pytest +# Alternative test runner +python run_tests.py + +# Linting and formatting +uv run ruff check +uv run ruff format +``` + +### CLI Usage +```bash +# Initialize project (creates .cxk/ directory) +python cxk.py init + +# Add MCP servers +python cxk.py mcp add-sse server-name ws://localhost:3000 +python cxk.py mcp add-stdio server-name --env KEY=value -- python server.py +python cxk.py mcp add-http server-name http://localhost:8000 + +# Create spec from template +python cxk.py create-spec path/to/template.md +``` + +## Key Files + +- `pyproject.toml` - Python dependencies, ruff configuration (line length: 120, Python 3.12+) +- `pytest.ini` - Test configuration (async mode enabled) +- `.cxk/mcp.json` - MCP server configuration (created after init) +- `tests/templates/spec1.md` - Example template with Jinja2 variables + +## Development Notes + +- Uses async/await throughout for MCP client compatibility +- Pydantic models for configuration validation +- Git repository detection required for project initialization +- Template variables can be JSON objects or strings +- MCP server configurations support stdio, SSE, and HTTP transports (HTTP not fully implemented) \ No newline at end of file diff --git a/commands/create_spec.py b/commands/create_spec.py index 34e05f5..7e5633b 100644 --- a/commands/create_spec.py +++ b/commands/create_spec.py @@ -6,7 +6,7 @@ from prompt import collect_var_value -async def handle_create_spec(spec_template: str): +async def handle_create_spec(spec_template: str, output_file: str | None = None): """Handle the create-spec command""" # Resolve relative paths against current working directory @@ -46,8 +46,16 @@ async def handle_create_spec(spec_template: str): # Render the template with collected variables rendered_content = await template_engine.render_async(**collected_vars) - print("\nRendered template:") - print(rendered_content) + + # Output to file or stdout + if output_file: + output_path = os.path.abspath(output_file) + with open(output_path, 'w') as f: + f.write(rendered_content) + print(f"Rendered template saved to: {output_path}") + else: + print("\nRendered template:") + print(rendered_content) except TemplateParseError as e: print(f"Error: {e}", file=sys.stderr) diff --git a/cxk.py b/cxk.py index f6c144c..f99f6e3 100644 --- a/cxk.py +++ b/cxk.py @@ -24,6 +24,7 @@ async def main(): # cxk create-spec [spec-template] create_spec_parser = subparsers.add_parser("create-spec", help="Create spec from template") create_spec_parser.add_argument("spec_template", help="Path to the spec template file") + create_spec_parser.add_argument("--output", help="Output file path (defaults to stdout if not specified)") # cxk mcp mcp_parser = subparsers.add_parser("mcp", help="Manage MCP servers") @@ -58,7 +59,7 @@ async def main(): await handle_init(state) elif args.command == "create-spec": - await handle_create_spec(args.spec_template) + await handle_create_spec(args.spec_template, args.output) elif args.command == "mcp": if not args.mcp_command: diff --git a/tests/e2e/test_e2e.py b/tests/e2e/test_e2e.py index 8756af1..b38d633 100644 --- a/tests/e2e/test_e2e.py +++ b/tests/e2e/test_e2e.py @@ -14,24 +14,18 @@ def temp_git_repo(self): repo_path = Path(temp_dir) # Initialize git repo - subprocess.run( - ["git", "init"], cwd=repo_path, check=True, capture_output=True - ) + subprocess.run(["git", "init"], cwd=repo_path, check=True, capture_output=True) subprocess.run( ["git", "config", "user.email", "test@example.com"], cwd=repo_path, check=True, ) - subprocess.run( - ["git", "config", "user.name", "Test User"], cwd=repo_path, check=True - ) + subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo_path, check=True) # Create initial commit (repo_path / "README.md").write_text("Test repo") subprocess.run(["git", "add", "README.md"], cwd=repo_path, check=True) - subprocess.run( - ["git", "commit", "-m", "Initial commit"], cwd=repo_path, check=True - ) + subprocess.run(["git", "commit", "-m", "Initial commit"], cwd=repo_path, check=True) yield repo_path @@ -96,9 +90,7 @@ def test_init_already_initialized(self, temp_git_repo): def test_mcp_before_init(self, temp_git_repo): """Test MCP commands before initialization should fail.""" - result = self.run_cli( - ["mcp", "add-sse", "test-server", "http://example.com"], cwd=temp_git_repo - ) + result = self.run_cli(["mcp", "add-sse", "test-server", "http://example.com"], cwd=temp_git_repo) assert result.returncode != 0 assert "Project not initialized. Run 'cxk init' first." in result.stderr @@ -110,15 +102,10 @@ def test_mcp_add_sse(self, temp_git_repo): assert init_result.returncode == 0 # Add SSE server - result = self.run_cli( - ["mcp", "add-sse", "test-sse", "http://example.com/sse"], cwd=temp_git_repo - ) + result = self.run_cli(["mcp", "add-sse", "test-sse", "http://example.com/sse"], cwd=temp_git_repo) assert result.returncode == 0 - assert ( - "Added SSE server 'test-sse' with URL: http://example.com/sse" - in result.stdout - ) + assert "Added SSE server 'test-sse' with URL: http://example.com/sse" in result.stdout # Verify server was added to config mcp_json = temp_git_repo / ".cxk" / "mcp.json" @@ -143,10 +130,7 @@ def test_mcp_add_stdio_simple(self, temp_git_repo): ) assert result.returncode == 0 - assert ( - "Added stdio server 'test-stdio' with command: python -m server" - in result.stdout - ) + assert "Added stdio server 'test-stdio' with command: python -m server" in result.stdout # Verify server was added to config mcp_json = temp_git_repo / ".cxk" / "mcp.json" @@ -183,14 +167,8 @@ def test_mcp_add_stdio_with_env(self, temp_git_repo): ) assert result.returncode == 0 - assert ( - "Added stdio server 'test-stdio-env' with command: node server.js" - in result.stdout - ) - assert ( - "Environment variables: {'API_KEY': 'test123', 'DEBUG': 'true'}" - in result.stdout - ) + assert "Added stdio server 'test-stdio-env' with command: node server.js" in result.stdout + assert "Environment variables: {'API_KEY': 'test123', 'DEBUG': 'true'}" in result.stdout # Verify server was added to config mcp_json = temp_git_repo / ".cxk" / "mcp.json" @@ -229,15 +207,11 @@ def test_mcp_duplicate_server_name(self, temp_git_repo): assert init_result.returncode == 0 # Add first server - result1 = self.run_cli( - ["mcp", "add-sse", "duplicate", "http://example.com/1"], cwd=temp_git_repo - ) + result1 = self.run_cli(["mcp", "add-sse", "duplicate", "http://example.com/1"], cwd=temp_git_repo) assert result1.returncode == 0 # Try to add server with same name - result2 = self.run_cli( - ["mcp", "add-sse", "duplicate", "http://example.com/2"], cwd=temp_git_repo - ) + result2 = self.run_cli(["mcp", "add-sse", "duplicate", "http://example.com/2"], cwd=temp_git_repo) assert result2.returncode != 0 assert "Server 'duplicate' already exists" in result2.stderr @@ -263,10 +237,7 @@ def test_invalid_env_format(self, temp_git_repo): ) assert result.returncode != 0 - assert ( - "Invalid environment variable format: INVALID_FORMAT. Use KEY=VALUE format." - in result.stderr - ) + assert "Invalid environment variable format: INVALID_FORMAT. Use KEY=VALUE format." in result.stderr def test_mcp_add_server_preserves_existing(self, temp_git_repo): """Test that adding a new server preserves existing servers.""" @@ -275,15 +246,11 @@ def test_mcp_add_server_preserves_existing(self, temp_git_repo): assert init_result.returncode == 0 # Add first server - result1 = self.run_cli( - ["mcp", "add-sse", "server1", "http://example.com/1"], cwd=temp_git_repo - ) + result1 = self.run_cli(["mcp", "add-sse", "server1", "http://example.com/1"], cwd=temp_git_repo) assert result1.returncode == 0 # Add second server - result2 = self.run_cli( - ["mcp", "add-stdio", "server2", "--", "python", "server.py"], cwd=temp_git_repo - ) + result2 = self.run_cli(["mcp", "add-stdio", "server2", "--", "python", "server.py"], cwd=temp_git_repo) assert result2.returncode == 0 # Verify both servers exist in config @@ -396,3 +363,82 @@ def test_create_spec_invalid_template(self, temp_non_git_dir): result = self.run_cli(["create-spec", str(template_file)], use_test_runner=True) assert result.returncode != 0 + + def test_create_spec_with_output_file(self, temp_non_git_dir): + """Test create-spec with --output flag saves to file.""" + # Create a test template with variables + template_content = """Hello {{ name }}! +Your age is {{ age }} and you live in {{ city }}.""" + template_file = temp_non_git_dir / "output_test_template.j2" + template_file.write_text(template_content) + + # Define output file + output_file = temp_non_git_dir / "rendered_spec.md" + + # Run create-spec command with --output flag + result = self.run_cli(["create-spec", str(template_file), "--output", str(output_file)], use_test_runner=True) + + assert result.returncode == 0 + assert f"Rendered template saved to: {output_file}" in result.stdout + + # Verify file was created and contains expected content + assert output_file.exists() + content = output_file.read_text() + assert "Hello John!" in content + assert "Your age is 25 and you live in New York." in content + + def test_create_spec_output_file_relative_path(self, temp_non_git_dir): + """Test create-spec with --output using relative path.""" + # Create a test template + template_content = "Template for {{ username }}" + template_file = temp_non_git_dir / "relative_output_template.j2" + template_file.write_text(template_content) + + # Run create-spec command with relative output path + result = self.run_cli( + ["create-spec", str(template_file), "--output", "output.md"], cwd=temp_non_git_dir, use_test_runner=True + ) + + assert result.returncode == 0 + + # Verify file was created with absolute path in message + output_file = temp_non_git_dir / "output.md" + assert f"Rendered template saved to: {output_file.resolve()}" in result.stdout + assert output_file.exists() + assert "Template for testuser" in output_file.read_text() + + def test_create_spec_stdout_vs_file_output(self, temp_non_git_dir): + """Test that stdout and file output contain the same content.""" + # Create a test template + template_content = "Hello {{ name }}! You are {{ age }} years old." + template_file = temp_non_git_dir / "comparison_template.j2" + template_file.write_text(template_content) + + # Run without --output (stdout) + result_stdout = self.run_cli(["create-spec", str(template_file)], use_test_runner=True) + + # Extract rendered content from stdout + stdout_lines = result_stdout.stdout.split("\n") + rendered_start = False + stdout_content = [] + for line in stdout_lines: + if line == "Rendered template:": + rendered_start = True + continue + elif rendered_start: + stdout_content.append(line) + stdout_rendered = "\n".join(stdout_content).strip() + + # Run with --output (file) + output_file = temp_non_git_dir / "comparison_output.md" + result_file = self.run_cli( + ["create-spec", str(template_file), "--output", str(output_file)], use_test_runner=True + ) + + assert result_stdout.returncode == 0 + assert result_file.returncode == 0 + + # Compare content + file_content = output_file.read_text().strip() + assert stdout_rendered == file_content + assert "Hello John! You are 25 years old." in file_content