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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ cython_debug/
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
# and can be added to the global gitignore or merged into this file. However, if you prefer,
# you could uncomment the following to ignore the entire vscode folder
# .vscode/
.vscode/

# Ruff stuff:
.ruff_cache/
Expand Down
34 changes: 22 additions & 12 deletions commands/create_spec.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,9 @@

import json
import os
import sys

from engine import TemplateEngine, TemplateParseError


# Create spec command:
# cxk create-spec [spec-template]
# Will output into stdout the rendered spec file
#
# For now all it does is to use TemplateEngine to print the list of variables in the template and nothing more.
# It also prints an error if the template is not found or cannot be parsed.
from prompt import collect_var_value


async def handle_create_spec(spec_template: str):
Expand All @@ -31,14 +24,31 @@ async def handle_create_spec(spec_template: str):
# Get variables from template
variables = template_engine.get_variables()

# Print list of variables
# Collect values for each variable
collected_vars = {}
if variables:
print("Template variables:")
print("Collecting values for template variables:")
for var in sorted(variables):
print(f" - {var}")
raw_value = await collect_var_value(var)
print(f" {var}: {raw_value}")

# Try to parse as JSON if it looks like JSON
if raw_value and (raw_value.strip().startswith('{') or raw_value.strip().startswith('[')):
Copy link

Copilot AI Aug 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The condition checking for JSON format is unsafe. If raw_value is None, calling raw_value.strip() will raise an AttributeError. The null check should also verify the value is a string.

Suggested change
if raw_value and (raw_value.strip().startswith('{') or raw_value.strip().startswith('[')):
if isinstance(raw_value, str) and (raw_value.strip().startswith('{') or raw_value.strip().startswith('[')):

Copilot uses AI. Check for mistakes.
try:
collected_vars[var] = json.loads(raw_value)
except json.JSONDecodeError:
# If it's not valid JSON, use as string
collected_vars[var] = raw_value
else:
collected_vars[var] = raw_value
else:
print("No variables found in template")

# Render the template with collected variables
rendered_content = await template_engine.render_async(**collected_vars)
print("\nRendered template:")
print(rendered_content)

except TemplateParseError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
Expand Down
22 changes: 6 additions & 16 deletions cxk.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
import asyncio
import sys

