833 lines
27 KiB
Python
Executable File
833 lines
27 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Betty Framework - Plugin Publish Skill
|
|
Upload bundled plugin (.tar.gz) to local, gh-release, or marketplace targets.
|
|
Supports --dry-run and --validate-only modes.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import json
|
|
import yaml
|
|
import hashlib
|
|
import shutil
|
|
import logging
|
|
import subprocess
|
|
from pathlib import Path
|
|
from datetime import datetime, timezone
|
|
from typing import Dict, Any, Optional, Tuple
|
|
|
|
# Add betty module to path
|
|
BETTY_HOME = os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))
|
|
|
|
from betty.config import BASE_DIR, REGISTRY_DIR
|
|
from utils.telemetry_utils import capture_telemetry
|
|
|
|
# Configure logging
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(message)s",
|
|
handlers=[logging.StreamHandler(sys.stdout)]
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Global flags
|
|
DRY_RUN = False
|
|
VALIDATE_ONLY = False
|
|
|
|
|
|
def calculate_file_checksum(file_path: str) -> Dict[str, str]:
|
|
"""
|
|
Calculate checksums for a file.
|
|
|
|
Args:
|
|
file_path: Path to the file
|
|
|
|
Returns:
|
|
Dictionary with md5 and sha256 checksums
|
|
"""
|
|
logger.info("🔐 Calculating checksums...")
|
|
|
|
md5_hash = hashlib.md5()
|
|
sha256_hash = hashlib.sha256()
|
|
|
|
# Stream reading to handle large files efficiently
|
|
with open(file_path, "rb") as f:
|
|
for chunk in iter(lambda: f.read(4096), b""): # 4KB chunks
|
|
md5_hash.update(chunk)
|
|
sha256_hash.update(chunk)
|
|
|
|
checksums = {
|
|
"md5": md5_hash.hexdigest(),
|
|
"sha256": sha256_hash.hexdigest()
|
|
}
|
|
|
|
logger.info(f" MD5: {checksums['md5']}")
|
|
logger.info(f" SHA256: {checksums['sha256']}")
|
|
|
|
return checksums
|
|
|
|
|
|
def validate_checksum(
|
|
package_path: str,
|
|
expected_sha256: Optional[str] = None,
|
|
manifest_path: Optional[str] = None
|
|
) -> Tuple[bool, Dict[str, str]]:
|
|
"""
|
|
Validate the SHA256 checksum of a package file.
|
|
|
|
Args:
|
|
package_path: Path to the package file
|
|
expected_sha256: Expected SHA256 hash (optional)
|
|
manifest_path: Path to manifest.json containing checksums (optional)
|
|
|
|
Returns:
|
|
Tuple of (is_valid, checksums_dict)
|
|
"""
|
|
logger.info("🔍 Validating package checksums...")
|
|
|
|
# Calculate actual checksums
|
|
actual_checksums = calculate_file_checksum(package_path)
|
|
|
|
# Try to get expected checksum from manifest if not provided
|
|
if not expected_sha256 and manifest_path:
|
|
try:
|
|
with open(manifest_path, "r", encoding="utf-8") as f:
|
|
manifest = json.load(f)
|
|
expected_sha256 = manifest.get("package", {}).get("checksums", {}).get("sha256")
|
|
except (FileNotFoundError, json.JSONDecodeError, KeyError):
|
|
logger.warning(f"⚠️ Could not read checksums from manifest: {manifest_path}")
|
|
|
|
# Validate if expected checksum is available
|
|
if expected_sha256:
|
|
is_valid = actual_checksums["sha256"] == expected_sha256
|
|
if is_valid:
|
|
logger.info("✅ SHA256 checksum validation: PASSED")
|
|
else:
|
|
logger.error("❌ SHA256 checksum validation: FAILED")
|
|
logger.error(f" Expected: {expected_sha256}")
|
|
logger.error(f" Actual: {actual_checksums['sha256']}")
|
|
return is_valid, actual_checksums
|
|
else:
|
|
logger.warning("⚠️ No expected checksum provided - skipping validation")
|
|
return True, actual_checksums
|
|
|
|
|
|
def publish_local(
|
|
package_path: str,
|
|
checksums: Dict[str, str],
|
|
metadata: Dict[str, Any],
|
|
dry_run: bool = False
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Publish plugin to local dist/published/ directory.
|
|
|
|
Args:
|
|
package_path: Path to the package file
|
|
checksums: Checksums dictionary
|
|
metadata: Additional metadata
|
|
dry_run: If True, only show what would be done
|
|
|
|
Returns:
|
|
Result dictionary with publication details
|
|
"""
|
|
logger.info("📦 Publishing to local directory...")
|
|
|
|
if dry_run:
|
|
logger.info("🔍 DRY RUN MODE - No changes will be made")
|
|
|
|
# Create published directory
|
|
published_dir = os.path.join(BASE_DIR, "dist", "published")
|
|
|
|
if not dry_run:
|
|
os.makedirs(published_dir, exist_ok=True)
|
|
else:
|
|
logger.info(f" Would create directory: {published_dir}")
|
|
|
|
# Copy package to published directory
|
|
package_filename = os.path.basename(package_path)
|
|
dest_path = os.path.join(published_dir, package_filename)
|
|
|
|
logger.info(f" {'Would copy' if dry_run else 'Copying'}: {package_path}")
|
|
logger.info(f" To: {dest_path}")
|
|
|
|
if not dry_run:
|
|
shutil.copy2(package_path, dest_path)
|
|
|
|
# Create publication metadata file
|
|
pub_metadata = {
|
|
"published_at": datetime.now(timezone.utc).isoformat(),
|
|
"target": "local",
|
|
"package": {
|
|
"filename": package_filename,
|
|
"path": dest_path,
|
|
"size_bytes": os.path.getsize(package_path),
|
|
"checksums": checksums
|
|
},
|
|
"metadata": metadata,
|
|
"dry_run": dry_run
|
|
}
|
|
|
|
metadata_path = os.path.join(
|
|
published_dir,
|
|
f"{package_filename}.publish.json"
|
|
)
|
|
|
|
if not dry_run:
|
|
with open(metadata_path, "w", encoding="utf-8") as f:
|
|
json.dump(pub_metadata, f, indent=2)
|
|
else:
|
|
logger.info(f" Would write metadata to: {metadata_path}")
|
|
|
|
if dry_run:
|
|
logger.info("✅ Dry run completed successfully")
|
|
else:
|
|
logger.info("✅ Successfully published to local directory")
|
|
logger.info(f" Package: {dest_path}")
|
|
logger.info(f" Metadata: {metadata_path}")
|
|
|
|
return {
|
|
"ok": True,
|
|
"target": "local",
|
|
"package_path": dest_path,
|
|
"metadata_path": metadata_path,
|
|
"publication_metadata": pub_metadata,
|
|
"dry_run": dry_run
|
|
}
|
|
|
|
|
|
def publish_marketplace(
|
|
package_path: str,
|
|
checksums: Dict[str, str],
|
|
metadata: Dict[str, Any],
|
|
endpoint: str = "https://marketplace.claude.ai/api/v1/plugins",
|
|
dry_run: bool = False
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Publish plugin to Claude Marketplace endpoint.
|
|
Currently simulated - actual implementation would use requests library.
|
|
|
|
Args:
|
|
package_path: Path to the package file
|
|
checksums: Checksums dictionary
|
|
metadata: Additional metadata
|
|
endpoint: API endpoint URL
|
|
dry_run: If True, only show what would be done
|
|
|
|
Returns:
|
|
Result dictionary with publication details
|
|
"""
|
|
logger.info("🌐 Publishing to Claude Marketplace...")
|
|
logger.info(f" Endpoint: {endpoint}")
|
|
|
|
if dry_run:
|
|
logger.info("🔍 DRY RUN MODE - No actual requests will be made")
|
|
|
|
# Prepare API request
|
|
package_filename = os.path.basename(package_path)
|
|
package_size = os.path.getsize(package_path)
|
|
|
|
# Prepare JSON metadata payload for marketplace
|
|
marketplace_metadata = {
|
|
"plugin": {
|
|
"name": metadata.get("name", "unknown"),
|
|
"version": metadata.get("version", "unknown"),
|
|
"description": metadata.get("description", ""),
|
|
"author": metadata.get("author", {}),
|
|
"license": metadata.get("license", ""),
|
|
"homepage": metadata.get("homepage", ""),
|
|
"repository": metadata.get("repository", ""),
|
|
"tags": metadata.get("tags", []),
|
|
"betty_version": metadata.get("betty_version", ">=0.1.0")
|
|
},
|
|
"package": {
|
|
"filename": package_filename,
|
|
"size_bytes": package_size,
|
|
"checksums": {
|
|
"md5": checksums["md5"],
|
|
"sha256": checksums["sha256"]
|
|
}
|
|
},
|
|
"submitted_at": datetime.now(timezone.utc).isoformat()
|
|
}
|
|
|
|
request_payload = {
|
|
"method": "POST",
|
|
"url": endpoint,
|
|
"headers": {
|
|
"Content-Type": "application/json",
|
|
"X-Plugin-Name": metadata.get("name", "unknown"),
|
|
"X-Plugin-Version": metadata.get("version", "unknown"),
|
|
"X-Package-SHA256": checksums["sha256"]
|
|
},
|
|
"json": marketplace_metadata
|
|
}
|
|
|
|
# Simulate successful response
|
|
simulated_response = {
|
|
"status": 200,
|
|
"body": {
|
|
"success": True,
|
|
"message": "Plugin published successfully",
|
|
"plugin": {
|
|
"id": f"{metadata.get('name')}-{metadata.get('version')}",
|
|
"name": metadata.get("name"),
|
|
"version": metadata.get("version"),
|
|
"published_at": datetime.now(timezone.utc).isoformat(),
|
|
"download_url": f"{endpoint.rstrip('/plugins')}/download/{metadata.get('name')}/{metadata.get('version')}/{package_filename}",
|
|
"listing_url": f"https://marketplace.claude.ai/plugins/{metadata.get('name')}"
|
|
},
|
|
"checksums": checksums
|
|
}
|
|
}
|
|
|
|
# Save simulation log
|
|
sim_log_dir = os.path.join(BASE_DIR, "dist", "published", "marketplace")
|
|
|
|
if not dry_run:
|
|
os.makedirs(sim_log_dir, exist_ok=True)
|
|
else:
|
|
logger.info(f" Would create directory: {sim_log_dir}")
|
|
|
|
sim_log_path = os.path.join(
|
|
sim_log_dir,
|
|
f"{package_filename}.marketplace-publish.json"
|
|
)
|
|
|
|
simulation_log = {
|
|
"simulated_at": datetime.now(timezone.utc).isoformat(),
|
|
"target": "marketplace",
|
|
"endpoint": endpoint,
|
|
"request": request_payload,
|
|
"response": simulated_response,
|
|
"dry_run": dry_run,
|
|
"note": "This is a simulated request. No actual HTTP request was made. To enable real publication, add requests library implementation."
|
|
}
|
|
|
|
if not dry_run:
|
|
with open(sim_log_path, "w", encoding="utf-8") as f:
|
|
json.dump(simulation_log, f, indent=2)
|
|
logger.info("✅ Marketplace publication simulated successfully")
|
|
logger.info(f" Simulation log: {sim_log_path}")
|
|
else:
|
|
logger.info("✅ Dry run completed successfully")
|
|
logger.info(f" Would write simulation log to: {sim_log_path}")
|
|
|
|
logger.warning("⚠️ NOTE: This is a SIMULATION. No actual HTTP request was made.")
|
|
logger.info(f" Would POST JSON metadata to: {endpoint}")
|
|
logger.info(f" Package size: {package_size:,} bytes")
|
|
|
|
return {
|
|
"ok": True,
|
|
"target": "marketplace",
|
|
"simulated": True,
|
|
"endpoint": endpoint,
|
|
"simulation_log": sim_log_path if not dry_run else None,
|
|
"simulated_response": simulated_response["body"],
|
|
"dry_run": dry_run
|
|
}
|
|
|
|
|
|
def check_gh_cli_available() -> bool:
|
|
"""
|
|
Check if GitHub CLI (gh) is available.
|
|
|
|
Returns:
|
|
True if gh CLI is available, False otherwise
|
|
"""
|
|
try:
|
|
result = subprocess.run(
|
|
["gh", "--version"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5
|
|
)
|
|
return result.returncode == 0
|
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
return False
|
|
|
|
|
|
def publish_gh_release(
|
|
package_path: str,
|
|
checksums: Dict[str, str],
|
|
metadata: Dict[str, Any],
|
|
dry_run: bool = False,
|
|
auto_create: bool = False
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Publish plugin to GitHub Release.
|
|
Can either prepare release files or automatically create the release using gh CLI.
|
|
|
|
Args:
|
|
package_path: Path to the package file
|
|
checksums: Checksums dictionary
|
|
metadata: Additional metadata
|
|
dry_run: If True, only show what would be done
|
|
auto_create: If True, automatically create GitHub Release using gh CLI
|
|
|
|
Returns:
|
|
Result dictionary with release details
|
|
"""
|
|
logger.info("🚀 Publishing to GitHub Release...")
|
|
|
|
if dry_run:
|
|
logger.info("🔍 DRY RUN MODE - No changes will be made")
|
|
|
|
# Create releases directory
|
|
releases_dir = os.path.join(BASE_DIR, "dist", "published", "releases")
|
|
|
|
if not dry_run:
|
|
os.makedirs(releases_dir, exist_ok=True)
|
|
else:
|
|
logger.info(f" Would create directory: {releases_dir}")
|
|
|
|
package_filename = os.path.basename(package_path)
|
|
version = metadata.get("version", "unknown")
|
|
name = metadata.get("name", "unknown")
|
|
|
|
# Copy package to releases directory
|
|
dest_path = os.path.join(releases_dir, package_filename)
|
|
|
|
logger.info(f" {'Would copy' if dry_run else 'Copying'}: {package_path}")
|
|
logger.info(f" To: {dest_path}")
|
|
|
|
if not dry_run:
|
|
shutil.copy2(package_path, dest_path)
|
|
|
|
# Generate release notes
|
|
release_notes = f"""# {name} v{version}
|
|
|
|
## Release Information
|
|
|
|
- **Version:** {version}
|
|
- **Released:** {datetime.now(timezone.utc).strftime('%Y-%m-%d')}
|
|
- **Package:** `{package_filename}`
|
|
|
|
## Checksums
|
|
|
|
Verify the integrity of your download:
|
|
|
|
```
|
|
MD5: {checksums['md5']}
|
|
SHA256: {checksums['sha256']}
|
|
```
|
|
|
|
## Installation
|
|
|
|
1. Download the package: `{package_filename}`
|
|
2. Verify checksums (see above)
|
|
3. Extract: `tar -xzf {package_filename}`
|
|
4. Install dependencies: `pip install -r requirements.txt`
|
|
5. Run: Follow instructions in README.md
|
|
|
|
## Description
|
|
|
|
{metadata.get('description', 'No description available.')}
|
|
|
|
---
|
|
*Generated by Betty Framework plugin.publish skill*
|
|
"""
|
|
|
|
release_notes_path = os.path.join(releases_dir, f"RELEASE_NOTES_v{version}.md")
|
|
|
|
if not dry_run:
|
|
with open(release_notes_path, "w", encoding="utf-8") as f:
|
|
f.write(release_notes)
|
|
else:
|
|
logger.info(f" Would write release notes to: {release_notes_path}")
|
|
|
|
# Create release metadata
|
|
release_metadata = {
|
|
"prepared_at": datetime.now(timezone.utc).isoformat(),
|
|
"target": "gh-release",
|
|
"version": version,
|
|
"name": name,
|
|
"package": {
|
|
"filename": package_filename,
|
|
"path": dest_path,
|
|
"size_bytes": os.path.getsize(package_path),
|
|
"checksums": checksums
|
|
},
|
|
"release_notes_path": release_notes_path,
|
|
"github_cli_command": f"gh release create v{version} --title \"{name} v{version}\" --notes-file {release_notes_path} {dest_path}",
|
|
"metadata": metadata,
|
|
"dry_run": dry_run,
|
|
"auto_created": False
|
|
}
|
|
|
|
metadata_path = os.path.join(
|
|
releases_dir,
|
|
f"{package_filename}.release.json"
|
|
)
|
|
|
|
if not dry_run:
|
|
with open(metadata_path, "w", encoding="utf-8") as f:
|
|
json.dump(release_metadata, f, indent=2)
|
|
else:
|
|
logger.info(f" Would write metadata to: {metadata_path}")
|
|
|
|
# Attempt to create GitHub Release if auto_create is True
|
|
gh_release_created = False
|
|
gh_release_url = None
|
|
|
|
if auto_create and not dry_run:
|
|
logger.info("")
|
|
logger.info("🤖 Attempting to create GitHub Release automatically...")
|
|
|
|
# Check if gh CLI is available
|
|
if not check_gh_cli_available():
|
|
logger.error("❌ GitHub CLI (gh) not available")
|
|
logger.info(" Install: https://cli.github.com/")
|
|
logger.info(" Falling back to manual instructions")
|
|
else:
|
|
try:
|
|
# Create GitHub Release using gh CLI
|
|
gh_command = [
|
|
"gh", "release", "create", f"v{version}",
|
|
"--title", f"{name} v{version}",
|
|
"--notes-file", release_notes_path,
|
|
dest_path
|
|
]
|
|
|
|
logger.info(f" Running: {' '.join(gh_command)}")
|
|
|
|
result = subprocess.run(
|
|
gh_command,
|
|
cwd=BASE_DIR,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=60
|
|
)
|
|
|
|
if result.returncode == 0:
|
|
gh_release_created = True
|
|
# Extract release URL from output
|
|
gh_release_url = result.stdout.strip()
|
|
release_metadata["auto_created"] = True
|
|
release_metadata["github_release_url"] = gh_release_url
|
|
|
|
# Update metadata file with release URL
|
|
with open(metadata_path, "w", encoding="utf-8") as f:
|
|
json.dump(release_metadata, f, indent=2)
|
|
|
|
logger.info("✅ GitHub Release created successfully!")
|
|
logger.info(f" Release URL: {gh_release_url}")
|
|
else:
|
|
logger.error("❌ Failed to create GitHub Release")
|
|
logger.error(f" Error: {result.stderr}")
|
|
logger.info(" Falling back to manual instructions")
|
|
|
|
except subprocess.TimeoutExpired:
|
|
logger.error("❌ GitHub Release creation timed out")
|
|
logger.info(" Falling back to manual instructions")
|
|
except Exception as e:
|
|
logger.error(f"❌ Error creating GitHub Release: {e}")
|
|
logger.info(" Falling back to manual instructions")
|
|
|
|
elif auto_create and dry_run:
|
|
logger.info("")
|
|
logger.info("🤖 Would attempt to create GitHub Release automatically")
|
|
logger.info(f" Would run: gh release create v{version} --title \"{name} v{version}\" --notes-file {release_notes_path} {dest_path}")
|
|
|
|
# Show summary
|
|
if dry_run:
|
|
logger.info("✅ Dry run completed successfully")
|
|
elif gh_release_created:
|
|
logger.info("✅ GitHub Release published successfully")
|
|
logger.info(f" Package: {dest_path}")
|
|
logger.info(f" Release notes: {release_notes_path}")
|
|
logger.info(f" Metadata: {metadata_path}")
|
|
logger.info(f" Release URL: {gh_release_url}")
|
|
else:
|
|
logger.info("✅ GitHub Release prepared successfully")
|
|
logger.info(f" Package: {dest_path}")
|
|
logger.info(f" Release notes: {release_notes_path}")
|
|
logger.info(f" Metadata: {metadata_path}")
|
|
logger.info("")
|
|
logger.info("📋 Next steps:")
|
|
logger.info(f" 1. Review release notes: {release_notes_path}")
|
|
logger.info(f" 2. Create GitHub release:")
|
|
logger.info(f" gh release create v{version} --title \"{name} v{version}\" \\")
|
|
logger.info(f" --notes-file {release_notes_path} {dest_path}")
|
|
|
|
return {
|
|
"ok": True,
|
|
"target": "gh-release",
|
|
"package_path": dest_path,
|
|
"release_notes_path": release_notes_path,
|
|
"metadata_path": metadata_path,
|
|
"github_cli_command": release_metadata["github_cli_command"],
|
|
"release_metadata": release_metadata,
|
|
"github_release_created": gh_release_created,
|
|
"github_release_url": gh_release_url,
|
|
"dry_run": dry_run
|
|
}
|
|
|
|
|
|
def load_manifest(manifest_path: str) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Load manifest.json to extract metadata.
|
|
|
|
Args:
|
|
manifest_path: Path to manifest.json
|
|
|
|
Returns:
|
|
Manifest dictionary or None
|
|
"""
|
|
if not os.path.exists(manifest_path):
|
|
logger.warning(f"⚠️ Manifest not found: {manifest_path}")
|
|
return None
|
|
|
|
try:
|
|
with open(manifest_path, "r", encoding="utf-8") as f:
|
|
return json.load(f)
|
|
except json.JSONDecodeError as e:
|
|
logger.error(f"❌ Error parsing manifest: {e}")
|
|
return None
|
|
|
|
|
|
def publish_plugin(
|
|
package_path: str,
|
|
target: str = "local",
|
|
expected_sha256: Optional[str] = None,
|
|
manifest_path: Optional[str] = None,
|
|
marketplace_endpoint: Optional[str] = None,
|
|
dry_run: bool = False,
|
|
validate_only: bool = False,
|
|
auto_create_release: bool = False
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Publish a bundled plugin package.
|
|
|
|
Args:
|
|
package_path: Path to the .tar.gz package file
|
|
target: Publication target - 'local', 'marketplace', or 'gh-release'
|
|
expected_sha256: Expected SHA256 checksum for validation (optional)
|
|
manifest_path: Path to manifest.json (optional, auto-detected)
|
|
marketplace_endpoint: Marketplace API endpoint for 'marketplace' target (optional)
|
|
dry_run: If True, only show what would be done without making changes
|
|
validate_only: If True, only validate checksums and exit
|
|
auto_create_release: If True and target is 'gh-release', automatically create the release
|
|
|
|
Returns:
|
|
Dictionary with publication results
|
|
"""
|
|
logger.info("=" * 80)
|
|
logger.info("🚀 BETTY PLUGIN PUBLISH")
|
|
logger.info("=" * 80)
|
|
logger.info("")
|
|
|
|
if dry_run:
|
|
logger.info("🔍 DRY RUN MODE ENABLED")
|
|
logger.info("")
|
|
|
|
if validate_only:
|
|
logger.info("🔍 VALIDATE ONLY MODE ENABLED")
|
|
logger.info("")
|
|
|
|
# Validate inputs
|
|
if not os.path.exists(package_path):
|
|
error_msg = f"Package file not found: {package_path}"
|
|
logger.error(f"❌ {error_msg}")
|
|
return {
|
|
"ok": False,
|
|
"error": error_msg
|
|
}
|
|
|
|
valid_targets = ["local", "marketplace", "gh-release"]
|
|
if target not in valid_targets:
|
|
error_msg = f"Invalid target: {target}. Must be one of: {', '.join(valid_targets)}"
|
|
logger.error(f"❌ {error_msg}")
|
|
return {
|
|
"ok": False,
|
|
"error": error_msg
|
|
}
|
|
|
|
# Auto-detect manifest path if not provided
|
|
if not manifest_path:
|
|
# Try common locations
|
|
package_dir = os.path.dirname(package_path)
|
|
possible_manifests = [
|
|
os.path.join(package_dir, "manifest.json"),
|
|
os.path.join(BASE_DIR, "dist", "manifest.json")
|
|
]
|
|
for possible_path in possible_manifests:
|
|
if os.path.exists(possible_path):
|
|
manifest_path = possible_path
|
|
break
|
|
|
|
logger.info(f"📦 Package: {package_path}")
|
|
logger.info(f"🎯 Target: {target}")
|
|
if manifest_path:
|
|
logger.info(f"📄 Manifest: {manifest_path}")
|
|
logger.info("")
|
|
|
|
# Validate checksum
|
|
is_valid, checksums = validate_checksum(
|
|
package_path,
|
|
expected_sha256=expected_sha256,
|
|
manifest_path=manifest_path
|
|
)
|
|
|
|
if not is_valid:
|
|
return {
|
|
"ok": False,
|
|
"error": "SHA256 checksum validation failed",
|
|
"checksums": checksums
|
|
}
|
|
|
|
logger.info("")
|
|
|
|
# If validate_only mode, stop here
|
|
if validate_only:
|
|
logger.info("=" * 80)
|
|
logger.info("✅ VALIDATION SUCCESSFUL")
|
|
logger.info("=" * 80)
|
|
logger.info("")
|
|
logger.info("Package is valid and ready for publication.")
|
|
logger.info(f"To publish, run without --validate-only flag:")
|
|
logger.info(f" python skills/plugin.publish/plugin_publish.py {package_path} --target={target}")
|
|
return {
|
|
"ok": True,
|
|
"validated": True,
|
|
"checksums": checksums,
|
|
"package_path": package_path
|
|
}
|
|
|
|
# Load metadata from manifest
|
|
metadata = {}
|
|
if manifest_path:
|
|
manifest = load_manifest(manifest_path)
|
|
if manifest:
|
|
metadata = {
|
|
"name": manifest.get("name"),
|
|
"version": manifest.get("version"),
|
|
"description": manifest.get("description"),
|
|
"author": manifest.get("author"),
|
|
"license": manifest.get("license"),
|
|
"homepage": manifest.get("homepage"),
|
|
"repository": manifest.get("repository"),
|
|
"tags": manifest.get("tags", []),
|
|
"betty_version": manifest.get("betty_version")
|
|
}
|
|
|
|
# Publish based on target
|
|
if target == "local":
|
|
result = publish_local(package_path, checksums, metadata, dry_run=dry_run)
|
|
elif target == "marketplace":
|
|
endpoint = marketplace_endpoint or "https://marketplace.claude.ai/api/v1/plugins"
|
|
result = publish_marketplace(package_path, checksums, metadata, endpoint, dry_run=dry_run)
|
|
elif target == "gh-release":
|
|
result = publish_gh_release(package_path, checksums, metadata, dry_run=dry_run, auto_create=auto_create_release)
|
|
|
|
logger.info("")
|
|
logger.info("=" * 80)
|
|
if result.get("ok"):
|
|
if dry_run:
|
|
logger.info("✅ DRY RUN COMPLETED SUCCESSFULLY")
|
|
else:
|
|
logger.info("✅ PUBLICATION SUCCESSFUL")
|
|
else:
|
|
logger.info("❌ PUBLICATION FAILED")
|
|
logger.info("=" * 80)
|
|
|
|
return result
|
|
|
|
|
|
@capture_telemetry(skill_name="plugin.publish", caller="cli")
|
|
def main():
|
|
"""CLI entry point."""
|
|
import argparse
|
|
|
|
parser = argparse.ArgumentParser(
|
|
description="Publish a bundled Betty plugin package to various targets",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog="""
|
|
Examples:
|
|
# Publish to local directory
|
|
%(prog)s dist/betty-1.0.0.tar.gz --target=local
|
|
|
|
# Publish to Claude Marketplace (simulated)
|
|
%(prog)s dist/betty-1.0.0.tar.gz --target=marketplace
|
|
|
|
# Prepare GitHub Release
|
|
%(prog)s dist/betty-1.0.0.tar.gz --target=gh-release
|
|
|
|
# Create GitHub Release automatically
|
|
%(prog)s dist/betty-1.0.0.tar.gz --target=gh-release --auto-create
|
|
|
|
# Dry run (show what would happen)
|
|
%(prog)s dist/betty-1.0.0.tar.gz --target=marketplace --dry-run
|
|
|
|
# Validate only (check checksums without publishing)
|
|
%(prog)s dist/betty-1.0.0.tar.gz --validate-only
|
|
"""
|
|
)
|
|
parser.add_argument(
|
|
"package_path",
|
|
help="Path to the .tar.gz package file"
|
|
)
|
|
parser.add_argument(
|
|
"--target",
|
|
choices=["local", "marketplace", "gh-release"],
|
|
default="local",
|
|
help="Publication target: local (dist/published/), marketplace (Claude Marketplace API), or gh-release (GitHub Release) (default: local)"
|
|
)
|
|
parser.add_argument(
|
|
"--sha256",
|
|
help="Expected SHA256 checksum for validation (auto-detected from manifest.json if not provided)"
|
|
)
|
|
parser.add_argument(
|
|
"--manifest",
|
|
help="Path to manifest.json (auto-detected if not provided)"
|
|
)
|
|
parser.add_argument(
|
|
"--endpoint",
|
|
help="Marketplace API endpoint for 'marketplace' target (default: https://marketplace.claude.ai/api/v1/plugins)"
|
|
)
|
|
parser.add_argument(
|
|
"--dry-run",
|
|
action="store_true",
|
|
help="Show what would be done without making any changes"
|
|
)
|
|
parser.add_argument(
|
|
"--validate-only",
|
|
action="store_true",
|
|
help="Only validate package checksums without publishing"
|
|
)
|
|
parser.add_argument(
|
|
"--auto-create",
|
|
action="store_true",
|
|
help="For gh-release target: automatically create GitHub Release using gh CLI (requires gh CLI installed)"
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
# Validate argument combinations
|
|
if args.validate_only and args.dry_run:
|
|
logger.warning("⚠️ Both --validate-only and --dry-run specified. Using --validate-only.")
|
|
logger.warning(" (--dry-run is ignored when --validate-only is set)")
|
|
logger.info("")
|
|
|
|
if args.auto_create and args.target != "gh-release":
|
|
logger.warning(f"⚠️ --auto-create only applies to gh-release target (current target: {args.target})")
|
|
logger.info("")
|
|
|
|
result = publish_plugin(
|
|
package_path=args.package_path,
|
|
target=args.target,
|
|
expected_sha256=args.sha256,
|
|
manifest_path=args.manifest,
|
|
marketplace_endpoint=args.endpoint,
|
|
dry_run=args.dry_run,
|
|
validate_only=args.validate_only,
|
|
auto_create_release=args.auto_create
|
|
)
|
|
|
|
# Exit with appropriate code
|
|
sys.exit(0 if result.get("ok") else 1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|