Rename project from SmartTools to CmdForge
Major rename operation: - Rename Python package: smarttools -> cmdforge - Update CLI entry point: smarttools -> cmdforge - Update all imports and module references - Update pyproject.toml with new name, URLs, entry point - Update all HTML templates with new branding - Update documentation (CLAUDE.md, README.md, docs/*, wiki/*) - Update environment variables: - SMARTTOOLS_ENV -> CMDFORGE_ENV - SMARTTOOLS_REGISTRY_DB -> CMDFORGE_REGISTRY_DB - SMARTTOOLS_TOKEN -> CMDFORGE_TOKEN - SMARTTOOLS_REGISTRY_* -> CMDFORGE_REGISTRY_* - Update Dockerfile and docker-compose.yml - Update tests to use new package name - Update scripts and examples - Update package.json and tailwind.config.js All 158 unit tests pass. The CLI is working correctly with the new cmdforge command. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
"""Project manifest (cmdforge.yaml) handling.
|
||||
|
||||
Manages project-level tool dependencies and overrides.
|
||||
"""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
MANIFEST_FILENAME = "cmdforge.yaml"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Dependency:
|
||||
"""A tool dependency declaration."""
|
||||
name: str # owner/name format (e.g., "rob/summarize")
|
||||
version: str = "*" # Version constraint (e.g., ">=1.0.0", "^1.2.0")
|
||||
|
||||
@property
|
||||
def owner(self) -> Optional[str]:
|
||||
"""Extract owner from name if present."""
|
||||
if "/" in self.name:
|
||||
return self.name.split("/")[0]
|
||||
return None
|
||||
|
||||
@property
|
||||
def tool_name(self) -> str:
|
||||
"""Extract tool name without owner."""
|
||||
if "/" in self.name:
|
||||
return self.name.split("/")[1]
|
||||
return self.name
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"name": self.name,
|
||||
"version": self.version
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "Dependency":
|
||||
if isinstance(data, str):
|
||||
# Simple string format: "rob/summarize" or "rob/summarize@^1.0.0"
|
||||
if "@" in data:
|
||||
name, version = data.rsplit("@", 1)
|
||||
return cls(name=name, version=version)
|
||||
return cls(name=data)
|
||||
|
||||
return cls(
|
||||
name=data["name"],
|
||||
version=data.get("version", "*")
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolOverride:
|
||||
"""Runtime overrides for a tool."""
|
||||
provider: Optional[str] = None
|
||||
# Future: other overrides like timeout, retries, etc.
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = {}
|
||||
if self.provider:
|
||||
d["provider"] = self.provider
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "ToolOverride":
|
||||
return cls(
|
||||
provider=data.get("provider")
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Manifest:
|
||||
"""Project manifest (cmdforge.yaml)."""
|
||||
name: str = "my-project"
|
||||
version: str = "1.0.0"
|
||||
dependencies: List[Dependency] = field(default_factory=list)
|
||||
overrides: Dict[str, ToolOverride] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = {
|
||||
"name": self.name,
|
||||
"version": self.version,
|
||||
}
|
||||
|
||||
if self.dependencies:
|
||||
d["dependencies"] = [dep.to_dict() for dep in self.dependencies]
|
||||
|
||||
if self.overrides:
|
||||
d["overrides"] = {
|
||||
name: override.to_dict()
|
||||
for name, override in self.overrides.items()
|
||||
}
|
||||
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "Manifest":
|
||||
dependencies = []
|
||||
for dep in data.get("dependencies", []):
|
||||
dependencies.append(Dependency.from_dict(dep))
|
||||
|
||||
overrides = {}
|
||||
for name, override_data in data.get("overrides", {}).items():
|
||||
overrides[name] = ToolOverride.from_dict(override_data)
|
||||
|
||||
return cls(
|
||||
name=data.get("name", "my-project"),
|
||||
version=data.get("version", "1.0.0"),
|
||||
dependencies=dependencies,
|
||||
overrides=overrides
|
||||
)
|
||||
|
||||
def get_override(self, tool_name: str) -> Optional[ToolOverride]:
|
||||
"""Get override for a tool by name (checks both full and short names)."""
|
||||
# Try exact match first
|
||||
if tool_name in self.overrides:
|
||||
return self.overrides[tool_name]
|
||||
|
||||
# Try matching just the tool name part (without owner)
|
||||
short_name = tool_name.split("/")[-1] if "/" in tool_name else tool_name
|
||||
for override_name, override in self.overrides.items():
|
||||
override_short = override_name.split("/")[-1] if "/" in override_name else override_name
|
||||
if override_short == short_name:
|
||||
return override
|
||||
|
||||
return None
|
||||
|
||||
def add_dependency(self, name: str, version: str = "*") -> None:
|
||||
"""Add or update a dependency."""
|
||||
# Check if already exists
|
||||
for dep in self.dependencies:
|
||||
if dep.name == name:
|
||||
dep.version = version
|
||||
return
|
||||
|
||||
self.dependencies.append(Dependency(name=name, version=version))
|
||||
|
||||
|
||||
def find_manifest(start_dir: Optional[Path] = None) -> Optional[Path]:
|
||||
"""
|
||||
Find cmdforge.yaml by searching up from start_dir.
|
||||
|
||||
Args:
|
||||
start_dir: Directory to start searching from (default: cwd)
|
||||
|
||||
Returns:
|
||||
Path to manifest file, or None if not found
|
||||
"""
|
||||
if start_dir is None:
|
||||
start_dir = Path.cwd()
|
||||
|
||||
current = start_dir.resolve()
|
||||
|
||||
while current != current.parent:
|
||||
manifest_path = current / MANIFEST_FILENAME
|
||||
if manifest_path.exists():
|
||||
return manifest_path
|
||||
current = current.parent
|
||||
|
||||
# Check root
|
||||
manifest_path = current / MANIFEST_FILENAME
|
||||
if manifest_path.exists():
|
||||
return manifest_path
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def load_manifest(path: Optional[Path] = None) -> Optional[Manifest]:
|
||||
"""
|
||||
Load a project manifest.
|
||||
|
||||
Args:
|
||||
path: Path to manifest file, or None to search
|
||||
|
||||
Returns:
|
||||
Manifest object, or None if not found
|
||||
"""
|
||||
if path is None:
|
||||
path = find_manifest()
|
||||
|
||||
if path is None or not path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
data = yaml.safe_load(path.read_text()) or {}
|
||||
return Manifest.from_dict(data)
|
||||
except Exception as e:
|
||||
print(f"Warning: Error loading manifest: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def save_manifest(manifest: Manifest, path: Optional[Path] = None) -> Path:
|
||||
"""
|
||||
Save a manifest to disk.
|
||||
|
||||
Args:
|
||||
manifest: Manifest to save
|
||||
path: Path to save to (default: ./cmdforge.yaml)
|
||||
|
||||
Returns:
|
||||
Path where manifest was saved
|
||||
"""
|
||||
if path is None:
|
||||
path = Path.cwd() / MANIFEST_FILENAME
|
||||
|
||||
path.write_text(yaml.dump(manifest.to_dict(), default_flow_style=False, sort_keys=False))
|
||||
return path
|
||||
|
||||
|
||||
def create_manifest(
|
||||
name: str = "my-project",
|
||||
version: str = "1.0.0",
|
||||
path: Optional[Path] = None
|
||||
) -> Manifest:
|
||||
"""
|
||||
Create a new manifest.
|
||||
|
||||
Args:
|
||||
name: Project name
|
||||
version: Project version
|
||||
path: Path to save to (optional)
|
||||
|
||||
Returns:
|
||||
Created Manifest object
|
||||
"""
|
||||
manifest = Manifest(name=name, version=version)
|
||||
|
||||
if path is not None:
|
||||
save_manifest(manifest, path)
|
||||
|
||||
return manifest
|
||||
|
||||
|
||||
def parse_version_constraint(constraint: str) -> dict:
|
||||
"""
|
||||
Parse a version constraint string.
|
||||
|
||||
Args:
|
||||
constraint: Version constraint (e.g., ">=1.0.0", "^1.2.3", "~1.2.0")
|
||||
|
||||
Returns:
|
||||
Dict with operator and version parts
|
||||
"""
|
||||
constraint = constraint.strip()
|
||||
|
||||
# Exact version
|
||||
if re.match(r'^\d+\.\d+\.\d+', constraint):
|
||||
return {"operator": "=", "version": constraint}
|
||||
|
||||
# Any version
|
||||
if constraint == "*" or constraint == "latest":
|
||||
return {"operator": "*", "version": None}
|
||||
|
||||
# Range operators
|
||||
patterns = [
|
||||
(r'^>=(.+)$', ">="),
|
||||
(r'^<=(.+)$', "<="),
|
||||
(r'^>(.+)$', ">"),
|
||||
(r'^<(.+)$', "<"),
|
||||
(r'^\^(.+)$', "^"), # Compatible (same major)
|
||||
(r'^~(.+)$', "~"), # Approximately (same minor)
|
||||
]
|
||||
|
||||
for pattern, operator in patterns:
|
||||
match = re.match(pattern, constraint)
|
||||
if match:
|
||||
return {"operator": operator, "version": match.group(1)}
|
||||
|
||||
# Default to exact match
|
||||
return {"operator": "=", "version": constraint}
|
||||
Reference in New Issue
Block a user