Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,4 @@ pak_prd.md
steps.md
CLAUDE.md
.claude/
/.pixell
8 changes: 8 additions & 0 deletions apkg_test/agent.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ mcp:
enabled: true
config_file: "mcp.json"

# UI 설정 - 리액트 빌드 결과물 경로
ui:
path: "client/dist"

# REST API 설정 (정적 파일 서빙용)
rest:
entry: "src.rest.index:mount"

# Extended metadata for registry
metadata:
version: "0.1.0"
Expand Down
153 changes: 119 additions & 34 deletions pixell/core/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ def build(self, output_dir: Optional[Path] = None) -> Path:
# Copy files to temp directory
self._copy_agent_files(temp_path)

# Copy pre-built frontend if configured
self._copy_frontend_assets(temp_path)

# Create metadata
self._create_metadata(temp_path)

Expand Down Expand Up @@ -100,12 +103,12 @@ def _load_manifest(self):

def _copy_agent_files(self, dest_dir: Path):
"""Copy agent files to the build directory."""
# Files and directories to include
include_items = ["src", "agent.yaml", ".env"]
# Required files and directories
include_items = ["agent.yaml", ".env"]

# Optional files and directories (common Python project structures)
optional_items = [
"requirements.txt",
"src", # src/ directory is now optional
"README.md",
"LICENSE",
"core",
Expand All @@ -119,24 +122,47 @@ def _copy_agent_files(self, dest_dir: Path):
if self.manifest and self.manifest.mcp and self.manifest.mcp.config_file:
optional_items.append(self.manifest.mcp.config_file)

# Check for pyproject.toml (uv dependency management)
# If pyproject.toml exists, use it and skip requirements.txt
pyproject_toml = self.project_dir / "pyproject.toml"
requirements_txt = self.project_dir / "requirements.txt"

if pyproject_toml.exists():
optional_items.append("pyproject.toml")
print(f"[INFO] pyproject.toml found - will use it instead of requirements.txt")
elif requirements_txt.exists():
optional_items.append("requirements.txt")

# Copy required items
for item in include_items:
src_path = self.project_dir / item
dest_path = dest_dir / item

print(f"Copying {item}: {src_path} -> {dest_path}")
if src_path.is_dir():
shutil.copytree(
src_path, dest_path, ignore=shutil.ignore_patterns("__pycache__", "*.pyc")
)
# List files in the copied directory for debugging
for root, dirs, files in os.walk(dest_path):
for file in files:
file_path = Path(root) / file
print(f" Included: {file_path.relative_to(dest_dir)}")
else:
shutil.copy2(src_path, dest_path)
if not src_path.exists():
continue

# Special handling for agent.yaml: remove None values (especially entrypoint when optional)
if item == "agent.yaml" and self.manifest:
print(f"Copying {item} (with None values excluded): {src_path} -> {dest_path}")
# Dump manifest excluding None values to match server validation expectations
manifest_dict = self.manifest.model_dump(exclude_none=True)
with open(dest_path, "w", encoding="utf-8") as f:
yaml.dump(manifest_dict, f, default_flow_style=False, sort_keys=False)
print(f" Included: {dest_path.relative_to(dest_dir)}")
else:
print(f"Copying {item}: {src_path} -> {dest_path}")
if src_path.is_dir():
shutil.copytree(
src_path, dest_path, ignore=shutil.ignore_patterns("__pycache__", "*.pyc")
)
# List files in the copied directory for debugging
for root, dirs, files in os.walk(dest_path):
for file in files:
file_path = Path(root) / file
print(f" Included: {file_path.relative_to(dest_dir)}")
else:
shutil.copy2(src_path, dest_path)
print(f" Included: {dest_path.relative_to(dest_dir)}")

# Copy optional items if they exist
for item in optional_items:
Expand All @@ -150,6 +176,42 @@ def _copy_agent_files(self, dest_dir: Path):
else:
shutil.copy2(src_path, dest_path)

def _copy_frontend_assets(self, build_dir: Path):
"""Copy pre-built frontend assets if configured."""
if not self.manifest or not getattr(self.manifest, "ui", None):
return

ui_config = self.manifest.ui
if not ui_config or not ui_config.path:
return

# UI 소스 디렉토리 찾기 (agent.yaml에서 지정된 경로만 사용)
if not ui_config.path:
print(f"[WARN] UI path not specified in agent.yaml")
return

ui_source_dir = self.project_dir / ui_config.path
if not ui_source_dir.exists() or not ui_source_dir.is_dir():
print(f"[ERROR] Specified UI path not found: {ui_config.path}")
print(f"[INFO] Please ensure the path exists and contains built frontend files")
return

print(f"[INFO] Using UI path: {ui_config.path}")

# 빌드 결과물을 APKG에 복사 (원본 경로 구조 유지)
try:
# agent.yaml에서 지정한 경로 구조를 그대로 유지
ui_dest = build_dir / ui_config.path
ui_dest.parent.mkdir(parents=True, exist_ok=True)

if ui_dest.exists():
shutil.rmtree(ui_dest)
shutil.copytree(ui_source_dir, ui_dest)
print(f"[SUCCESS] Copied frontend to APKG at {ui_config.path}")

except Exception as e:
print(f"[ERROR] Failed to copy frontend: {e}")

def _create_metadata(self, build_dir: Path):
"""Create package metadata files."""
metadata_dir = build_dir / ".pixell"
Expand All @@ -158,18 +220,28 @@ def _create_metadata(self, build_dir: Path):
# Create package metadata
if not self.manifest:
raise BuildError("Manifest not loaded")

# Dump manifest, excluding None values (especially entrypoint when optional)
manifest_dict = self.manifest.model_dump(exclude_none=True)

package_meta = {
"format_version": "1.0",
"created_by": "pixell-kit",
"created_at": self._get_timestamp(),
"manifest": self.manifest.model_dump(),
"manifest": manifest_dict,
}

with open(metadata_dir / "package.json", "w") as f:
json.dump(package_meta, f, indent=2)

def _create_requirements(self, build_dir: Path):
"""Create requirements.txt from manifest if not present."""
"""Create requirements.txt from manifest if not present and pyproject.toml doesn't exist."""
# Skip if pyproject.toml exists (uv dependency management takes priority)
pyproject_toml = build_dir / "pyproject.toml"
if pyproject_toml.exists():
print("[INFO] pyproject.toml found - skipping requirements.txt generation")
return

req_path = build_dir / "requirements.txt"

if not req_path.exists() and self.manifest and self.manifest.dependencies:
Expand Down Expand Up @@ -435,39 +507,52 @@ def is_namespaced(pkg: str) -> bool:
return created

def _create_dist_layout(self, build_dir: Path):
"""Create /dist directory with surfaces assets according to PRD."""
"""Copy surface files directly to APKG root (no dist/ folder)."""
if not self.manifest:
raise BuildError("Manifest not loaded")

dist_dir = build_dir / "dist"
dist_dir.mkdir(exist_ok=True)

# A2A: copy server implementation into dist/a2a/
if getattr(self.manifest, "a2a", None) and self.manifest.a2a:
module_path, _func = self.manifest.a2a.service.split(":", 1)
# A2A: copy entry file directly to APKG root
if getattr(self.manifest, "a2a", None) and self.manifest.a2a and getattr(
self.manifest.a2a, "entry", None
):
module_path, _func = self.manifest.a2a.entry.split(":", 1)
src_file = self.project_dir / (module_path.replace(".", "/") + ".py")
a2a_dir = dist_dir / "a2a"
a2a_dir.mkdir(exist_ok=True)
if src_file.exists():
shutil.copy2(src_file, a2a_dir / src_file.name)
dest_file = build_dir / src_file.name
shutil.copy2(src_file, dest_file)
print(f"[A2A] Copied {src_file.name} to APKG root")

# REST: copy entry module into dist/rest/
# REST: copy entry file directly to APKG root
if getattr(self.manifest, "rest", None) and self.manifest.rest:
module_path, _func = self.manifest.rest.entry.split(":", 1)
rest_entry = self.manifest.rest.entry
# If rest.entry doesn't have ':', use entrypoint's module
if ":" not in rest_entry:
if self.manifest.entrypoint and ":" in self.manifest.entrypoint:
module_path, _ = self.manifest.entrypoint.split(":", 1)
else:
# Skip if no entrypoint to derive module from
print(f"[WARN] REST entry '{rest_entry}' is not in 'module:function' format and no entrypoint available")
return
else:
module_path, _func = rest_entry.split(":", 1)

src_file = self.project_dir / (module_path.replace(".", "/") + ".py")
rest_dir = dist_dir / "rest"
rest_dir.mkdir(exist_ok=True)
if src_file.exists():
shutil.copy2(src_file, rest_dir / src_file.name)
dest_file = build_dir / src_file.name
shutil.copy2(src_file, dest_file)
print(f"[REST] Copied {src_file.name} to APKG root")

# UI: copy static directory to dist/ui/
# UI: copy UI assets with original path structure to APKG root
if getattr(self.manifest, "ui", None) and self.manifest.ui and self.manifest.ui.path:
ui_src = self.project_dir / self.manifest.ui.path
ui_dest = dist_dir / "ui"
if ui_src.exists() and ui_src.is_dir():
# Preserve original path structure (e.g., "client/dist" -> "client/dist" in APKG)
ui_dest = build_dir / self.manifest.ui.path
ui_dest.parent.mkdir(parents=True, exist_ok=True)
if ui_dest.exists():
shutil.rmtree(ui_dest)
shutil.copytree(ui_src, ui_dest)
print(f"[UI] Copied UI assets to {self.manifest.ui.path} in APKG root")

def _create_deploy_metadata(self, build_dir: Path):
"""Emit deploy.json with exposed surfaces and ports."""
Expand Down
2 changes: 1 addition & 1 deletion pixell/core/deployment.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ class DeploymentClient:

# Environment configurations
ENVIRONMENTS = {
"local": {"base_url": "http://localhost:4000", "name": "Local Development"},
"local": {"base_url": "http://localhost:3000", "name": "Local Development"},
"prod": {"base_url": "https://cloud.pixell.global", "name": "Production"},
}

Expand Down
58 changes: 34 additions & 24 deletions pixell/core/validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,9 @@ def _validate_project_structure(self):
"Missing required .env file at project root. Create a `.env` with placeholders or real values. See `.env.example`."
)

