Skip to content

Commit 8aab28a

Browse files
fix: address CodeRabbit review issues for PR #141
- Cache ui_config in client.py to avoid 3 redundant file reads - Fix env:None in MCP config by omitting key when empty - Add integration tests for generate_design_tokens_from_spec - Fix Phase 3b → 3d reference in create-spec.md - Remove duplicate ROOT_DIR in spec_chat_session.py - Add ARIA listbox role to UILibrarySelector and VisualStyleSelector - Remove redundant isCompatible check in UILibrarySelector - Add OSError handling in design_tokens.py - Move constants to top of app_spec_parser.py - Improve XML comment filtering with regex pattern Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent c5ab6d7 commit 8aab28a

File tree

8 files changed

+185
-79
lines changed

8 files changed

+185
-79
lines changed

.claude/commands/create-spec.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,7 @@ These are just reference points - your actual count should come from the require
293293

294294
**MANDATORY: Infrastructure Features**
295295

296-
If the app requires a database (Phase 3b answer was "Yes" or "Not sure"), you MUST include 5 Infrastructure features (indices 0-4):
296+
If the app requires a database (Phase 3d answer was "Yes" or "Not sure"), you MUST include 5 Infrastructure features (indices 0-4):
297297
1. Database connection established
298298
2. Database schema applied correctly
299299
3. Data persists across server restart

app_spec_parser.py

Lines changed: 48 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,41 @@
44
55
Shared utilities for parsing app_spec.txt sections.
66
Used by client.py, prompts.py, and design_tokens.py to avoid code duplication.
7+
8+
This module provides functions to:
9+
- Extract XML sections from app_spec.txt
10+
- Parse UI component library configuration
11+
- Parse visual style configuration
12+
- Combine configurations for the coding agent
713
"""
814

915
import re
1016
from pathlib import Path
1117
from typing import TypedDict
1218

19+
# =============================================================================
20+
# Constants
21+
# =============================================================================
22+
23+
# Valid UI library options
24+
VALID_UI_LIBRARIES = {"shadcn-ui", "ark-ui", "radix-ui", "none"}
25+
26+
# Libraries that have MCP server support
27+
MCP_SUPPORTED_LIBRARIES = {"shadcn-ui", "ark-ui"}
28+
29+
# Valid visual style options
30+
VALID_VISUAL_STYLES = {"default", "neobrutalism", "glassmorphism", "retro", "custom"}
31+
32+
# Valid frameworks
33+
VALID_FRAMEWORKS = {"react", "vue", "solid", "svelte"}
34+
35+
# Regex pattern to match XML comments (<!-- ... -->)
36+
XML_COMMENT_PATTERN = re.compile(r"<!--.*?-->", re.DOTALL)
37+
38+
# =============================================================================
39+
# Type Definitions
40+
# =============================================================================
41+
1342

1443
class UIComponentsConfig(TypedDict, total=False):
1544
"""Configuration for UI component library."""
@@ -33,6 +62,11 @@ class UIConfig(TypedDict, total=False):
3362
design_tokens_path: str
3463

3564

65+
# =============================================================================
66+
# Parsing Functions
67+
# =============================================================================
68+
69+
3670
def parse_section(content: str, section_name: str) -> str | None:
3771
"""
3872
Parse an XML section from app_spec.txt content.
@@ -53,19 +87,29 @@ def parse_xml_value(content: str, tag_name: str) -> str | None:
5387
"""
5488
Parse a single XML value from content.
5589
90+
Extracts the text content from an XML tag, filtering out any XML comments.
91+
Returns None if the tag is not found or contains only whitespace/comments.
92+
5693
Args:
5794
content: XML content to search
5895
tag_name: The tag name to extract value from
5996
6097
Returns:
61-
The value inside the tags, or None if not found.
98+
The value inside the tags, or None if not found or empty.
99+
100+
Example:
101+
>>> parse_xml_value("<library>shadcn-ui</library>", "library")
102+
'shadcn-ui'
103+
>>> parse_xml_value("<library><!-- comment --></library>", "library")
104+
None
62105
"""
63106
pattern = rf"<{tag_name}[^>]*>(.*?)</{tag_name}>"
64107
match = re.search(pattern, content, re.DOTALL)
65108
if match:
66-
value = match.group(1).strip()
67-
# Filter out XML comments
68-
if value and not value.startswith("<!--"):
109+
value = match.group(1)
110+
# Remove XML comments using regex pattern
111+
value = XML_COMMENT_PATTERN.sub("", value).strip()
112+
if value:
69113
return value
70114
return None
71115