from commands.init import handle_init
from commands.create_spec import handle_create_spec
from commands.init import handle_init
from commands.mcp import (
MCPAddHttpContext,
MCPAddSSEContext,
Expand Down Expand Up @@ -35,16 +35,10 @@ async def main():
add_sse_parser.add_argument("url", help="URL of the SSE server")

# cxk mcp add-stdio [server-name] --env [env-var] -- [command]
add_stdio_parser = mcp_subparsers.add_parser(
"add-stdio", help="Add stdio MCP server"
)
add_stdio_parser = mcp_subparsers.add_parser("add-stdio", help="Add stdio MCP server")
add_stdio_parser.add_argument("server_name", help="Name of the server")
add_stdio_parser.add_argument(
"--env", action="append", help="Environment variable (key=value)"
)
add_stdio_parser.add_argument(
"command_line", nargs=argparse.ONE_OR_MORE, help="Command to run"
)
add_stdio_parser.add_argument("--env", action="append", help="Environment variable (key=value)")
add_stdio_parser.add_argument("command_line", nargs=argparse.ONE_OR_MORE, help="Command to run")

# cxk mcp add-http [server-name] [url]
add_http_parser = mcp_subparsers.add_parser("add-http", help="Add HTTP MCP server")
Expand Down Expand Up @@ -74,9 +68,7 @@ async def main():
if args.mcp_command == "add-sse":
mcp_context = MCPCommandContext(
subcommand="add-sse",
add_sse=MCPAddSSEContext(
server_name=args.server_name, url=args.url
),
add_sse=MCPAddSSEContext(server_name=args.server_name, url=args.url),
)
await handle_mcp(state, mcp_context)

Expand All @@ -94,9 +86,7 @@ async def main():
elif args.mcp_command == "add-http":
mcp_context = MCPCommandContext(
subcommand="add-http",
add_http=MCPAddHttpContext(
server_name=args.server_name, url=args.url
),
add_http=MCPAddHttpContext(server_name=args.server_name, url=args.url),
)
await handle_mcp(state, mcp_context)

Expand Down
1 change: 1 addition & 0 deletions engine/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ def __init__(self, template_path: str):
self.env = Environment(
loader=FileSystemLoader(template_dir),
autoescape=select_autoescape(),
enable_async=True,
)
self.template = self.env.get_template(template_name)
self.template_path = template_path
Expand Down
10 changes: 10 additions & 0 deletions prompt/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import questionary

# Add interactive prompt helpers using questionary that will collect values for
# unspecified template variables.


async def collect_var_value(var_name: str) -> str:
"""Collect a value for a variable using questionary."""

return await questionary.text(f"Please provide a value for '{var_name}':").ask_async()
Copy link

Copilot AI Aug 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function doesn't handle the case where the user cancels the prompt (Ctrl+C). questionary.ask_async() can return None when cancelled, which could cause issues downstream when the value is used in template rendering.

Suggested change
return await questionary.text(f"Please provide a value for '{var_name}':").ask_async()
value = await questionary.text(f"Please provide a value for '{var_name}':").ask_async()
if value is None:
raise RuntimeError("Prompt cancelled by user. Aborting variable collection.")
return value

Copilot uses AI. Check for mistakes.
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ requires-python = ">=3.12"
dependencies = [
"jinja2>=3.1.6",
"pydantic>=2.11.7",
"questionary>=2.1.0",
]

[project.optional-dependencies]
Expand Down
25 changes: 25 additions & 0 deletions test_runner.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/usr/bin/env python3
"""Test runner script that patches collect_var_value for e2e testing."""
import asyncio
from unittest.mock import patch

from cxk import main


async def mock_collect_var_value(var_name: str) -> str:
"""Mock implementation that returns predictable values based on variable name."""
mock_values = {
"name": "John",
"age": "25",
"city": "New York",
"weather": '{"condition": "sunny", "temp": "75F"}',
"username": "testuser",
"user": "test_user"
}
return mock_values.get(var_name, f"mock_value_{var_name}")


if __name__ == "__main__":
# Patch collect_var_value before running main
with patch('commands.create_spec.collect_var_value', side_effect=mock_collect_var_value):
asyncio.run(main())
58 changes: 39 additions & 19 deletions tests/e2e/test_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,15 @@ def temp_non_git_dir(self):
with tempfile.TemporaryDirectory() as temp_dir:
yield Path(temp_dir)

def run_cli(self, args, cwd=None):
def run_cli(self, args, cwd=None, use_test_runner=False):
"""Run the CLI and return result."""
# Get the path to cxk.py relative to this test file
cli_path = Path(__file__).parent.parent.parent / "cxk.py"
if use_test_runner:
# Use test runner that patches collect_var_value
cli_path = Path(__file__).parent.parent.parent / "test_runner.py"
else:
# Get the path to cxk.py relative to this test file
cli_path = Path(__file__).parent.parent.parent / "cxk.py"

cmd = ["python", str(cli_path)] + args

result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
Expand Down Expand Up @@ -321,15 +326,22 @@ def test_create_spec_with_variables(self, temp_non_git_dir):
template_file = temp_non_git_dir / "test_template.j2"
template_file.write_text(template_content)

# Run create-spec command
result = self.run_cli(["create-spec", str(template_file)])
# Run create-spec command with test runner to patch collect_var_value
result = self.run_cli(["create-spec", str(template_file)], use_test_runner=True)

assert result.returncode == 0
assert "Template variables:" in result.stdout
assert "- age" in result.stdout
assert "- city" in result.stdout
assert "- name" in result.stdout
assert "- weather" in result.stdout
assert "Collecting values for template variables:" in result.stdout
# Verify mocked values are displayed
assert "age: 25" in result.stdout
assert "city: New York" in result.stdout
assert "name: John" in result.stdout
assert 'weather: {"condition": "sunny", "temp": "75F"}' in result.stdout

# Verify rendered template output
assert "Rendered template:" in result.stdout
assert "Hello John!" in result.stdout
assert "Your age is 25 and you live in New York." in result.stdout
assert "Today's weather is sunny with temperature 75F." in result.stdout

def test_create_spec_no_variables(self, temp_non_git_dir):
"""Test create-spec with a template containing no variables."""
Expand All @@ -338,29 +350,37 @@ def test_create_spec_no_variables(self, temp_non_git_dir):
template_file = temp_non_git_dir / "static_template.j2"
template_file.write_text(template_content)

# Run create-spec command
result = self.run_cli(["create-spec", str(template_file)])
# Run create-spec command with test runner (patching won't affect this case)
result = self.run_cli(["create-spec", str(template_file)], use_test_runner=True)

assert result.returncode == 0
assert "No variables found in template" in result.stdout

# Verify rendered template output for static template
assert "Rendered template:" in result.stdout
assert "This is a static template with no variables." in result.stdout

def test_create_spec_relative_path(self, temp_non_git_dir):
"""Test create-spec with a relative path (filename only)."""
# Create a test template
template_content = "Hello {{ username }}!"
template_file = temp_non_git_dir / "relative_template.j2"
template_file.write_text(template_content)

# Run create-spec command with just the filename (relative path)
result = self.run_cli(["create-spec", "relative_template.j2"], cwd=temp_non_git_dir)
# Run create-spec command with just the filename (relative path) using test runner
result = self.run_cli(["create-spec", "relative_template.j2"], cwd=temp_non_git_dir, use_test_runner=True)

assert result.returncode == 0
assert "Template variables:" in result.stdout
assert "- username" in result.stdout
assert "Collecting values for template variables:" in result.stdout
assert "username: testuser" in result.stdout

# Verify rendered template output
assert "Rendered template:" in result.stdout
assert "Hello testuser!" in result.stdout

def test_create_spec_file_not_found(self, temp_non_git_dir):
"""Test create-spec with non-existent template file."""
result = self.run_cli(["create-spec", "non_existent.j2"], cwd=temp_non_git_dir)
result = self.run_cli(["create-spec", "non_existent.j2"], cwd=temp_non_git_dir, use_test_runner=True)

assert result.returncode != 0
assert "Error: Template file 'non_existent.j2' not found" in result.stderr
Expand All @@ -372,7 +392,7 @@ def test_create_spec_invalid_template(self, temp_non_git_dir):
template_file = temp_non_git_dir / "invalid_template.j2"
template_file.write_text(template_content)

# Run create-spec command
result = self.run_cli(["create-spec", str(template_file)])
# Run create-spec command with test runner
result = self.run_cli(["create-spec", str(template_file)], use_test_runner=True)

assert result.returncode != 0
4 changes: 3 additions & 1 deletion tests/templates/spec1.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

## Ticket description

{{ ticket }}
{{ ticket.id }}

{{ ticket.description }}

## Additional context

Expand Down