Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 17:51:59 +08:00
commit 38e80921c8
89 changed files with 20480 additions and 0 deletions

View File

@@ -0,0 +1,448 @@
#!/usr/bin/env python3
"""
Navigator CLAUDE.md Updater
Extracts customizations and generates updated CLAUDE.md with v3.1 template
"""
import sys
import json
import re
from pathlib import Path
from typing import Dict, List, Optional
from urllib import request
from urllib.error import URLError, HTTPError
def get_plugin_version() -> Optional[str]:
"""
Get installed Navigator plugin version from plugin.json.
Returns:
Version string (e.g., "4.3.0") or None if not found
"""
possible_paths = [
Path.home() / '.claude' / 'plugins' / 'marketplaces' / 'navigator-marketplace' / '.claude-plugin' / 'plugin.json',
Path.home() / '.config' / 'claude' / 'plugins' / 'navigator' / '.claude-plugin' / 'plugin.json',
Path.home() / '.claude' / 'plugins' / 'navigator' / '.claude-plugin' / 'plugin.json',
]
for path in possible_paths:
if path.exists():
try:
with open(path, 'r') as f:
data = json.load(f)
return data.get('version')
except (json.JSONDecodeError, FileNotFoundError, PermissionError):
continue
return None
def fetch_template_from_github(version: Optional[str] = None) -> Optional[str]:
"""
Fetch CLAUDE.md template from GitHub releases.
Priority:
1. Specified version (e.g., 'v4.3.0' or '4.3.0')
2. Detected plugin version
3. Returns None (caller should use bundled fallback)
Args:
version: Specific version to fetch (optional)
Returns:
Template content as string, or None if fetch fails
"""
if not version:
version = get_plugin_version()
if not version:
return None
# Ensure version has 'v' prefix for GitHub URL
if not version.startswith('v'):
version = f'v{version}'
github_url = f"https://raw.githubusercontent.com/alekspetrov/navigator/{version}/templates/CLAUDE.md"
try:
req = request.Request(github_url)
req.add_header('User-Agent', 'Navigator-CLAUDE-Updater')
with request.urlopen(req, timeout=10) as response:
if response.status == 200:
content = response.read().decode('utf-8')
return content
except (URLError, HTTPError, TimeoutError) as e:
# Silent fail - caller will use bundled template
print(f"⚠️ Could not fetch template from GitHub ({version}): {e}", file=sys.stderr)
print(f" Falling back to bundled template", file=sys.stderr)
return None
return None
def get_template_path(bundled_template_dir: str, version: Optional[str] = None) -> tuple[str, bool]:
"""
Get template path, preferring GitHub source over bundled.
Args:
bundled_template_dir: Path to bundled templates directory
version: Optional specific version to fetch
Returns:
Tuple of (template_path_or_content, is_from_github)
"""
# Try GitHub first
github_template = fetch_template_from_github(version)
if github_template:
# Write to temporary file
import tempfile
temp_file = tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False)
temp_file.write(github_template)
temp_file.close()
detected_version = version or get_plugin_version()
print(f"✓ Using template from GitHub ({detected_version})", file=sys.stderr)
return (temp_file.name, True)
# Fallback to bundled
bundled_path = Path(bundled_template_dir) / 'CLAUDE.md'
if bundled_path.exists():
bundled_version = get_plugin_version() or "unknown"
print(f"✓ Using bundled template (v{bundled_version})", file=sys.stderr)
return (str(bundled_path), False)
raise FileNotFoundError(f"No template found (GitHub failed, bundled not at {bundled_path})")
def extract_section(content: str, header: str, next_headers: List[str]) -> Optional[str]:
"""Extract content between header and next section header"""
# Find header (supports ## or # with various markdown formats)
header_pattern = r'^#{1,2}\s+' + re.escape(header) + r'.*?$'
match = re.search(header_pattern, content, re.MULTILINE | re.IGNORECASE)
if not match:
return None
start = match.end()
# Find next header
next_pattern = r'^#{1,2}\s+(' + '|'.join(re.escape(h) for h in next_headers) + r').*?$'
next_match = re.search(next_pattern, content[start:], re.MULTILINE | re.IGNORECASE)
if next_match:
end = start + next_match.start()
else:
end = len(content)
section = content[start:end].strip()
return section if section else None
def extract_customizations(claude_md_path: str) -> Dict:
"""Extract project-specific customizations from CLAUDE.md"""
with open(claude_md_path, 'r', encoding='utf-8') as f:
content = f.read()
customizations = {
"project_name": "",
"description": "",
"tech_stack": [],
"code_standards": [],
"forbidden_actions": [],
"pm_tool": "none",
"custom_sections": {}
}
# Extract project name (first # header)
title_match = re.search(r'^#\s+(.+?)\s*-\s*Claude Code Configuration', content, re.MULTILINE)
if title_match:
customizations["project_name"] = title_match.group(1).strip()
# Extract description from Context section
context = extract_section(content, "Context", [
"Navigator Quick Start", "Quick Start", "Project-Specific", "Code Standards",
"Forbidden Actions", "Documentation", "Project Management"
])
if context:
# Extract brief description (text before tech stack, excluding brackets)
lines = context.split('\n')
desc_lines = []
for line in lines:
line = line.strip()
if line and not line.startswith('**Tech Stack') and not line.startswith('['):
desc_lines.append(line)
if line.startswith('**Tech Stack'):
break
if desc_lines:
customizations["description"] = ' '.join(desc_lines)
# Extract tech stack
tech_match = re.search(r'\*\*Tech Stack\*\*:\s*(.+?)(?:\n|$)', context)
if tech_match:
tech_text = tech_match.group(1).strip()
# Remove brackets and split by comma
tech_text = re.sub(r'\[|\]', '', tech_text)
customizations["tech_stack"] = [t.strip() for t in tech_text.split(',')]
# Extract code standards
standards_section = extract_section(content, "Project-Specific Code Standards", [
"Forbidden", "Documentation", "Project Management", "Configuration",
"Commit Guidelines", "Success Metrics"
])
if not standards_section:
standards_section = extract_section(content, "Code Standards", [
"Forbidden", "Documentation", "Project Management", "Configuration"
])
if standards_section:
# Extract custom rules (lines that aren't in default template)
default_rules = [
"KISS, DRY, SOLID",
"TypeScript",
"Strict mode",
"Line Length",
"Max 100",
"Testing",
"Framework-Specific",
"General Standards",
"Architecture"
]
lines = standards_section.split('\n')
for line in lines:
line = line.strip()
# Skip empty lines, headers, and default rules
if not line or line.startswith('#') or line.startswith('**'):
continue
# Check if it's a custom rule
is_default = any(rule in line for rule in default_rules)
if not is_default:
if line.startswith('-') or line.startswith('*'):
customizations["code_standards"].append(line.lstrip('-*').strip())
elif ':' in line: # Format like "Custom rule: Always use hooks"
customizations["code_standards"].append(line)
# Extract forbidden actions
forbidden_section = extract_section(content, "Forbidden Actions", [
"Documentation", "Project Management", "Configuration",
"Commit Guidelines", "Success Metrics"
])
if forbidden_section:
# Extract custom forbidden actions (not in default template)
default_forbidden = [
"NEVER wait for explicit commit",
"NEVER leave tickets open",
"NEVER skip documentation",
"NEVER load all `.agent/`",
"NEVER load all .agent",
"NEVER skip reading DEVELOPMENT-README",
"No Claude Code mentions",
"No package.json modifications",
"Never commit secrets",
"Don't delete tests",
"NEVER skip tests"
]
lines = forbidden_section.split('\n')
for line in lines:
line = line.strip()
# Skip empty lines and headers
if not line or line.startswith('#') or line.startswith('**'):
continue
if line.startswith('') or line.startswith('-'):
action = line.lstrip('❌- ').strip()
# Check if it's truly custom
is_default = any(df in action for df in default_forbidden)
if action and not is_default:
# Remove any leading emoji that might remain
action = action.lstrip('')
customizations["forbidden_actions"].append(action)
# Extract PM tool configuration
pm_section = extract_section(content, "Project Management", [
"Configuration", "Commit Guidelines", "Success Metrics"
])
if pm_section:
# Look for configured tool
tool_match = re.search(r'\*\*Configured Tool\*\*:\s*(\w+)', pm_section, re.IGNORECASE)
if tool_match:
tool = tool_match.group(1).lower()
if tool in ['linear', 'github', 'jira', 'gitlab']:
customizations["pm_tool"] = tool
# Extract custom sections (not in standard template)
standard_sections = [
"Context", "Navigator", "Quick Start", "Code Standards",
"Project-Specific Code Standards", "Forbidden Actions",
"Documentation Structure", "Project Management",
"Configuration", "Commit Guidelines", "Success Metrics"
]
# Find all ## headers
headers = re.findall(r'^##\s+(.+?)$', content, re.MULTILINE)
for header in headers:
if header.strip() not in standard_sections:
section_content = extract_section(content, header, standard_sections + headers)
if section_content:
customizations["custom_sections"][header.strip()] = section_content
return customizations
def generate_updated_claude_md(customizations: Dict, template_path: str, output_path: str):
"""Generate updated CLAUDE.md using v3.1 template and customizations"""
with open(template_path, 'r', encoding='utf-8') as f:
template = f.read()
# Replace project name
if customizations["project_name"]:
template = template.replace('[Project Name]', customizations["project_name"])
# Replace description
if customizations["description"]:
template = template.replace(
'[Brief project description - explain what this project does]',
customizations["description"]
)
# Replace tech stack
if customizations["tech_stack"]:
tech_stack = ', '.join(customizations["tech_stack"])
template = template.replace(
'[List your technologies, e.g., Next.js, TypeScript, PostgreSQL]',
tech_stack
)
# Append custom code standards
if customizations["code_standards"]:
standards_marker = "[Add project-specific violations here]"
if standards_marker in template:
custom_standards = "\n\n### Additional Project Standards\n\n"
for standard in customizations["code_standards"]:
custom_standards += f"- {standard}\n"
template = template.replace(standards_marker, custom_standards + "\n" + standards_marker)
# Append custom forbidden actions
if customizations["forbidden_actions"]:
forbidden_marker = "[Add project-specific violations here]"
if forbidden_marker in template:
custom_forbidden = "\n### Additional Forbidden Actions\n\n"
for action in customizations["forbidden_actions"]:
custom_forbidden += f"- ❌ {action}\n"
# Find the marker and append after it
template = template.replace(forbidden_marker, custom_forbidden)
# Update PM tool
if customizations["pm_tool"] != "none":
template = template.replace(
'**Configured Tool**: [Linear / GitHub Issues / Jira / GitLab / None]',
f'**Configured Tool**: {customizations["pm_tool"].title()}'
)
# Update config JSON
template = template.replace(
'"project_management": "none"',
f'"project_management": "{customizations["pm_tool"]}"'
)
# Append custom sections at the end
if customizations["custom_sections"]:
template += "\n\n---\n\n## Custom Project Sections\n\n"
for section_name, section_content in customizations["custom_sections"].items():
template += f"### {section_name}\n\n{section_content}\n\n"
# Write updated file
with open(output_path, 'w', encoding='utf-8') as f:
f.write(template)
def main():
if len(sys.argv) < 3:
print("Usage:", file=sys.stderr)
print(" Extract: python3 claude_updater.py extract CLAUDE.md > customizations.json", file=sys.stderr)
print(" Generate: python3 claude_updater.py generate --customizations file.json --template template.md --output CLAUDE.md", file=sys.stderr)
sys.exit(1)
command = sys.argv[1]
if command == "extract":
claude_md_path = sys.argv[2]
if not Path(claude_md_path).exists():
print(f"Error: File not found: {claude_md_path}", file=sys.stderr)
sys.exit(1)
try:
customizations = extract_customizations(claude_md_path)
print(json.dumps(customizations, indent=2))
except Exception as e:
print(f"Error extracting customizations: {e}", file=sys.stderr)
sys.exit(2)
elif command == "generate":
# Parse arguments
args = {
'customizations': None,
'template': None,
'output': None
}
i = 2
while i < len(sys.argv):
if sys.argv[i] == '--customizations' and i + 1 < len(sys.argv):
args['customizations'] = sys.argv[i + 1]
i += 2
elif sys.argv[i] == '--template' and i + 1 < len(sys.argv):
args['template'] = sys.argv[i + 1]
i += 2
elif sys.argv[i] == '--output' and i + 1 < len(sys.argv):
args['output'] = sys.argv[i + 1]
i += 2
else:
i += 1
if not all(args.values()):
print("Error: Missing required arguments", file=sys.stderr)
print("Required: --customizations, --template, --output", file=sys.stderr)
sys.exit(1)
try:
with open(args['customizations'], 'r') as f:
customizations = json.load(f)
# Use get_template_path for GitHub fetch with bundled fallback
# If --template is a directory, treat it as bundled_template_dir
# Otherwise, use it directly as a file path
template_arg = args['template']
if Path(template_arg).is_dir():
# Directory provided - use get_template_path for smart fetching
template_path, is_github = get_template_path(template_arg)
elif Path(template_arg).is_file():
# File provided directly - use as-is (backward compatibility)
template_path = template_arg
is_github = False
else:
# Try parent directory for get_template_path
template_dir = str(Path(template_arg).parent)
template_path, is_github = get_template_path(template_dir)
generate_updated_claude_md(customizations, template_path, args['output'])
print(f"✓ Generated {args['output']}", file=sys.stderr)
# Cleanup temp file if from GitHub
if is_github and Path(template_path).exists():
Path(template_path).unlink()
except Exception as e:
print(f"Error generating CLAUDE.md: {e}", file=sys.stderr)
sys.exit(2)
else:
print(f"Error: Unknown command: {command}", file=sys.stderr)
print("Valid commands: extract, generate", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()