@@ -169,16 +213,3 @@ def get_ui_config_from_spec(project_dir: Path) -> UIConfig | None:
169213
return parse_ui_config(content)
170214
except (OSError, UnicodeDecodeError):
171215
return None
172-
173-
174-
# Valid UI library options
175-
VALID_UI_LIBRARIES = {"shadcn-ui", "ark-ui", "radix-ui", "none"}
176-
177-
# Libraries that have MCP server support
178-
MCP_SUPPORTED_LIBRARIES = {"shadcn-ui", "ark-ui"}
179-
180-
# Valid visual style options
181-
VALID_VISUAL_STYLES = {"default", "neobrutalism", "glassmorphism", "retro", "custom"}
182-
183-
# Valid frameworks
184-
VALID_FRAMEWORKS = {"react", "vue", "solid", "svelte"}

client.py

Lines changed: 53 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,12 @@ def create_client(
354354
Note: Authentication is handled by start.bat/start.sh before this runs.
355355
The Claude SDK auto-detects credentials from the Claude CLI configuration
356356
"""
357+
# Cache UI config once to avoid repeated file reads
358+
# This is used for both allowed_tools and MCP server configuration
359+
ui_mcp_disabled = os.getenv("DISABLE_UI_MCP", "").lower() == "true"
360+
ui_config = None if ui_mcp_disabled else get_ui_config_from_spec(project_dir)
361+
has_ui_mcp = ui_config is not None and ui_config.get("has_mcp", False)
362+
357363
# Build allowed tools list based on mode
358364
# In YOLO mode, exclude Playwright tools for faster prototyping
359365
allowed_tools = [*BUILTIN_TOOLS, *FEATURE_MCP_TOOLS]
@@ -362,10 +368,8 @@ def create_client(
362368

363369
# Add UI MCP tools if the project uses a library with MCP support
364370
# UI MCP is available in both standard and YOLO mode
365-
if os.getenv("DISABLE_UI_MCP", "").lower() != "true":
366-
ui_config = get_ui_config_from_spec(project_dir)
367-
if ui_config and ui_config.get("has_mcp"):
368-
allowed_tools.extend(UI_MCP_TOOLS)
371+
if has_ui_mcp:
372+
allowed_tools.extend(UI_MCP_TOOLS)
369373

370374
# Build permissions list
371375
permissions_list = [
@@ -399,10 +403,8 @@ def create_client(
399403
permissions_list.extend(PLAYWRIGHT_TOOLS)
400404

401405
# Add UI MCP tools to permissions if available
402-
if os.getenv("DISABLE_UI_MCP", "").lower() != "true":
403-
ui_config = get_ui_config_from_spec(project_dir)
404-
if ui_config and ui_config.get("has_mcp"):
405-
permissions_list.extend(UI_MCP_TOOLS)
406+
if has_ui_mcp:
407+
permissions_list.extend(UI_MCP_TOOLS)
406408

407409
# Create comprehensive security settings
408410
# Note: Using relative paths ("./**") restricts access to project directory
@@ -485,52 +487,50 @@ def create_client(
485487

486488
# UI Components MCP server (available in both standard and YOLO mode)
487489
# Only added for libraries with MCP support (shadcn-ui, ark-ui)
488-
if os.getenv("DISABLE_UI_MCP", "").lower() != "true":
489-
ui_config = get_ui_config_from_spec(project_dir)
490-
if ui_config and ui_config.get("has_mcp"):
491-
library = ui_config.get("library", "")
492-
framework = ui_config.get("framework", "react")
490+
if has_ui_mcp and ui_config:
491+
library = ui_config.get("library", "")
492+
framework = ui_config.get("framework", "react")
493493

494-
try:
495-
npx_cmd = get_npx_command()
496-
497-
if library == "shadcn-ui":
498-
# shadcn/ui MCP server for React components
499-
# Uses GitHub API - benefits from GITHUB_PERSONAL_ACCESS_TOKEN for rate limits
500-
ui_mcp_args = [
501-
"-y",
502-
"--prefer-offline",
503-
f"@jpisnice/shadcn-ui-mcp-server@{SHADCN_MCP_VERSION}",
504-
"--framework", framework,
505-
]
506-
ui_mcp_env = {}
507-
github_token = os.getenv("GITHUB_PERSONAL_ACCESS_TOKEN")
508-
if github_token:
509-
ui_mcp_env["GITHUB_TOKEN"] = github_token
510-
511-
mcp_servers["ui_components"] = {
512-
"command": npx_cmd,
513-
"args": ui_mcp_args,
514-
"env": ui_mcp_env if ui_mcp_env else None,
515-
}
516-
print(f" - UI MCP: shadcn/ui ({framework})")
517-
518-
elif library == "ark-ui":
519-
# Ark UI MCP server for multi-framework headless components
520-
ui_mcp_args = [
521-
"-y",
522-
"--prefer-offline",
523-
f"@ark-ui/mcp@{ARK_MCP_VERSION}",
524-
]
525-
mcp_servers["ui_components"] = {
526-
"command": npx_cmd,
527-
"args": ui_mcp_args,
528-
}
529-
print(f" - UI MCP: Ark UI ({framework})")
530-
531-
except RuntimeError as e:
532-
# npx not found - graceful degradation
533-
print(f" - Warning: UI MCP disabled - {e}")
494+
try:
495+
npx_cmd = get_npx_command()
496+
497+
if library == "shadcn-ui":
498+
# shadcn/ui MCP server for React components
499+
# Uses GitHub API - benefits from GITHUB_PERSONAL_ACCESS_TOKEN for rate limits
500+
ui_mcp_args = [
501+
"-y",
502+
"--prefer-offline",
503+
f"@jpisnice/shadcn-ui-mcp-server@{SHADCN_MCP_VERSION}",
504+
"--framework", framework,
505+
]
506+
ui_mcp_config: dict = {
507+
"command": npx_cmd,
508+
"args": ui_mcp_args,
509+
}
510+
# Only add env if there are environment variables to pass
511+
github_token = os.getenv("GITHUB_PERSONAL_ACCESS_TOKEN")
512+
if github_token:
513+
ui_mcp_config["env"] = {"GITHUB_TOKEN": github_token}
514+
515+
mcp_servers["ui_components"] = ui_mcp_config
516+
print(f" - UI MCP: shadcn/ui ({framework})")
517+
518+
elif library == "ark-ui":
519+
# Ark UI MCP server for multi-framework headless components
520+
ui_mcp_args = [
521+
"-y",
522+
"--prefer-offline",
523+
f"@ark-ui/mcp@{ARK_MCP_VERSION}",
524+
]
525+
mcp_servers["ui_components"] = {
526+
"command": npx_cmd,
527+
"args": ui_mcp_args,
528+
}
529+
print(f" - UI MCP: Ark UI ({framework})")
530+
531+
except RuntimeError as e:
532+
# npx not found - graceful degradation
533+
print(f" - Warning: UI MCP disabled - {e}")
534534

535535
# Build environment overrides for API endpoint configuration
536536
# These override system env vars for the Claude CLI subprocess,

design_tokens.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,8 +158,11 @@ def generate_design_tokens(project_dir: Path, style: str) -> Path | None:
158158
style: The visual style to use
159159
160160
Returns:
161-
Path to the generated tokens file, or None if style is default.
161+
Path to the generated tokens file, or None if style is default/custom
162+
or if file write fails.
162163
"""
164+
# "default" uses library defaults, no tokens needed
165+
# "custom" means user will define their own tokens manually
163166
if style == "default" or style == "custom":
164167
return None
165168

@@ -173,7 +176,11 @@ def generate_design_tokens(project_dir: Path, style: str) -> Path | None:
173176

174177
# Write design tokens
175178
tokens_path = autocoder_dir / "design-tokens.json"
176-
tokens_path.write_text(json.dumps(preset, indent=2), encoding="utf-8")
179+
try:
180+
tokens_path.write_text(json.dumps(preset, indent=2), encoding="utf-8")
181+
except OSError:
182+
# File write failed (permissions, disk full, etc.)
183+
return None
177184

178185
return tokens_path
179186

server/services/spec_chat_session.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,6 @@ async def _make_multimodal_message(content_blocks: list[dict]) -> AsyncGenerator
5959
"session_id": "default",
6060
}
6161

