Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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)
14 changes: 11 additions & 3 deletions commands/create_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion cxk.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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:
Expand Down
140 changes: 93 additions & 47 deletions tests/e2e/test_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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

Expand All @@ -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."""
Expand All @@ -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
Expand Down Expand Up @@ -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