Skip to content

Commit ee06a9f

Browse files
Initial commit: CNC Part Designer
Flask app with Claude CLI integration for natural language to SVG generation. Includes MCP file management server, zoom/pan/rotate preview, and Docker support. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
0 parents  commit ee06a9f

16 files changed

Lines changed: 1699 additions & 0 deletions

File tree

.github/workflows/ci.yaml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
smoke-test:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
15+
- uses: astral-sh/setup-uv@v4
16+
17+
- name: Install dependencies
18+
run: uv sync
19+
20+
- name: Verify MCP server loads
21+
run: uv run python -c "from cnctools.mcp_server import mcp; print('OK')"
22+
23+
- name: Start server and verify it responds
24+
run: |
25+
uv run cnctools &
26+
for i in $(seq 1 10); do
27+
if curl -sf -o /dev/null http://127.0.0.1:5000/; then
28+
echo "Server responded with 200"
29+
exit 0
30+
fi
31+
sleep 1
32+
done
33+
echo "Server did not respond in time"
34+
exit 1

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
__pycache__/
2+
*.pyc
3+
.venv/
4+
*.egg-info/
5+
dist/
6+
build/
7+
.cache/

Dockerfile

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
FROM python:3.12-slim
2+
3+
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
4+
5+
# Install Node.js (required by claude CLI)
6+
RUN apt-get update && apt-get install -y --no-install-recommends nodejs npm curl && \
7+
rm -rf /var/lib/apt/lists/*
8+
9+
# Install claude CLI
10+
RUN npm install -g @anthropic-ai/claude-code
11+
12+
WORKDIR /app
13+
14+
# Install dependencies first for layer caching
15+
COPY pyproject.toml ./
16+
RUN uv venv /app/.venv && uv pip install --python /app/.venv/bin/python -e .
17+
18+
# Copy application code
19+
COPY src/ ./src/
20+
21+
ENV PATH="/app/.venv/bin:$PATH"
22+
ENV CNCTOOLS_WORKSPACE=/workspace
23+
RUN mkdir -p /workspace
24+
25+
EXPOSE 5000
26+
27+
CMD ["cnctools"]

README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# CNC Part Designer
2+
3+
Describe CNC parts in natural language and get SVG files optimized for VCarve Pro.
4+
5+
## Features
6+
7+
- **Natural language to SVG** — Chat with Claude to design CNC parts
8+
- **SVG preview** — Zoom, pan, and rotate generated SVGs in the browser
9+
- **MCP file management** — Save, list, copy, move, and delete SVG files via natural language
10+
- **Session persistence** — Continue conversations across messages
11+
12+
## Quick Start
13+
14+
```bash
15+
uv sync
16+
uv run cnctools
17+
```
18+
19+
Open http://localhost:5000 in your browser.
20+
21+
## Docker
22+
23+
```bash
24+
docker compose up --build
25+
```
26+
27+
## Requirements
28+
29+
- Python 3.11+
30+
- [Claude CLI](https://github.com/anthropics/claude-code) installed and authenticated
31+
- [uv](https://docs.astral.sh/uv/) package manager

compose.yaml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
services:
2+
cnctools:
3+
build: .
4+
ports:
5+
- "5000:5000"
6+
volumes:
7+
# Persist claude CLI session data between restarts
8+
- claude-data:/root/.claude
9+
# Persist workspace files
10+
- workspace:/workspace
11+
environment:
12+
- CNCTOOLS_WORKSPACE=/workspace
13+
restart: unless-stopped
14+
15+
volumes:
16+
claude-data:
17+
workspace:

pyproject.toml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
[project]
2+
name = "cnctools"
3+
version = "0.1.0"
4+
description = "CNC Part Designer – describe parts in natural language, get SVG for VCarve Pro"
5+
requires-python = ">=3.11"
6+
dependencies = [
7+
"flask>=3.0",
8+
"mcp>=1.0",
9+
]
10+
11+
[project.scripts]
12+
cnctools = "cnctools.app:main"
13+
14+
[build-system]
15+
requires = ["hatchling"]
16+
build-backend = "hatchling.build"
17+
18+
[tool.hatch.build.targets.wheel]
19+
packages = ["src/cnctools"]

src/cnctools/__init__.py

Whitespace-only changes.

src/cnctools/app.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import os
2+
import signal
3+
import sys
4+
5+
from flask import Flask
6+
7+
from .routes import register_routes
8+
9+
10+
def create_app():
11+
app = Flask(__name__)
12+
register_routes(app)
13+
14+
@app.before_request
15+
def log_request():
16+
from flask import request
17+
print(f"[cnctools] {request.method} {request.path}", file=sys.stderr, flush=True)
18+
19+
print("[cnctools] app created, threaded=True", file=sys.stderr, flush=True)
20+
return app
21+
22+
23+
def main():
24+
def handle_sigint(*_):
25+
print("\n[cnctools] shutting down...", file=sys.stderr, flush=True)
26+
os._exit(0)
27+
28+
signal.signal(signal.SIGINT, handle_sigint)
29+
signal.signal(signal.SIGTERM, handle_sigint)
30+
create_app().run(host="0.0.0.0", port=5000, threaded=True, use_reloader=False)
31+
32+
33+
if __name__ == "__main__":
34+
main()

src/cnctools/claude_cli.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import json
2+
import logging
3+
import subprocess
4+
import shlex
5+
6+
from .mcp_config import get_mcp_config_path
7+
from .prompts import SYSTEM_PROMPT
8+
9+
CLI_TIMEOUT = 300
10+
log = logging.getLogger(__name__)
11+
12+
13+
def invoke_claude(message, session_id, system_prompt=None, mcp_config_path=None, timeout=CLI_TIMEOUT):
14+
"""Invoke the Claude CLI and return parsed output.
15+
16+
Returns dict with keys: result, session_id, error
17+
"""
18+
if system_prompt is None:
19+
system_prompt = SYSTEM_PROMPT
20+
if mcp_config_path is None:
21+
mcp_config_path = get_mcp_config_path()
22+
23+
cmd = [
24+
"claude",
25+
"-p",
26+
message,
27+
"--output-format",
28+
"json",
29+
"--model",
30+
"sonnet",
31+
"--mcp-config",
32+
mcp_config_path,
33+
]
34+
35+
if session_id:
36+
cmd.extend(["--resume", session_id])
37+
else:
38+
cmd.extend(["--system-prompt", system_prompt])
39+
40+
print(f"[cnctools] Running: {' '.join(shlex.quote(c) for c in cmd)}", flush=True)
41+
42+
try:
43+
result = subprocess.run(
44+
cmd,
45+
capture_output=True,
46+
text=True,
47+
timeout=timeout,
48+
stdin=subprocess.DEVNULL,
49+
)
50+
except subprocess.TimeoutExpired as e:
51+
# Capture any partial output
52+
partial_out = (e.stdout or "")[:500] if e.stdout else ""
53+
partial_err = (e.stderr or "")[:500] if e.stderr else ""
54+
log.error("CLI timed out after %ds. partial stdout=%r partial stderr=%r", timeout, partial_out, partial_err)
55+
return {"result": "", "session_id": session_id, "error": f"Claude CLI timed out after {timeout}s"}
56+
57+
log.info("CLI exited with code %d", result.returncode)
58+
if result.stderr:
59+
log.info("stderr: %s", result.stderr[:500])
60+
61+
if result.returncode != 0:
62+
stderr = result.stderr.strip() if result.stderr else "Unknown error"
63+
return {"result": "", "session_id": session_id, "error": f"Claude CLI error: {stderr}"}
64+
65+
try:
66+
output = json.loads(result.stdout)
67+
except json.JSONDecodeError:
68+
log.error("JSON parse failed. stdout[:500]=%r", result.stdout[:500])
69+
return {"result": "", "session_id": session_id, "error": "Failed to parse Claude CLI output"}
70+
71+
log.info("Success. session_id=%s result_length=%d", output.get("session_id"), len(output.get("result", "")))
72+
73+
return {
74+
"result": output.get("result", ""),
75+
"session_id": output.get("session_id", session_id),
76+
"error": None,
77+
}

src/cnctools/mcp_config.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import json
2+
import os
3+
import sys
4+
from pathlib import Path
5+
6+
_cached_path = None
7+
8+
9+
def get_mcp_config_path(workspace_dir=None):
10+
"""Generate and cache the MCP config JSON file for the claude CLI.
11+
12+
Returns the path to the config file.
13+
"""
14+
global _cached_path
15+
if _cached_path is not None:
16+
return _cached_path
17+
18+
if workspace_dir is None:
19+
workspace_dir = os.environ.get(
20+
"CNCTOOLS_WORKSPACE",
21+
str(Path.home() / "cnctools-workspace"),
22+
)
23+
24+
# Ensure workspace exists
25+
Path(workspace_dir).mkdir(parents=True, exist_ok=True)
26+
27+
config_dir = Path.home() / ".cache" / "cnctools"
28+
config_dir.mkdir(parents=True, exist_ok=True)
29+
config_path = config_dir / "mcp-config.json"
30+
31+
config = {
32+
"mcpServers": {
33+
"file-manager": {
34+
"type": "stdio",
35+
"command": sys.executable,
36+
"args": ["-m", "cnctools.mcp_server"],
37+
"env": {"CNCTOOLS_WORKSPACE": workspace_dir},
38+
}
39+
}
40+
}
41+
42+
config_path.write_text(json.dumps(config, indent=2))
43+
_cached_path = str(config_path)
44+
return _cached_path

0 commit comments

Comments
 (0)