62-
# Root directory of the project
63-
ROOT_DIR = Path(__file__).parent.parent.parent
64-
6562

6663
class SpecChatSession:
6764
"""

test_ui_config.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from design_tokens import (
2424
STYLE_PRESETS,
2525
generate_design_tokens,
26+
generate_design_tokens_from_spec,
2627
get_style_preset,
2728
validate_visual_style,
2829
)
@@ -343,6 +344,67 @@ def test_generate_custom_tokens(self):
343344
assert result is None
344345

345346

347+
# =============================================================================
348+
# Test: generate_design_tokens_from_spec
349+
# =============================================================================
350+
351+
class TestGenerateDesignTokensFromSpec:
352+
"""Tests for generate_design_tokens_from_spec function."""
353+
354+
def test_generate_tokens_from_neobrutalism_spec(self):
355+
"""Integration test: generate tokens from a spec file with neobrutalism style."""
356+
with tempfile.TemporaryDirectory() as tmpdir:
357+
project_dir = Path(tmpdir)
358+
prompts_dir = project_dir / "prompts"
359+
prompts_dir.mkdir()
360+
361+
spec_content = """
362+
<project_specification>
363+
<visual_style>
364+
<style>neobrutalism</style>
365+
<design_tokens_path>.autocoder/design-tokens.json</design_tokens_path>
366+
</visual_style>
367+
</project_specification>
368+
"""
369+
(prompts_dir / "app_spec.txt").write_text(spec_content)
370+
371+
result = generate_design_tokens_from_spec(project_dir)
372+
373+
assert result is not None
374+
assert result.exists()
375+
assert result.name == "design-tokens.json"
376+
377+
tokens = json.loads(result.read_text())
378+
assert tokens["borders"]["width"] == "4px"
379+
assert tokens["borders"]["radius"] == "0"
380+
381+
def test_generate_tokens_from_default_spec(self):
382+
"""No tokens generated for default style."""
383+
with tempfile.TemporaryDirectory() as tmpdir:
384+
project_dir = Path(tmpdir)
385+
prompts_dir = project_dir / "prompts"
386+
prompts_dir.mkdir()
387+
388+
spec_content = """
389+
<project_specification>
390+
<visual_style>
391+
<style>default</style>
392+
</visual_style>
393+
</project_specification>
394+
"""
395+
(prompts_dir / "app_spec.txt").write_text(spec_content)
396+
397+
result = generate_design_tokens_from_spec(project_dir)
398+
assert result is None
399+
400+
def test_generate_tokens_missing_spec(self):
401+
"""Returns None when spec file doesn't exist."""
402+
with tempfile.TemporaryDirectory() as tmpdir:
403+
project_dir = Path(tmpdir)
404+
result = generate_design_tokens_from_spec(project_dir)
405+
assert result is None
406+
407+
346408
# =============================================================================
347409
# Test: validate_visual_style
348410
# =============================================================================