# Check for source directory
# Check for source directory (optional)
src_dir = self.project_dir / "src"
if not src_dir.exists():
self.errors.append("Source directory 'src/' not found")
elif not src_dir.is_dir():
if src_dir.exists() and not src_dir.is_dir():
self.errors.append("'src' exists but is not a directory")

# Check for requirements.txt (warning if missing)
Expand Down Expand Up @@ -131,7 +129,7 @@ def _validate_entrypoint(self, manifest: AgentManifest):

# Basic check: look for function definition
try:
with open(file_path, "r") as f:
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
if f"def {function_name}" not in content:
self.warnings.append(f"Function '{function_name}' not found in {file_path}")
Expand All @@ -142,34 +140,46 @@ def _validate_surfaces(self, manifest: AgentManifest):
"""Validate A2A, REST, and UI configuration."""
# REST
if manifest.rest:
try:
rest_module, rest_func = manifest.rest.entry.split(":", 1)
rest_file = self.project_dir / (rest_module.replace(".", "/") + ".py")
if not rest_file.exists():
self.errors.append(f"REST entry module not found: {rest_file}")
rest_entry = manifest.rest.entry
# If rest.entry doesn't have ':', try to use entrypoint's module
if ":" not in rest_entry:
if manifest.entrypoint and ":" in manifest.entrypoint:
# Use entrypoint's module with rest.entry as function name
entrypoint_module, _ = manifest.entrypoint.split(":", 1)
rest_module = entrypoint_module
rest_func = rest_entry
else:
try:
with open(rest_file, "r") as f:
content = f.read()
if f"def {rest_func}" not in content:
self.warnings.append(
f"REST entry function '{rest_func}' not found in {rest_file}"
)
except Exception as exc:
self.warnings.append(f"Could not read REST entry file: {exc}")
except ValueError:
self.errors.append("REST entry must be in 'module:function' format")
self.errors.append(
"REST entry must be in 'module:function' format, or entrypoint must be specified"
)
return
else:
rest_module, rest_func = rest_entry.split(":", 1)