ui/src/components/UILibrarySelector.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,11 +84,16 @@ export function UILibrarySelector({
8484
}
8585

8686
const isCompatible = (lib: UILibrary) => {
87-
return lib.frameworks.includes(framework) || lib.id === 'none'
87+
// 'none' is always compatible as it's a catch-all for any framework
88+
return lib.frameworks.includes(framework)
8889
}
8990

9091
return (
91-
<div className={cn('grid grid-cols-1 gap-3 sm:grid-cols-2', className)}>
92+
<div
93+
className={cn('grid grid-cols-1 gap-3 sm:grid-cols-2', className)}
94+
role="listbox"
95+
aria-label="Select UI component library"
96+
>
9297
{UI_LIBRARIES.map((lib) => {
9398
const selected = value === lib.id
9499
const compatible = isCompatible(lib)

ui/src/components/VisualStyleSelector.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,11 @@ export function VisualStyleSelector({
163163
}
164164

165165
return (
166-
<div className={cn('grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3', className)}>
166+
<div
167+
className={cn('grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3', className)}
168+
role="listbox"
169+
aria-label="Select visual style"
170+
>
167171
{VISUAL_STYLES.map((style) => {
168172
const selected = value === style.id
169173
// hoveredId is tracked for potential future hover preview effects

0 commit comments

Comments
 (0)