rest_file = self.project_dir / (rest_module.replace(".", "/") + ".py")
if not rest_file.exists():
self.errors.append(f"REST entry module not found: {rest_file}")
else:
try:
with open(rest_file, "r", encoding="utf-8") as f:
content = f.read()
if f"def {rest_func}" not in content:
self.warnings.append(
f"REST entry function '{rest_func}' not found in {rest_file}"
)
except Exception as exc:
self.warnings.append(f"Could not read REST entry file: {exc}")

# A2A
if manifest.a2a:
if manifest.a2a and getattr(manifest.a2a, "entry", None):
try:
a2a_module, a2a_func = manifest.a2a.service.split(":", 1)
a2a_module, a2a_func = manifest.a2a.entry.split(":", 1)
a2a_file = self.project_dir / (a2a_module.replace(".", "/") + ".py")
if not a2a_file.exists():
self.errors.append(f"A2A service module not found: {a2a_file}")
else:
try:
with open(a2a_file, "r") as f:
with open(a2a_file, "r", encoding="utf-8") as f:
content = f.read()
if f"def {a2a_func}" not in content:
self.warnings.append(
Expand Down
34 changes: 26 additions & 8 deletions pixell/models/agent_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,23 +45,41 @@ class AgentManifest(BaseModel):

# Surfaces (optional)
class A2AConfig(BaseModel):
service: str = Field(description="Module:function for A2A gRPC server entry")
# Prefer 'entry' for consistency with REST; keep 'service' for backwards compatibility
entry: Optional[str] = Field(
default=None,
description="Module:function for A2A gRPC server entry (optional)",
)
# Backwards compatible alias for manifests that still use `service`
service: Optional[str] = Field(
default=None,
description="DEPRECATED: use 'entry' instead",
alias="service",
)

@field_validator("service")
@field_validator("entry")
@classmethod
def validate_service(cls, v): # type: ignore[no-redef]
if ":" not in v:
raise ValueError("A2A service must be in format 'module:function'")
def validate_entry(cls, v): # type: ignore[no-redef]
# Allow omission; full path validation is handled in Validator/Builder
if v is not None and ":" not in v:
raise ValueError("A2A entry must be in format 'module:function'")
return v

@model_validator(mode="after")
def _populate_entry_from_service(self): # type: ignore[no-redef]
# If only legacy `service` is provided, mirror it into `entry`
if self.entry is None and self.service is not None:
self.entry = self.service
return self

class RestConfig(BaseModel):
entry: str = Field(description="Module:function that mounts REST routes on FastAPI app")
entry: str = Field(description="Module:function that mounts REST routes on FastAPI app, or just function name to use entrypoint's module")

@field_validator("entry")
@classmethod
def validate_entry(cls, v): # type: ignore[no-redef]
if ":" not in v:
raise ValueError("REST entry must be in format 'module:function'")
# Allow function name only (will use entrypoint's module in validator)
# Full validation happens in AgentValidator._validate_surfaces
return v

class UIConfig(BaseModel):
Expand Down
Loading
Loading