From d2b7a5927d3e326ab44bed69937e70ddc0eec888 Mon Sep 17 00:00:00 2001 From: mike <571232+mgwilt@users.noreply.github.com> Date: Tue, 30 Sep 2025 08:56:50 -0700 Subject: [PATCH 1/2] Refactor CLI structure and enhance command registration Updated the CLI implementation by consolidating command registrations for improved organization and maintainability. Introduced new command definitions for creating and validating packages, along with enhancements to the create command for .prmd files. Streamlined imports for faster startup and improved error handling across commands. This refactor aims to enhance user experience and command accessibility within the Prompd CLI. --- AGENTS.md | 34 + cli/python/prompd/cli.py | 2869 +------------------- cli/python/prompd/commands/cache.py | 81 + cli/python/prompd/commands/chat.py | 26 + cli/python/prompd/commands/common.py | 17 + cli/python/prompd/commands/compile.py | 185 ++ cli/python/prompd/commands/create.py | 49 +- cli/python/prompd/commands/deps.py | 78 + cli/python/prompd/commands/deps_install.py | 67 + cli/python/prompd/commands/deps_update.py | 70 + cli/python/prompd/commands/git.py | 215 ++ cli/python/prompd/commands/init.py | 90 + cli/python/prompd/commands/install.py | 197 ++ cli/python/prompd/commands/list_prompts.py | 82 + cli/python/prompd/commands/login.py | 42 + cli/python/prompd/commands/logout.py | 27 + cli/python/prompd/commands/mcp.py | 117 + cli/python/prompd/commands/namespace.py | 250 ++ cli/python/prompd/commands/pack.py | 38 + cli/python/prompd/commands/package.py | 292 +- cli/python/prompd/commands/publish.py | 80 + cli/python/prompd/commands/run.py | 288 ++ cli/python/prompd/commands/search.py | 58 + cli/python/prompd/commands/shell_cmd.py | 30 + cli/python/prompd/commands/show.py | 118 + cli/python/prompd/commands/uninstall.py | 56 + cli/python/prompd/commands/validate.py | 113 + cli/python/prompd/commands/version_cmds.py | 296 ++ cli/python/prompd/commands/versions.py | 47 + cli/python/prompd/console.py | 23 + 30 files changed, 2974 insertions(+), 2961 deletions(-) create mode 100644 AGENTS.md create mode 100644 cli/python/prompd/commands/cache.py create mode 100644 cli/python/prompd/commands/chat.py create mode 100644 cli/python/prompd/commands/common.py create mode 100644 cli/python/prompd/commands/compile.py create mode 100644 cli/python/prompd/commands/deps.py create mode 100644 cli/python/prompd/commands/deps_install.py create mode 100644 cli/python/prompd/commands/deps_update.py create mode 100644 cli/python/prompd/commands/git.py create mode 100644 cli/python/prompd/commands/init.py create mode 100644 cli/python/prompd/commands/install.py create mode 100644 cli/python/prompd/commands/list_prompts.py create mode 100644 cli/python/prompd/commands/login.py create mode 100644 cli/python/prompd/commands/logout.py create mode 100644 cli/python/prompd/commands/mcp.py create mode 100644 cli/python/prompd/commands/namespace.py create mode 100644 cli/python/prompd/commands/pack.py create mode 100644 cli/python/prompd/commands/publish.py create mode 100644 cli/python/prompd/commands/run.py create mode 100644 cli/python/prompd/commands/search.py create mode 100644 cli/python/prompd/commands/shell_cmd.py create mode 100644 cli/python/prompd/commands/show.py create mode 100644 cli/python/prompd/commands/uninstall.py create mode 100644 cli/python/prompd/commands/validate.py create mode 100644 cli/python/prompd/commands/version_cmds.py create mode 100644 cli/python/prompd/commands/versions.py create mode 100644 cli/python/prompd/console.py diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e9f3f1f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,34 @@ +# Repository Guidelines + +## Project Structure & Module Organization +- `cli/go/`: Go CLI sources in `cmd/prompd`; keep `_test.go` beside implementations. +- `cli/python/`: Installable Python package with `.pdpkg` fixtures and pytest suites in `cli/python/tests`. +- `cli/npm/`: TypeScript CLI in `src/`, compiled to `dist/`, executable shim under `bin/`. +- `examples/` + root `.prmd` bundles: canonical prompt references for integration and packaging checks. +- `vscode-extension/`: VS Code tooling aligned with prompt bundles; update alongside CLI changes. + +## Build, Test, and Development Commands +- `cd cli/go && go build -o prompd ./cmd/prompd`: build Go binary for local/manual testing. +- `./build.sh`: emit cross-platform artifacts to `dist/`. +- `pip install -e "cli/python/[dev]"`: install editable Python CLI with dev tooling. +- `python -m pytest cli/python/tests`: run Python suite; append `--cov=prompd` after core edits. +- `cd cli/npm && npm install && npm run build`: compile Node CLI; follow with `npm test` for Jest specs. + +## Coding Style & Naming Conventions +- Python: 4-space indent, run `black --line-length 100` and `ruff check`; modules stay snake_case. +- Go: format with `gofmt`, keep package names concise, commands under `cmd/prompd`. +- TypeScript: camelCase functions, PascalCase classes, document exports; keep `dist/` lint-clean. +- Prompt bundles: kebab-case filenames ending in `.prmd` or `.pdpkg`. + +## Testing Guidelines +- Co-locate tests (`test_*.py`, `_test.go`, `*.test.ts`) with targets and fixtures. +- `python cli/python/run_tests.py`: smoke validates CLI interactions. +- `go test ./...` plus `go vet ./...`: mandatory before shipping parser/registry changes. +- `npm test -- --coverage`: capture metrics after TypeScript updates. +- `python test_all_production.py` and `validate_production_prompts.py`: required before packaging prompts. + +## Commit & Pull Request Guidelines +- Write imperative, scoped commit subjects (e.g., “Add prompt validation smoke tests”). +- In PRs, spell out affected CLIs, manual test commands, linked issues, and UX diffs. +- Update docs, examples, or bundles with code changes; call out new binaries or scripts. +- Verify all relevant build/test commands locally and flag follow-up work when needed. diff --git a/cli/python/prompd/cli.py b/cli/python/prompd/cli.py index 461c8bd..b693fa6 100644 --- a/cli/python/prompd/cli.py +++ b/cli/python/prompd/cli.py @@ -1,2820 +1,79 @@ """Command-line interface for Prompd.""" - -import sys -import subprocess -from pathlib import Path -from typing import Optional, Dict, Any, List +from __future__ import annotations import click -from rich.console import Console -from rich.table import Table -from rich.syntax import Syntax -from rich.panel import Panel -# Lazy imports - moved to function level for faster startup -# Heavy modules like executor, registry, compiler are imported only when needed from prompd import __version__ as PROMPD_VERSION -from prompd.exceptions import PrompdError -# Configure console with proper encoding handling for Windows -import platform -try: - if platform.system() == "Windows": - # Force UTF-8 encoding on Windows to handle all characters properly - console = Console(file=sys.stdout, legacy_windows=True, width=120, force_terminal=True) - else: - console = Console(file=sys.stdout, force_terminal=True, width=120) -except: - # Fallback to basic console if Rich fails - console = Console(file=sys.stdout, legacy_windows=True, width=120) +from prompd.commands.cache import cache +from prompd.commands.chat import chat_command +from prompd.commands.compile import compile_command +from prompd.commands.config import config +from prompd.commands.create import create_command +from prompd.commands.deps import dependencies +from prompd.commands.deps_install import install_dependencies +from prompd.commands.deps_update import update_dependencies +from prompd.commands.git import git +from prompd.commands.init import init +from prompd.commands.install import install +from prompd.commands.list_prompts import list_prompts +from prompd.commands.login import login +from prompd.commands.logout import logout +from prompd.commands.mcp import mcp +from prompd.commands.namespace import namespace, ns +from prompd.commands.package import package +from prompd.commands.pack import pack_alias +from prompd.commands.publish import publish +from prompd.commands.run import run +from prompd.commands.search import search +from prompd.commands.shell_cmd import shell_command +from prompd.commands.show import show +from prompd.commands.uninstall import uninstall +from prompd.commands.validate import validate +from prompd.commands.version_cmds import version +from prompd.commands.versions import versions +from prompd.commands.registry import registry @click.group() -@click.version_option(version="0.4.0", prog_name="prompd") +@click.version_option(version=PROMPD_VERSION, prog_name="prompd") def cli(): """Prompd - CLI for structured prompt definitions.""" pass -# Register config command with registry and provider subcommands -from prompd.commands.config import config +# Register command groups cli.add_command(config) - - -def _run_impl(ctx, file: Path, provider: Optional[str], model: Optional[str], param: tuple, param_file: tuple, - api_key: Optional[str], output: Optional[str], format: str, version: Optional[str], verbose: bool, show_usage: bool): - import asyncio - import tempfile - - try: - # Handle version checkout if specified - actual_file = file - temp_file = None - - if version: - # Create a temporary file with the specified version - with tempfile.NamedTemporaryFile(mode='w', suffix='.prmd', delete=False, encoding='utf-8') as tmp: - temp_file = Path(tmp.name) - - # Get the file content at that version - if _is_valid_semver(version): - tag_name = f"{file.stem}-v{version}" - # Check if tag exists - tag_check = subprocess.run( - ["git", "tag", "-l", tag_name], - capture_output=True, - text=True - ) - version_ref = tag_name if tag_check.stdout.strip() else version - else: - version_ref = version - - # Convert Windows paths to forward slashes for git - git_path = str(file).replace('\\', '/') - result = subprocess.run( - ["git", "show", f"{version_ref}:{git_path}"], - capture_output=True, - text=True, - check=True - ) - - tmp.write(result.stdout) - actual_file = temp_file - - if verbose: - console.print(f"[dim]Using version {version} of {file}[/dim]") - - # Parse meta alias flags of form --meta:{section} - # Any section name is accepted. We'll pass through as 'meta:{section}' for executor handling. - metadata_overrides: Dict[str, str] = {} - try: - extra_args = list(ctx.args) if hasattr(ctx, 'args') else [] - i = 0 - while i < len(extra_args): - token = extra_args[i] - if isinstance(token, str) and token.startswith("--meta:"): - section = token.split(":", 1)[1] - # Grab the next arg as the value if present - if i + 1 < len(extra_args): - val = extra_args[i+1] - # Pass through as meta:{section} for executor to process dynamically - metadata_overrides[f"meta:{section}"] = str(val) - i += 2 - continue - i += 1 - except Exception: - # Best-effort; ignore parsing errors - pass - - # Create executor - from prompd.executor import PrompdExecutor - from prompd.config import PrompdConfig - executor = PrompdExecutor() - - # Resolve defaults for provider/model when omitted - try: - cfg = PrompdConfig.load() - # Provider defaulting - if not provider: - provider = cfg.default_provider - if not provider: - # Pick first provider with an API key - for cand in ['openai', 'anthropic', 'ollama']: - if cand == 'ollama': - provider = cand - break - if cfg.get_api_key(cand): - provider = cand - break - if verbose and provider: - console.print(f"[dim]Using default provider: {provider}[/dim]") - # Model defaulting - if not model: - model = cfg.default_model - if not model and provider: - # Provider-specific sensible defaults - if provider == 'openai': - model = 'gpt-4o' - elif provider == 'anthropic': - model = 'claude-3-haiku-20240307' - elif provider == 'ollama': - model = 'llama2' - if verbose and model: - console.print(f"[dim]Using default model: {model}[/dim]") - except Exception: - pass - - # Convert parameters - cli_params = list(param) if param else None - param_files = [Path(p) for p in param_file] if param_file else None - - # Execute - response = asyncio.run(executor.execute( - prompd_file=actual_file, - provider=provider, - model=model, - cli_params=cli_params, - param_files=param_files, - api_key=api_key, - metadata_overrides=metadata_overrides if metadata_overrides else None - )) - - # Clean up temp file if created - if temp_file and temp_file.exists(): - temp_file.unlink() - - # Output result based on format - if format == "json": - import json - result = { - "response": response.content, - "provider": provider, - "model": model, - "file": str(file) - } - if response.usage: - result["usage"] = response.usage - - json_output = json.dumps(result, indent=2, ensure_ascii=False) - - if output: - with open(output, "w", encoding="utf-8") as f: - f.write(json_output) - try: - console.print(f"[green]OK[/green] JSON response written to {output}") - except UnicodeEncodeError: - print(f"OK - JSON response written to {output}") - else: - print(json_output) - else: - # Text format (default) - if output: - with open(output, "w", encoding="utf-8") as f: - f.write(response.content) - try: - console.print(f"[green]OK[/green] Response written to {output}") - except UnicodeEncodeError: - print(f"OK - Response written to {output}") - else: - try: - console.print(Panel( - response.content, - title=f"Response from {provider}/{model}", - border_style="green" - )) - except UnicodeEncodeError: - # Fallback for Windows console encoding issues - print(f"\n--- Response from {provider}/{model} ---") - print(response.content) - print("-" * 50) - - if (verbose or show_usage) and response.usage: - try: - console.print(f"\n[dim]Usage: {response.usage}[/dim]") - except UnicodeEncodeError: - print(f"\nUsage: {response.usage}") - - except ConfigurationError as e: - try: - console.print(f"[red]Configuration Error:[/red] {e}") - except UnicodeEncodeError: - print(f"Configuration Error: {e}") - sys.exit(1) - except ProviderError as e: - try: - console.print(f"[red]Provider Error:[/red] {e}") - except UnicodeEncodeError: - print(f"Provider Error: {e}") - sys.exit(1) - except PrompdError as e: - try: - console.print(f"[red]Error:[/red] {e}") - except UnicodeEncodeError: - print(f"Error: {e}") - sys.exit(1) - except Exception as e: - try: - console.print(f"[red]Unexpected error:[/red] {e}") - if verbose: - import traceback - console.print(traceback.format_exc()) - except UnicodeEncodeError: - print(f"Unexpected error: {e}") - if verbose: - import traceback - print(traceback.format_exc()) - sys.exit(1) - - - - - - -@cli.command(name="run", context_settings=dict(ignore_unknown_options=True)) -@click.argument("file", type=click.Path(exists=True, path_type=Path)) -@click.option("--provider", required=False, help="LLM provider (openai, anthropic, ollama). Defaults from config if omitted") -@click.option("--model", required=False, help="Model name. Defaults from config/provider if omitted") -@click.option("--param", "-p", multiple=True, help="Parameter in format key=value") -@click.option("--param-file", "-f", type=click.Path(exists=True, path_type=Path), - multiple=True, help="JSON parameter file") -@click.option("--api-key", help="API key override") -@click.option("--output", "-o", type=click.Path(), help="Output file path") -@click.option("--format", type=click.Choice(["text", "json"]), default="text", help="Output format") -@click.option("--version", help="Execute a specific version (e.g., '1.2.3', 'HEAD', commit hash)") -@click.option("--verbose", "-v", is_flag=True, help="Verbose output") -@click.option("--show-usage", is_flag=True, help="Show token usage statistics") -@click.pass_context -def run(ctx, file: Path, provider: Optional[str], model: Optional[str], param: tuple, param_file: tuple, - api_key: Optional[str], output: Optional[str], format: str, version: Optional[str], verbose: bool, show_usage: bool): - """Run a .prmd file with an LLM provider (supports --meta:* flags).""" - return _run_impl(ctx, file, provider, model, param, param_file, api_key, output, format, version, verbose, show_usage) -@cli.command() -@click.argument("file", type=click.Path(exists=True, path_type=Path)) -@click.option("--verbose", "-v", is_flag=True, help="Show detailed validation results") -@click.option("--git", is_flag=True, help="Include git history consistency checks") -@click.option("--version-only", is_flag=True, help="Only validate version-related aspects") -@click.option("--check-overrides", is_flag=True, help="Validate section overrides against parent template") -def validate(file: Path, verbose: bool, git: bool, version_only: bool, check_overrides: bool): - """Validate a .prmd file syntax and structure.""" - try: - from prompd.validator import PrompdValidator - validator = PrompdValidator() - - if version_only: - # Only check version consistency - issues = validator.validate_version_consistency(file, check_git=git) - else: - # Full validation - issues = validator.validate_file(file) - if git: - # Add git consistency checks - git_issues = validator.validate_version_consistency(file, check_git=True) - issues.extend(git_issues) - - # Check override validation if requested - override_warnings = [] - if check_overrides: - try: - from prompd.parser import PrompdParser - parser = PrompdParser() - prompd = parser.parse_file(file) - - # Check if file has inheritance and overrides - if prompd.metadata and hasattr(prompd.metadata, 'inherits') and prompd.metadata.inherits: - if hasattr(prompd.metadata, 'override') and prompd.metadata.override: - # Resolve parent file path - parent_path = prompd.metadata.inherits - base_dir = file.parent - - # Handle relative paths - if not Path(parent_path).is_absolute(): - parent_file = base_dir / parent_path - else: - parent_file = Path(parent_path) - - if parent_file.exists(): - # Validate overrides against parent - override_warnings = parser.validate_overrides_against_parent(file, parent_file) - - if verbose and override_warnings: - console.print(f"\n[yellow]Override Validation Results:[/yellow]") - for warning in override_warnings: - console.print(f" [yellow]![/yellow] {warning}") - - # Add as warnings to issues - for warning in override_warnings: - issues.append({ - "level": "warning", - "message": f"Override validation: {warning}" - }) - else: - issues.append({ - "level": "error", - "message": f"Parent template not found: {parent_file}" - }) - else: - if verbose: - console.print(f"\n[blue]Override Check:[/blue] File inherits from {prompd.metadata.inherits} but has no overrides") - else: - if verbose: - console.print(f"\n[blue]Override Check:[/blue] File does not use inheritance") - - except Exception as e: - issues.append({ - "level": "error", - "message": f"Override validation failed: {e}" - }) - - if not issues: - console.print(f"[green]OK[/green] {file} is valid") - else: - # Group issues by level - errors = [i for i in issues if i.get("level") == "error"] - warnings = [i for i in issues if i.get("level") == "warning"] - info = [i for i in issues if i.get("level") == "info"] - - if errors: - console.print(f"[red]ERRORS[/red] ({len(errors)}):") - for issue in errors: - console.print(f" [red]-[/red] {issue['message']}") - - if warnings: - console.print(f"[yellow]WARNINGS[/yellow] ({len(warnings)}):") - for issue in warnings: - console.print(f" [yellow]-[/yellow] {issue['message']}") - - if info and verbose: - console.print(f"[blue]INFO[/blue] ({len(info)}):") - for issue in info: - console.print(f" [blue]-[/blue] {issue['message']}") - - sys.exit(1 if errors else 0) - - except Exception as e: - console.print(f"[red]Error validating file:[/red] {e}") - sys.exit(1) - - -@cli.command("list") -@click.option("--path", "-p", type=click.Path(exists=True, path_type=Path), - default=Path("."), help="Directory to search for .prmd files") -@click.option("--detailed", "-d", is_flag=True, help="Show detailed information") -@click.option("--recursive", "-r", is_flag=True, help="Search recursively in subdirectories") -def list_prompts(path: Path, detailed: bool, recursive: bool): - """List available .prmd files.""" - try: - from prompd.parser import PrompdParser - # Use recursive glob only if --recursive is specified - if recursive: - prompd_files = list(Path(path).glob("**/*.prmd")) - else: - prompd_files = list(Path(path).glob("*.prmd")) - - if not prompd_files: - console.print(f"No .prmd files found in {path}") - return - - if detailed: - parser = PrompdParser() - for prompd_file in prompd_files: - try: - prompd = parser.parse_file(prompd_file) - metadata = prompd.metadata - - console.print(Panel( - f"[bold]{metadata.name or prompd_file.stem}[/bold]\n" - f"[dim]File:[/dim] {prompd_file}\n" - f"[dim]Description:[/dim] {metadata.description or 'No description'}\n" - f"[dim]Version:[/dim] {metadata.version or 'N/A'}\n" - f"[dim]Variables:[/dim] {', '.join(p.name for p in metadata.parameters)}", - border_style="blue" - )) - except Exception as e: - console.print(f"[red]Error reading {prompd_file}:[/red] {e}") - else: - table = Table(title=f"Prompd Files in {path}") - table.add_column("Name", style="cyan") - table.add_column("File", style="green") - table.add_column("Description") - - parser = PrompdParser() - for prompd_file in prompd_files: - try: - prompd = parser.parse_file(prompd_file) - metadata = prompd.metadata - table.add_row( - metadata.name or prompd_file.stem, - str(prompd_file), - (metadata.description or "")[:60] + "..." - if len(metadata.description or "") > 60 else (metadata.description or "") - ) - except Exception: - table.add_row(prompd_file.stem, str(prompd_file), "[red]Error reading file[/red]") - - console.print(table) - - except Exception as e: - console.print(f"[red]Error listing files:[/red] {e}") - sys.exit(1) - - -@cli.group() -def mcp(): - """Model Context Protocol (MCP) utilities.""" - pass - - -@mcp.command("serve") -@click.argument("path", type=click.Path(exists=True, path_type=Path)) -@click.option("--host", default="0.0.0.0", help="Bind host", show_default=True) -@click.option("--port", type=int, default=3333, help="Bind port", show_default=True) -@click.option("--oauth-client-id", default=None, help="OAuth client id") -@click.option("--auth-url", default=None, help="OAuth authorization URL") -@click.option("--token-url", default=None, help="OAuth token URL") -@click.option("--scopes", default=None, help="OAuth scopes (comma separated)") -def mcp_serve(path: Path, host: str, port: int, oauth_client_id: str, auth_url: str, token_url: str, scopes: str): - """Serve a .prmd or .pdflow over HTTP with simple MCP-style endpoints.""" - try: - try: - from prompd.mcp_server import serve_app - except Exception as imp_err: - console.print("[red]FastAPI/uvicorn not installed.[/red] Install with: [cyan]pip install fastapi uvicorn[/cyan]") - console.print(f"[dim]{imp_err}[/dim]") - sys.exit(1) - - scope_list = [s.strip() for s in scopes.split(',')] if scopes else None - serve_app( - file_path=path, - host=host, - port=port, - oauth={ - 'client_id': oauth_client_id, - 'auth_url': auth_url, - 'token_url': token_url, - 'scopes': scope_list - } - ) - except Exception as e: - console.print(f"[red]Failed to start MCP server:[/red] {e}") - sys.exit(1) - - -@mcp.command("dockerize") -@click.option("--dockerfile", default="Dockerfile.prmd-mcp", help="Output Dockerfile name", show_default=True) -@click.option("--compose", default="docker-compose.prmd-mcp.yml", help="Output docker-compose file name", show_default=True) -@click.option("--port", type=int, default=3333, help="Container port to expose", show_default=True) -def mcp_dockerize(dockerfile: str, compose: str, port: int): - """Scaffold Docker + Compose files to serve a .prmd/.pdflow via MCP.""" - try: - from textwrap import dedent - dockerfile_content = dedent(f""" - # Prompd MCP server image - FROM python:3.11-slim - WORKDIR /app - # Install Prompd with MCP extras from PyPI (requires published package) - RUN pip install --no-cache-dir "prompd[mcp]" - # Default env; override at runtime - ENV PROMPD_DEFAULT_PROVIDER=openai \\ - PROMPD_DEFAULT_MODEL=gpt-3.5-turbo - EXPOSE {port} - # Serve any mounted file under /data; override the path with docker run args or compose command - CMD ["prompd", "mcp", "serve", "/data/prompt.prmd", "--host", "0.0.0.0", "--port", "{port}"] - """) - - compose_content = dedent(f""" - version: "3.9" - services: - prompd-mcp: - build: - context: . - dockerfile: {dockerfile} - environment: - - OPENAI_API_KEY=${{OPENAI_API_KEY}} - - ANTHROPIC_API_KEY=${{ANTHROPIC_API_KEY}} - - PROMPD_DEFAULT_PROVIDER=${{PROMPD_DEFAULT_PROVIDER:-openai}} - - PROMPD_DEFAULT_MODEL=${{PROMPD_DEFAULT_MODEL:-gpt-3.5-turbo}} - volumes: - - ./prompds:/data - ports: - - "{port}:{port}" - # Example override: serve a different file - # command: ["prompd", "mcp", "serve", "/data/workflow.pdflow", "--host", "0.0.0.0", "--port", "{port}"] - """) - - Path(dockerfile).write_text(dockerfile_content, encoding="utf-8") - Path(compose).write_text(compose_content, encoding="utf-8") - console.print(f"[green]OK[/green] Wrote {dockerfile} and {compose}") - console.print("Build + run:") - console.print(f" [dim]docker build -f {dockerfile} -t prompd-mcp .[/dim]") - console.print(f" [dim]docker run -p {port}:{port} -v $PWD/prompds:/data -e OPENAI_API_KEY=sk-... prompd-mcp[/dim]") - console.print("Or via compose:") - console.print(f" [dim]docker compose -f {compose} up --build[/dim]") - except Exception as e: - console.print(f"[red]Failed to scaffold Docker files:[/red] {e}") - sys.exit(1) - -@cli.command("shell") -@click.option("--simple", is_flag=True, help="Use the simple REPL (no AI chat UI)") -def shell_command(simple: bool): - """Start the interactive Prompd shell (REPL). [AI features in BETA]""" - try: - if simple: - from prompd.interactive_simple import SimplePrompdREPL - SimplePrompdREPL().start() - else: - from prompd.shell import PrompdShell - PrompdShell().start() - except Exception as e: - try: - console.print(f"[red]Error launching shell:[/red] {e}") - except Exception: - print(f"Error launching shell: {e}") - sys.exit(1) - - -@cli.command("chat") -def chat_command(): - """Start the Prompd shell directly in chat mode. [BETA FEATURE]""" - try: - from prompd.shell import PrompdShell - sh = PrompdShell() - sh.enter_chat_mode() - sh.start() - except Exception as e: - try: - console.print(f"[red]Error launching chat:[/red] {e}") - except Exception: - print(f"Error launching chat: {e}") - sys.exit(1) - -@cli.command("compile", context_settings=dict(ignore_unknown_options=True, allow_extra_args=True)) -@click.argument("source", type=str) -@click.option("--to", "output_format", default="markdown", help="Output format (markdown | provider-json [openai|anthropic] | provider-json:openai)") -@click.option("--to-markdown", is_flag=True, help="Shorthand for --to markdown") -@click.option("--to-provider-json", type=click.Choice(["openai", "anthropic"]), help="Shorthand for --to provider-json ") -@click.option("-p", "--param", multiple=True, help="Parameter in format key=value (repeat for multiple)") -@click.option("-f", "--params-file", type=click.Path(exists=True, path_type=Path), multiple=True, help="Load parameters from JSON file (repeatable)") -@click.option("-o", "--output", type=click.Path(), help="Write compiled output to file") -@click.option("-v", "--verbose", is_flag=True, help="Verbose output") -@click.pass_context -def compile_command(ctx, source: str, output_format: str, to_markdown: bool, to_provider_json: Optional[str], param: tuple, params_file: tuple, output: Optional[str], verbose: bool): - """Compile a .prmd file or package reference to a target format. - - Supports package references like: - - @namespace/package@version/path/to/file.prmd - - @prompd.io/security@1.0.0/prompts/audit.prmd - - package@version/file.prmd - """ - try: - # Check if source is a package reference with path - source_path = Path(source) - - # Pattern to detect package references: @namespace/package@version/path or package@version/path - package_pattern = r'^(@[\w.-]+/[\w.-]+|[\w.-]+)@([\w.-]+)/(.+\.prmd)$' - import re - match = re.match(package_pattern, source) - - if match: - # This is a package reference with file path - package_ref = f"{match.group(1)}@{match.group(2)}" - file_path_in_package = match.group(3) - - if verbose: - console.print(f"[cyan]Resolving package:[/cyan] {package_ref}") - console.print(f"[cyan]File path:[/cyan] {file_path_in_package}") - - # Resolve the package - from .package_resolver import PackageResolver - resolver = PackageResolver() - - try: - # Resolve package to local path - package_path = resolver.resolve_package(package_ref) - - # Construct full file path - source_path = package_path / file_path_in_package - - if not source_path.exists(): - console.print(f"[red]File not found in package:[/red] {file_path_in_package}") - console.print(f"[yellow]Package location:[/yellow] {package_path}") - sys.exit(1) - - if verbose: - console.print(f"[green]Resolved to:[/green] {source_path}") - - except Exception as e: - console.print(f"[red]Failed to resolve package:[/red] {e}") - sys.exit(1) - elif not source_path.exists(): - # Try as a direct package reference without file path - if '@' in source and '/' not in source.split('@')[-1]: - # Might be just a package reference, try to resolve it - from .package_resolver import PackageResolver - resolver = PackageResolver() - - try: - package_path = resolver.resolve_package(source) - # Look for main file in manifest - manifest_file = package_path / 'manifest.json' - if manifest_file.exists(): - import json - with open(manifest_file) as f: - manifest = json.load(f) - main_file = manifest.get('main') - if main_file: - source_path = package_path / main_file - if verbose: - console.print(f"[green]Using main file:[/green] {main_file}") - except: - pass - - if not source_path.exists(): - console.print(f"[red]File not found:[/red] {source}") - sys.exit(1) - - # Merge parameters from files and CLI - parameters: Dict[str, Any] = {} - if params_file: - import json - for pf in params_file: - try: - data = json.loads(Path(pf).read_text(encoding='utf-8')) - if isinstance(data, dict): - parameters.update(data) - except Exception as e: - console.print(f"[red]Error loading params file {pf}:[/red] {e}") - sys.exit(1) - - if param: - import json - for kv in param: - if '=' not in kv: - console.print(f"[red]Invalid parameter:[/red] {kv}. Use key=value") - sys.exit(1) - k, v = kv.split('=', 1) - - # Try to parse as JSON for complex objects, fallback to string - try: - # If it looks like JSON (starts with { or [), try parsing it - if v.strip().startswith(('{', '[')): - parameters[k] = json.loads(v) - # Handle boolean values - elif v.lower() in ('true', 'false'): - parameters[k] = v.lower() == 'true' - # Handle numeric values - elif v.isdigit() or (v.replace('.', '').replace('-', '').isdigit() and v.count('.') <= 1): - if '.' in v: - parameters[k] = float(v) - else: - parameters[k] = int(v) - # Default to string - else: - parameters[k] = v - except json.JSONDecodeError: - # If JSON parsing fails, treat as string - parameters[k] = v - - # Resolve requested output format, supporting legacy + shorthand forms - if to_markdown: - output_format = "markdown" - elif to_provider_json: - output_format = f"provider-json:{to_provider_json}" - else: - # Accept space-separated option after provider-json, e.g. "--to provider-json openai" - try: - extra = list(getattr(ctx, 'args', []) or []) - except Exception: - extra = [] - if output_format.strip().lower() == "provider-json" and extra: - next_tok = extra[0] - if next_tok and not next_tok.startswith('-'): - output_format = f"provider-json:{next_tok}" - # consume the token to avoid confusing other parsing - try: - ctx.args = extra[1:] - except Exception: - pass - - if verbose: - try: - console.print(f"[dim]Compiling {source} -> {output_format} with params: {list(parameters.keys())}[/dim]") - except Exception: - pass - - from prompd.compiler import PrompdCompiler - compiler = PrompdCompiler() - result = compiler.compile( - source=source, - output_format=output_format, - parameters=parameters, - output_file=Path(output) if output else None, - verbose=verbose - ) - - if output: - try: - console.print(f"[green]OK[/green] Compiled output written to {output}") - except Exception: - print(f"OK - Compiled output written to {output}") - else: - print(result) - - except Exception as e: - try: - console.print(f"[red]Error compiling:[/red] {e}") - except Exception: - print(f"Error compiling: {e}") - sys.exit(1) - - - -# Removed provider commands - moved to prompd.commands.config - -# Old provider commands removed (lines 715-918) -# All provider functionality now available via: -# - prompd config provider list -# - prompd config provider add -# - prompd config provider remove -# - prompd config provider setkey -# - prompd config providers (alias) - -# Keep the old providers command for backward compatibility -# All provider functionality moved to prompd.commands.config - -# Legacy providers command removed - use 'prompd config provider list' or 'prompd config providers' - - -@cli.command() -@click.argument("file", type=click.Path(exists=True, path_type=Path)) -@click.option("--sections", is_flag=True, help="Show available section IDs for override reference") -@click.option("--verbose", is_flag=True, help="Show detailed section information") -def show(file: Path, sections: bool, verbose: bool): - """Show the structure and parameters of a .prmd file.""" - try: - from prompd.parser import PrompdParser - parser = PrompdParser() - prompd = parser.parse_file(file) - metadata = prompd.metadata - - console.print(Panel(f"[bold cyan]{metadata.name}[/bold cyan]", - subtitle=f"Version: {metadata.version or 'N/A'}")) - - if metadata.description: - console.print(f"\n[bold]Description:[/bold] {metadata.description}\n") - - if metadata.parameters: - table = Table(title="Parameters") - table.add_column("Name", style="cyan") - table.add_column("Type", style="green") - table.add_column("Required", style="yellow") - table.add_column("Default") - table.add_column("Description") - - for param in metadata.parameters: - table.add_row( - param.name, - param.type.value, - "Yes" if param.required else "No", - str(param.default or "")[:20], - param.description[:40] if param.description else "" - ) - console.print(table) - - # Show content structure - content_info = [] - if metadata.system: - content_info.append(f"System: {metadata.system}") - if metadata.context: - content_info.append(f"Context: {metadata.context}") - if metadata.user: - content_info.append(f"User: {metadata.user}") - if metadata.response: - content_info.append(f"Response: {metadata.response}") - - if content_info: - console.print(f"\n[bold]Content Structure:[/bold]") - for info in content_info: - console.print(f" -{info}") - - # Handle sections display - if sections: - try: - # Extract detailed section information - section_summary = parser.get_section_summary(file) - - if section_summary: - # Create sections table - sections_table = Table(title="Available Sections for Override") - sections_table.add_column("Section ID", style="cyan", min_width=20) - sections_table.add_column("Heading Text", style="green", min_width=30) - if verbose: - sections_table.add_column("Content Length", style="yellow", justify="right") - - for section_id, heading_text, content_length in section_summary: - if verbose: - sections_table.add_row(section_id, heading_text, f"{content_length:,} chars") - else: - sections_table.add_row(section_id, heading_text) - - console.print(f"\n") - console.print(sections_table) - - # Show usage example - console.print(f"\n[bold]Override Usage Example:[/bold]") - console.print("[dim]override:[/dim]") - if section_summary: - example_id = section_summary[0][0] # First section ID - console.print(f"[dim] {example_id}: \"./custom-{example_id}.md\"[/dim]") - console.print(f"[dim] another-section: null # Remove section[/dim]") - - else: - console.print(f"\n[yellow]No sections found in {file.name}[/yellow]") - console.print("[dim]Note: Only markdown headings (# Header) create sections[/dim]") - - except Exception as e: - console.print(f"\n[red]Error extracting sections:[/red] {e}") - else: - # Show basic sections found in file (legacy behavior) - if prompd.sections: - console.print(f"\n[bold]Available Sections:[/bold]") - for section_name in prompd.sections: - console.print(f" -#{section_name}") - - # Show inheritance information if present - if metadata and hasattr(metadata, 'inherits') and metadata.inherits: - console.print(f"\n[bold]Inherits from:[/bold] {metadata.inherits}") - - # Show override information if present - if hasattr(metadata, 'override') and metadata.override: - console.print(f"\n[bold]Section Overrides:[/bold]") - for section_id, override_path in metadata.override.items(): - if override_path is None: - console.print(f" -[red]{section_id}[/red]: [removed]") - else: - console.print(f" -[cyan]{section_id}[/cyan]: {override_path}") - - if metadata.requires: - console.print(f"\n[bold]Requirements:[/bold] {', '.join(metadata.requires)}") - - except Exception as e: - console.print(f"[red]Error reading file:[/red] {e}") - sys.exit(1) - - -@cli.group() -def git(): - """Git operations for .prmd files.""" - pass - - -@git.command("add") -@click.argument("files", nargs=-1, required=True, type=click.Path(exists=True, path_type=Path)) -@click.option("--verbose", "-v", is_flag=True, help="Show git output") -def git_add(files: tuple, verbose: bool): - """Add .prmd files to git staging area.""" - try: - for file_path in files: - file_path = Path(file_path) - if not file_path.suffix == ".prmd": - console.print(f"[yellow]Skipping non-.prmd file:[/yellow] {file_path}") - continue - - result = subprocess.run( - ["git", "add", str(file_path)], - capture_output=True, - text=True, - check=True - ) - - console.print(f"[green]OK[/green] Added {file_path}") - if verbose and result.stdout: - console.print(f"[dim]{result.stdout}[/dim]") - - except subprocess.CalledProcessError as e: - console.print(f"[red]Error adding files:[/red] {e.stderr}") - sys.exit(1) - - -@git.command("remove") -@click.argument("files", nargs=-1, required=True, type=click.Path(exists=True, path_type=Path)) -@click.option("--cached", is_flag=True, help="Only remove from index, keep in working directory") -@click.option("--verbose", "-v", is_flag=True, help="Show git output") -def git_remove(files: tuple, cached: bool, verbose: bool): - """Remove .prmd files from git tracking.""" - try: - for file_path in files: - file_path = Path(file_path) - if not file_path.suffix == ".prmd": - console.print(f"[yellow]Skipping non-.prmd file:[/yellow] {file_path}") - continue - - cmd = ["git", "rm"] - if cached: - cmd.append("--cached") - cmd.append(str(file_path)) - - result = subprocess.run( - cmd, - capture_output=True, - text=True, - check=True - ) - - action = "Removed from index" if cached else "Removed" - console.print(f"[green]OK[/green] {action}: {file_path}") - if verbose and result.stdout: - console.print(f"[dim]{result.stdout}[/dim]") - - except subprocess.CalledProcessError as e: - console.print(f"[red]Error removing files:[/red] {e.stderr}") - sys.exit(1) - - -@git.command("status") -@click.option("--path", "-p", type=click.Path(exists=True, path_type=Path), - help="Check status for specific path") -def git_status(path: Optional[Path]): - """Show git status for .prmd files.""" - try: - cmd = ["git", "status", "--short"] - if path: - cmd.append(str(path)) - - result = subprocess.run( - cmd, - capture_output=True, - text=True, - check=True - ) - - if not result.stdout: - console.print("[green]No changes to .prmd files[/green]") - return - - # Filter for .prmd files - prompd_changes = [] - for line in result.stdout.strip().split('\n'): - if '.prmd' in line: - prompd_changes.append(line) - - if prompd_changes: - console.print("[bold]Git status for .prmd files:[/bold]") - for change in prompd_changes: - status_code = change[:2] - file_path = change[3:] - - # Color code based on status - if 'M' in status_code: - status_color = "yellow" - status_text = "Modified" - elif 'A' in status_code: - status_color = "green" - status_text = "Added" - elif 'D' in status_code: - status_color = "red" - status_text = "Deleted" - elif '?' in status_code: - status_color = "blue" - status_text = "Untracked" - else: - status_color = "white" - status_text = status_code - - console.print(f" [{status_color}]{status_text:10}[/{status_color}] {file_path}") - else: - console.print("[dim]No .prmd file changes[/dim]") - - except subprocess.CalledProcessError as e: - console.print(f"[red]Error checking status:[/red] {e.stderr}") - sys.exit(1) - - -@git.command("commit") -@click.option("--message", "-m", required=True, help="Commit message") -@click.option("--all", "-a", is_flag=True, help="Automatically stage all modified .prmd files") -def git_commit(message: str, all: bool): - """Commit staged .prmd files.""" - try: - from prompd.security import validate_git_file_path, validate_git_message, SecurityError - if all: - # First add all modified .prmd files - result = subprocess.run( - ["git", "status", "--porcelain"], - capture_output=True, - text=True, - check=True - ) - - for line in result.stdout.strip().split('\n'): - if line and '.prmd' in line and line[0] == ' ' and line[1] == 'M': - file_path = line[3:] - try: - safe_path = validate_git_file_path(file_path) - subprocess.run(["git", "add", safe_path], check=True) - console.print(f"[dim]Auto-staging: {safe_path}[/dim]") - except SecurityError as e: - console.print(f"[red]Security warning: Skipping unsafe file path: {e}[/red]") - continue - - # Commit with validated message - try: - safe_message = validate_git_message(message) - except SecurityError as e: - console.print(f"[red]Error: Invalid commit message: {e}[/red]") - raise click.Abort() - - result = subprocess.run( - ["git", "commit", "-m", safe_message], - capture_output=True, - text=True, - check=True - ) - - console.print(f"[green]OK[/green] Committed changes") - if result.stdout: - # Extract commit hash and stats - lines = result.stdout.strip().split('\n') - for line in lines: - if 'file' in line and 'changed' in line: - console.print(f"[dim]{line}[/dim]") - - except subprocess.CalledProcessError as e: - if "nothing to commit" in e.stdout: - console.print("[yellow]Nothing to commit[/yellow]") - else: - console.print(f"[red]Error committing:[/red] {e.stderr}") - sys.exit(1) - - -@git.command("checkout") -@click.argument("file", type=click.Path(path_type=Path)) -@click.argument("version") -@click.option("--output", "-o", type=click.Path(), help="Output to different file instead of overwriting") -def git_checkout(file: Path, version: str, output: Optional[str]): - """Checkout a specific version of a .prmd file. - - VERSION can be: - - A semantic version (e.g., '1.2.3') - - A git tag name - - A commit hash - - 'HEAD' for latest committed version - - 'HEAD~1' for previous commit, etc. - """ - try: - file = Path(file) - if not file.suffix == ".prmd": - console.print(f"[red]Error:[/red] {file} is not a .prmd file") - sys.exit(1) - - # Try to resolve as semantic version tag first - if _is_valid_semver(version): - tag_name = f"{file.stem}-v{version}" - # Check if tag exists - tag_check = subprocess.run( - ["git", "tag", "-l", tag_name], - capture_output=True, - text=True - ) - if tag_check.stdout.strip(): - version_ref = tag_name - else: - version_ref = version - else: - version_ref = version - - # Get the file content at that version - # Convert Windows paths to forward slashes for git - git_path = str(file).replace('\\', '/') - result = subprocess.run( - ["git", "show", f"{version_ref}:{git_path}"], - capture_output=True, - text=True, - check=True - ) - - if output: - # Write to specified output file - output_path = Path(output) - output_path.write_text(result.stdout, encoding='utf-8') - console.print(f"[green]OK[/green] Checked out {file} @ {version} to {output_path}") - else: - # Overwrite current file - file.write_text(result.stdout, encoding='utf-8') - console.print(f"[green]OK[/green] Checked out {file} @ {version}") - console.print("[yellow]Note:[/yellow] Working directory has been modified. Use 'git diff' to see changes.") - - except subprocess.CalledProcessError as e: - if "does not exist" in e.stderr: - console.print(f"[red]Error:[/red] Version '{version}' not found for {file}") - console.print("[dim]Try 'prompd version history' to see available versions[/dim]") - else: - console.print(f"[red]Error checking out version:[/red] {e.stderr}") - sys.exit(1) - - -@cli.group() -def version(): - """Version management commands.""" - pass - - -@version.command("bump") -@click.argument("file", type=click.Path(exists=True, path_type=Path)) -@click.argument("bump_type", type=click.Choice(["major", "minor", "patch"])) -@click.option("--message", "-m", help="Commit message") -@click.option("--dry-run", is_flag=True, help="Show what would be done without making changes") -def version_bump(file: Path, bump_type: str, message: Optional[str], dry_run: bool): - """Bump version in a .prmd file and create git tag.""" - try: - parser = PrompdParser() - prompd = parser.parse_file(file) - - current_version = prompd.metadata.version or "0.0.0" - new_version = _bump_version(current_version, bump_type) - - if dry_run: - console.print(f"[dim]Would bump {file} from {current_version} to {new_version}[/dim]") - return - - # Update version in file - _update_version_in_file(file, new_version) - - # Git operations - commit_msg = message or f"Bump {file.name} to {new_version}" - _git_commit_and_tag(file, new_version, commit_msg) - - console.print(f"[green]OK[/green] Bumped {file.name} from {current_version} to {new_version}") - - except Exception as e: - console.print(f"[red]Error:[/red] {e}") - sys.exit(1) - - -@version.command("history") -@click.argument("file", type=click.Path(exists=True, path_type=Path)) -@click.option("--limit", "-n", type=int, default=10, help="Number of versions to show") -def version_history(file: Path, limit: int): - """Show version history for a .prmd file.""" - try: - tags = _get_git_tags(file, limit) - - if not tags: - console.print(f"[yellow]No version tags found for {file}[/yellow]") - return - - table = Table(title=f"Version History for {file}") - table.add_column("Version", style="cyan") - table.add_column("Date", style="green") - table.add_column("Commit", style="yellow") - table.add_column("Message") - - for tag_info in tags: - table.add_row( - tag_info["tag"], - tag_info["date"], - tag_info["commit"][:8], - tag_info["message"][:60] - ) - - console.print(table) - - except Exception as e: - console.print(f"[red]Error:[/red] {e}") - sys.exit(1) - - -@version.command("diff") -@click.argument("file", type=click.Path(exists=True, path_type=Path)) -@click.argument("version1") -@click.argument("version2", required=False) -def version_diff(file: Path, version1: str, version2: Optional[str]): - """Show differences between versions of a .prmd file.""" - try: - version2 = version2 or "HEAD" - diff_output = _git_diff_versions(file, version1, version2) - - if not diff_output: - console.print(f"[green]No differences between {version1} and {version2}[/green]") - return - - syntax = Syntax(diff_output, "diff", theme="monokai", line_numbers=True) - console.print(Panel(syntax, title=f"Diff: {version1} -> {version2}")) - - except Exception as e: - console.print(f"[red]Error:[/red] {e}") - sys.exit(1) - - -@version.command("validate") -@click.argument("file", type=click.Path(exists=True, path_type=Path)) -@click.option("--git", is_flag=True, help="Validate against git history") -def version_validate(file: Path, git: bool): - """Validate version consistency.""" - try: - parser = PrompdParser() - prompd = parser.parse_file(file) - - current_version = prompd.metadata.version - if not current_version: - console.print(f"[yellow]WARNING[/yellow] No version specified in {file}") - return - - # Validate semantic version format - if not _is_valid_semver(current_version): - console.print(f"[red]ERROR[/red] Invalid semantic version: {current_version}") - sys.exit(1) - - if git: - # Check if version matches latest git tag - latest_tag = _get_latest_git_tag(file) - if latest_tag and latest_tag != current_version: - console.print(f"[yellow]WARNING[/yellow] Version mismatch:") - console.print(f" File version: {current_version}") - console.print(f" Latest git tag: {latest_tag}") - - console.print(f"[green]OK[/green] Version {current_version} is valid") - - except Exception as e: - console.print(f"[red]Error:[/red] {e}") - sys.exit(1) - - -@version.command("suggest") -@click.argument("file", type=click.Path(exists=True, path_type=Path)) -@click.option("--changes", help="Description of changes made") -def version_suggest(file: Path, changes: Optional[str]): - """Suggest appropriate version bump based on changes.""" - try: - parser = PrompdParser() - validator = PrompdValidator() - prompd = parser.parse_file(file) - - current_version = prompd.metadata.version or "0.0.0" - suggestion = validator.suggest_version_bump(current_version, changes or "") - - console.print(Panel( - f"[bold cyan]Current Version:[/bold cyan] {suggestion['suggestions']['current']}\\n\\n" - f"[bold green]Suggested Bump:[/bold green] {suggestion['recommended']} -> " - f"{suggestion['suggestions'][suggestion['recommended']]}\\n\\n" - f"[bold]All Options:[/bold]\\n" - f" - Patch: {suggestion['suggestions']['patch']} (bug fixes)\\n" - f" - Minor: {suggestion['suggestions']['minor']} (new features)\\n" - f" - Major: {suggestion['suggestions']['major']} (breaking changes)\\n\\n" - f"[dim]{suggestion['reason']}[/dim]", - title="Version Bump Suggestions" - )) - - except Exception as e: - console.print(f"[red]Error:[/red] {e}") - sys.exit(1) - - -def _bump_version(version: str, bump_type: str) -> str: - """Bump semantic version.""" - parts = version.split(".") - if len(parts) != 3: - raise ValueError(f"Invalid semantic version: {version}") - - major, minor, patch = map(int, parts) - - if bump_type == "major": - major += 1 - minor = 0 - patch = 0 - elif bump_type == "minor": - minor += 1 - patch = 0 - elif bump_type == "patch": - patch += 1 - - return f"{major}.{minor}.{patch}" - - -def _is_valid_semver(version: str) -> bool: - """Check if version follows semantic versioning.""" - import re - pattern = r"^(\d+)\.(\d+)\.(\d+)$" - return bool(re.match(pattern, version)) - - -def _update_version_in_file(file_path: Path, new_version: str): - """Update version field in .prmd file.""" - content = file_path.read_text(encoding='utf-8') - - # Parse YAML frontmatter - import re - if content.startswith('---\n'): - # Find the end of frontmatter - end_match = re.search(r'\n---\n', content[4:]) - if end_match: - yaml_end = end_match.end() + 4 - frontmatter = content[4:yaml_end-5] # Remove --- delimiters - markdown_content = content[yaml_end:] - - # Update version in frontmatter - import yaml - metadata = yaml.safe_load(frontmatter) or {} - metadata['version'] = new_version - - # Write back - updated_content = f"---\n{yaml.dump(metadata, default_flow_style=False)}---\n{markdown_content}" - file_path.write_text(updated_content, encoding='utf-8') - - -def _git_commit_and_tag(file_path: Path, version: str, message: str): - """Create git commit and tag.""" - try: - from prompd.security import validate_git_file_path, validate_git_message, validate_version_string, SecurityError - # Validate inputs for security - safe_path = validate_git_file_path(str(file_path)) - safe_message = validate_git_message(message) - safe_version = validate_version_string(version) - - # Add file to git - subprocess.run(["git", "add", safe_path], check=True, capture_output=True) - - # Commit - subprocess.run(["git", "commit", "-m", safe_message], check=True, capture_output=True) - - # Create tag (validate tag name components) - safe_stem = validate_git_file_path(file_path.stem) - tag_name = f"{safe_stem}-v{safe_version}" - subprocess.run(["git", "tag", tag_name], check=True, capture_output=True) - - except SecurityError as e: - raise Exception(f"Security validation failed: {e}") - except subprocess.CalledProcessError as e: - raise Exception(f"Git operation failed: {e.stderr.decode()}") - - -def _get_git_tags(file_path: Path, limit: int) -> List[Dict[str, str]]: - """Get git tags related to a file.""" - try: - # Get tags with commit info - result = subprocess.run([ - "git", "log", "--tags", "--simplify-by-decoration", "--pretty=format:%d|%H|%ai|%s", - "-n", str(limit), "--", str(file_path) - ], capture_output=True, text=True, check=True) - - tags = [] - for line in result.stdout.split('\n'): - if line.strip(): - parts = line.split('|', 3) - if len(parts) == 4 and 'tag:' in parts[0]: - # Extract tag name - import re - tag_match = re.search(r'tag: ([^,)]+)', parts[0]) - if tag_match: - tags.append({ - 'tag': tag_match.group(1).strip(), - 'commit': parts[1], - 'date': parts[2][:10], # Just the date part - 'message': parts[3] - }) - - return tags - - except subprocess.CalledProcessError: - return [] - - -def _get_latest_git_tag(file_path: Path) -> Optional[str]: - """Get latest git tag for a file.""" - tags = _get_git_tags(file_path, 1) - return tags[0]['tag'] if tags else None - - -def _git_diff_versions(file_path: Path, version1: str, version2: str) -> str: - """Get git diff between versions.""" - try: - result = subprocess.run([ - "git", "diff", f"{file_path.stem}-v{version1}", f"{file_path.stem}-v{version2}", - "--", str(file_path) - ], capture_output=True, text=True, check=True) - - return result.stdout - - except subprocess.CalledProcessError as e: - raise Exception(f"Git diff failed: {e.stderr.decode()}") - - -# ================================================================================ -# PACKAGE MANAGEMENT COMMANDS (NEW NPM-STYLE ARCHITECTURE) -# ================================================================================ - -@cli.command() -@click.option('-k', '--api-key', help='API key for authentication') -@click.option('-u', '--username', help='Username for credential authentication') -@click.option('--password', help='Password for credential authentication') -@click.option('--registry', help='Registry to login to') -def login(api_key: Optional[str], username: Optional[str], password: Optional[str], registry: Optional[str]): - """Login to package registry.""" - try: - from .registry import RegistryClient - - client = RegistryClient(registry_name=registry) - - if api_key: - result = client.login_with_token(api_key) - elif username and password: - result = client.login_with_credentials(username, password) - else: - # Interactive login - import getpass - username = click.prompt('Username') - password = getpass.getpass('Password: ') - result = client.login_with_credentials(username, password) - - console.print(f"[green]Success:[/green] Logged in to {client.registry_name} as {result.get('username', 'user')}") - - except Exception as e: - console.print(f"[red]Login failed:[/red] {e}") - sys.exit(1) - - -@cli.command() -@click.option('--registry', help='Registry to logout from') -def logout(registry: Optional[str]): - """Logout from package registry.""" - try: - from .registry import RegistryClient - - client = RegistryClient(registry_name=registry) - client.logout() - - console.print(f"[green]Success:[/green] Logged out from {client.registry_name}") - - except Exception as e: - console.print(f"[red]Logout failed:[/red] {e}") - sys.exit(1) - - -@cli.command() -@click.argument('packages', nargs=-1, required=False) -@click.option('-g', '--global', 'global_install', is_flag=True, help='Install packages globally') -@click.option('--save', is_flag=True, default=True, help='Save to dependencies (default behavior)') -@click.option('--save-dev', is_flag=True, help='Save to development dependencies') -@click.option('--registry', help='Registry to install from') -def install(packages: tuple, global_install: bool, save: bool, save_dev: bool, registry: Optional[str]): - """Install packages from registry. - - Without arguments: installs all dependencies from manifest.json - With arguments: installs specified packages and updates manifest.json - """ - try: - from .package_resolver import PackageResolver - import json - import asyncio - from concurrent.futures import ThreadPoolExecutor - from rich.progress import Progress, TaskID - from rich.table import Table - from rich.live import Live - - # Determine dependency type - save_dev takes precedence over save - dev = save_dev - - manifest_path = Path.cwd() / 'manifest.json' - - # If no packages specified, install from manifest.json - if not packages: - if not manifest_path.exists(): - console.print("[yellow]No manifest.json found and no packages specified[/yellow]") - console.print("[dim]Run 'prompd install ' to create a new project[/dim]") - return - - # Load manifest and install all dependencies - with open(manifest_path, 'r', encoding='utf-8') as f: - manifest = json.load(f) - - dependencies = manifest.get('dependencies', {}) - dev_dependencies = manifest.get('devDependencies', {}) - - if not dependencies and not dev_dependencies: - console.print("[yellow]No dependencies found in manifest.json[/yellow]") - return - - # Create resolver - resolver = PackageResolver( - registry_urls=[registry] if registry else None, - global_mode=global_install - ) - - # Prepare all packages to install - all_packages = [] - for package_name, version in dependencies.items(): - package_ref = f"{package_name}@{version}" if version != "latest" else package_name - all_packages.append((package_ref, False)) # (package, is_dev) - - for package_name, version in dev_dependencies.items(): - package_ref = f"{package_name}@{version}" if version != "latest" else package_name - all_packages.append((package_ref, True)) # (package, is_dev) - - # Install packages in parallel - console.print(f"[bold]Installing {len(all_packages)} packages in parallel...[/bold]\n") - - def install_single_package(package_info): - package_ref, is_dev = package_info - try: - if global_install: - package_path = resolver.install_package(package_ref, force_global=True, save_to_lock=False) - else: - resolver.add_dependency(package_ref, dev=is_dev, global_install=False) - package_path = resolver.resolve_package(package_ref) - return (package_ref, is_dev, True, str(package_path)) - except Exception as e: - return (package_ref, is_dev, False, str(e)) - - # Use ThreadPoolExecutor for parallel downloads - with ThreadPoolExecutor(max_workers=5) as executor: - results = list(executor.map(install_single_package, all_packages)) - - # Display results - success_count = sum(1 for _, _, success, _ in results if success) - for package_ref, is_dev, success, result in results: - dev_tag = " (dev)" if is_dev else "" - if success: - console.print(f"[green]OK[/green] {package_ref}{dev_tag}") - else: - console.print(f"[red]FAILED[/red] {package_ref}{dev_tag}: {result}") - - console.print(f"\n[green]Successfully installed {success_count}/{len(all_packages)} packages[/green]") - return - - # Installing specific packages - # Create or update manifest.json - if not manifest_path.exists(): - # Create new manifest.json - manifest = { - "name": Path.cwd().name.lower().replace(' ', '-'), - "version": "1.0.0", - "description": "", - "dependencies": {}, - "devDependencies": {} - } - console.print(f"[green]Created manifest.json for {manifest['name']}[/green]") - else: - # Load existing manifest - with open(manifest_path, 'r', encoding='utf-8') as f: - manifest = json.load(f) - - # Ensure dependencies sections exist - if 'dependencies' not in manifest: - manifest['dependencies'] = {} - if 'devDependencies' not in manifest: - manifest['devDependencies'] = {} - - # Create resolver - resolver = PackageResolver( - registry_urls=[registry] if registry else None, - global_mode=global_install - ) - - # Install packages in parallel if multiple - if len(packages) > 1: - console.print(f"[bold]Installing {len(packages)} packages in parallel...[/bold]\n") - - def install_single_package(package_ref): - try: - # Parse package reference to get name and version - if '@' in package_ref and not package_ref.startswith('@'): - package_name, package_version = package_ref.rsplit('@', 1) - else: - # Handle scoped packages like @prompd.io/package@version - parts = package_ref.split('@') - if len(parts) == 3: # @scope/name@version - package_name = f"@{parts[1]}" - package_version = parts[2] - elif len(parts) == 2 and parts[0] == '': # @scope/name - package_name = package_ref - package_version = "latest" - else: - package_name = package_ref - package_version = "latest" - - if global_install: - # Global installation - package_path = resolver.install_package(package_ref, force_global=True, save_to_lock=False) - else: - # Local installation with dependency management - resolver.add_dependency(package_ref, dev=dev, global_install=False) - package_path = resolver.resolve_package(package_ref) - - # Update manifest.json - if dev: - manifest['devDependencies'][package_name] = package_version - else: - manifest['dependencies'][package_name] = package_version - - return (package_ref, package_name, package_version, True, str(package_path)) - except Exception as e: - return (package_ref, None, None, False, str(e)) - - # Use ThreadPoolExecutor for parallel downloads - with ThreadPoolExecutor(max_workers=5) as executor: - results = list(executor.map(install_single_package, packages)) - - # Display results - success_count = sum(1 for _, _, _, success, _ in results if success) - for package_ref, _, _, success, result in results: - if success: - console.print(f"[green]OK[/green] {package_ref}") - console.print(f" Location: {result}") - else: - console.print(f"[red]FAILED[/red] {package_ref}: {result}") - - if success_count == len(packages): - console.print(f"\n[green]All {len(packages)} packages installed successfully[/green]") - else: - console.print(f"\n[yellow]Installed {success_count}/{len(packages)} packages[/yellow]") - else: - # Single package installation - package_ref = packages[0] - console.print(f"Installing {package_ref} {'globally' if global_install else 'locally'}...") - - # Parse package reference to get name and version - if '@' in package_ref and not package_ref.startswith('@'): - package_name, package_version = package_ref.rsplit('@', 1) - else: - # Handle scoped packages like @prompd.io/package@version - parts = package_ref.split('@') - if len(parts) == 3: # @scope/name@version - package_name = f"@{parts[1]}" - package_version = parts[2] - elif len(parts) == 2 and parts[0] == '': # @scope/name - package_name = package_ref - package_version = "latest" - else: - package_name = package_ref - package_version = "latest" - - if global_install: - # Global installation - package_path = resolver.install_package(package_ref, force_global=True, save_to_lock=False) - else: - # Local installation with dependency management - resolver.add_dependency(package_ref, dev=dev, global_install=False) - package_path = resolver.resolve_package(package_ref) - - # Update manifest.json - if dev: - manifest['devDependencies'][package_name] = package_version - else: - manifest['dependencies'][package_name] = package_version - - console.print(f"[green]OK[/green] Installed {package_ref}") - console.print(f" Location: {package_path}") - - # Save updated manifest.json (only for local installs) - if not global_install: - with open(manifest_path, 'w', encoding='utf-8') as f: - json.dump(manifest, f, indent=2) - console.print(f"\n[dim]Updated manifest.json and .prompd/lock.json[/dim]") - - except Exception as e: - console.print(f"[red]Installation failed:[/red] {e}") - sys.exit(1) - - -@cli.command() -@click.argument('packages', nargs=-1, required=True) -@click.option('-g', '--global', 'global_uninstall', is_flag=True, help='Uninstall packages globally') -@click.option('--save-dev', is_flag=True, help='Remove from development dependencies') -def uninstall(packages: tuple, global_uninstall: bool, save_dev: bool): - """Uninstall packages.""" - try: - from .package_resolver import PackageResolver - - resolver = PackageResolver(global_mode=global_uninstall) - - for package_name in packages: - console.print(f"Uninstalling {package_name}{'globally' if global_uninstall else 'locally'}...") - - if global_uninstall: - # For global uninstalls, we need version - show available versions - cached_packages = resolver.global_cache.list_packages() - matching = [p for p in cached_packages if p.name == package_name or f"@{p.namespace}/{p.name}" == package_name] - - if not matching: - console.print(f"[yellow]Package {package_name} not found in global cache[/yellow]") - continue - - if len(matching) > 1: - console.print(f"[yellow]Multiple versions found. Please specify version:[/yellow]") - for p in matching: - console.print(f" {p.to_string()}") - continue - - removed = resolver.uninstall_package(matching[0].to_string(), force_global=True) - else: - # Local uninstall with dependency management - resolver.remove_dependency(package_name, dev=save_dev, global_uninstall=False) - removed = True - - if removed: - console.print(f"[green]OK[/green] Uninstalled {package_name}") - else: - console.print(f"[yellow]Package {package_name} not found[/yellow]") - - except Exception as e: - console.print(f"[red]Uninstall failed:[/red] {e}") - sys.exit(1) - - -@cli.command() -@click.argument('query', required=True) -@click.option('-l', '--limit', default=20, help='Maximum number of results') -@click.option('--registry', help='Registry to search in') -def search(query: str, limit: int, registry: Optional[str]): - """Search packages in registry.""" - try: - from .registry import RegistryClient - - client = RegistryClient(registry_name=registry) - results = client.search(query, limit=limit) - - if not results: - console.print(f"[yellow]No packages found matching '{query}'[/yellow]") - return - - console.print(f"\n[bold]Found {len(results)} packages:[/bold]\n") - - table = Table(show_header=True, header_style="bold magenta") - table.add_column("Package", style="cyan") - table.add_column("Version", style="green") - table.add_column("Description", style="white") - table.add_column("Downloads", justify="right", style="yellow") - - for pkg in results: - # Use fullName (includes scope) or fallback to name - package_name = pkg.get('fullName', pkg.get('name', 'Unknown')) - - # Try multiple version field names from registry response - version = (pkg.get('latestVersion') or - pkg.get('latest_version') or - pkg.get('version') or - pkg.get('currentVersion') or - 'Unknown') - - # Use downloads30d (backend field) or fallback to downloads - downloads = pkg.get('downloads30d', pkg.get('downloads', 0)) - - table.add_row( - package_name, - version, - pkg.get('description', '')[:50] + ('...' if len(pkg.get('description', '')) > 50 else ''), - str(downloads) - ) - - console.print(table) - - except Exception as e: - console.print(f"[red]Search failed:[/red] {e}") - sys.exit(1) - - -@cli.command() -@click.argument('package_file', type=click.Path(exists=True, path_type=Path)) -@click.option('--registry', help='Registry to publish to') -@click.option('-ns', '--namespace', help='Namespace to publish to (overrides current namespace context)') -@click.option('-n', '--dry-run', is_flag=True, help='Show what would be published without actually doing it') -def publish(package_file: Path, registry: Optional[str], namespace: Optional[str], dry_run: bool): - """Publish package to registry.""" - try: - import zipfile - import json - from .registry import RegistryClient - - if dry_run: - console.print(f"[yellow]DRY RUN: Would publish {package_file}[/yellow]") - return - - # Extract package information from manifest before upload - package_name = "unknown" - package_version = "unknown" - try: - with zipfile.ZipFile(package_file, 'r') as zf: - if 'manifest.json' in zf.namelist(): - # Ensure proper encoding handling - manifest_bytes = zf.read('manifest.json') - manifest_text = manifest_bytes.decode('utf-8', errors='replace') - manifest_data = json.loads(manifest_text) - package_name = manifest_data.get('id', manifest_data.get('name', 'unknown')) - package_version = manifest_data.get('version', 'unknown') - - # If namespace is provided and package doesn't have a scope, add it - if namespace and not package_name.startswith('@'): - package_name = f"{namespace}/{package_name}" - except Exception as e: - # Silently fall back to unknown values if parsing fails - pass - - console.print(f"[blue]Publishing {package_name}@{package_version}...[/blue]") - console.print(f"[dim]Package: {package_file}[/dim]") - - client = RegistryClient(registry_name=registry) - - # Show registry info - console.print(f"[dim]Registry: {client.registry_name} ({client.registry_url})[/dim]") - - # Show current namespace context - current_ns = client.get_current_namespace() - if namespace: - console.print(f"[dim]Namespace: {namespace} (override)[/dim]") - elif current_ns: - console.print(f"[dim]Namespace: {current_ns} (current)[/dim]") - else: - console.print(f"[dim]Namespace: none (will use package scope or registry default)[/dim]") - - # Add upload progress - file_size = package_file.stat().st_size - console.print(f"[dim]Size: {file_size:,} bytes[/dim]") - console.print("[yellow]Uploading...[/yellow]") - - # Handle namespace specification - if namespace: - # Override current namespace context for this publish - result = client.publish_package(package_file, target_namespace=namespace) - else: - # Use current namespace context or default behavior - result = client.publish_package(package_file) - - # Extract actual published info from registry response or use our pre-extracted values - published_name = result.get('package', {}).get('fullName') or result.get('name') or package_name - published_version = result.get('package', {}).get('version') or result.get('version') or package_version - - console.print(f"[green]SUCCESS[/green] Published {published_name}@{published_version}") - console.print(f" Registry: {client.registry_name}") - if 'package_url' in result: - console.print(f" URL: {result['package_url']}") - elif 'url' in result: - console.print(f" URL: {result['url']}") - - except Exception as e: - console.print(f"[red]Publish failed:[/red] {e}") - sys.exit(1) - - -# ================================================================================ -# NAMESPACE MANAGEMENT COMMANDS -# ================================================================================ - -@cli.group(name='namespace') -def namespace(): - """Manage namespaces for organizations.""" - pass - - -# Add the 'ns' alias as a separate group -@cli.group(name='ns') -def ns(): - """Alias for namespace commands.""" - pass - - -# Add commands to ns group that delegate to namespace commands -@ns.command('list') -@click.option('--registry', help='Registry to query') -@click.option('--show-permissions', '-p', is_flag=True, help='Show detailed permissions for each namespace') -def ns_list(registry: Optional[str], show_permissions: bool): - """List accessible namespaces.""" - return namespace_list(registry, show_permissions) - - -@ns.command('current') -@click.option('--registry', help='Registry to query') -def ns_current(registry: Optional[str]): - """Show current namespace context.""" - # Call the actual namespace_current function directly - return namespace_current(registry) - - -@ns.command('use') -@click.argument('namespace_name') -@click.option('--registry', help='Registry to use') -def ns_use(namespace_name: str, registry: Optional[str]): - """Switch to a different namespace context.""" - return namespace_use(namespace_name, registry) - - -@ns.command('create') -@click.argument('namespace_name') -@click.option('--registry', help='Registry to use') -@click.option('--description', help='Namespace description') -def ns_create(namespace_name: str, registry: Optional[str], description: Optional[str]): - """Create a new namespace.""" - return namespace_create(namespace_name, registry, description) - - -@namespace.command('list') -@click.option('--registry', help='Registry to query') -@click.option('--show-permissions', '-p', is_flag=True, help='Show detailed permissions for each namespace') -def namespace_list(registry: Optional[str], show_permissions: bool): - """List accessible namespaces.""" - try: - from .registry import RegistryClient - - client = RegistryClient(registry_name=registry) - namespaces = client.list_user_namespaces() - - if not namespaces: - console.print("[yellow]No namespaces available[/yellow]") - console.print("\nTo get started:") - console.print("-Free users can publish to @public automatically") - console.print("-Create a team namespace: [cyan]prompd namespace create @my-company[/cyan]") - return - - # Get current namespace context - current_ns = client.get_current_namespace() - - console.print(f"[bold]Available namespaces ({len(namespaces)} total):[/bold]") - - from rich.table import Table - table = Table(show_header=True, header_style="bold magenta") - table.add_column("NAMESPACE", style="cyan") - table.add_column("PACKAGES", justify="right") - table.add_column("DOWNLOADS", justify="right") - table.add_column("ROLE", style="green") - if show_permissions: - table.add_column("PERMISSIONS", style="dim") - table.add_column("STATUS", justify="center") - - for ns in namespaces: - status = "[bold green]CURRENT[/bold green]" if ns['name'] == current_ns else "" - if ns.get('verified'): - status += " [OK]" if status else "[OK]" - - permissions_str = "" - if show_permissions: - perms = ns.get('permissions', {}) - perm_list = [] - if perms.get('canPublish'): perm_list.append('publish') - if perms.get('canManage'): perm_list.append('manage') - if perms.get('canInvite'): perm_list.append('invite') - if perms.get('canDelete'): perm_list.append('delete') - permissions_str = ', '.join(perm_list) or 'read' - - row = [ - ns['name'], - str(ns.get('packageCount', 0)), - str(ns.get('downloadCount', 0)), - ns.get('role', 'read').upper(), - ] - if show_permissions: - row.append(permissions_str) - row.append(status) - - table.add_row(*row) - - console.print(table) - - if current_ns: - console.print(f"\n[dim]Current namespace context: [cyan]{current_ns}[/cyan][/dim]") - else: - console.print("\n[dim]No current namespace context set[/dim]") - - console.print("\n[dim]Switch namespace: [cyan]prompd ns use [/cyan][/dim]") - - except Exception as e: - console.print(f"[red]Failed to list namespaces:[/red] {e}") - sys.exit(1) - - -@namespace.command('current') -@click.option('--registry', help='Registry to query') -def namespace_current(registry: Optional[str]): - """Show current namespace context.""" - try: - from .registry import RegistryClient - - client = RegistryClient(registry_name=registry) - current_ns = client.get_current_namespace() - - if current_ns: - # Get namespace details - details = client.get_namespace_details(current_ns) - console.print(f"[bold]Current namespace:[/bold] [cyan]{current_ns}[/cyan]") - - if details: - console.print(f" Description: {details.get('description', 'No description')}") - console.print(f" Packages: {details.get('packageCount', 0)}") - console.print(f" Downloads: {details.get('downloadCount', 0)}") - console.print(f" Role: {details.get('role', 'unknown').upper()}") - if details.get('verified'): - console.print(" Status: [green]Verified [OK][/green]") - else: - console.print("[yellow]No current namespace context set[/yellow]") - console.print("\nSet a namespace context:") - console.print(" [cyan]prompd ns use @public[/cyan] # Use public namespace") - console.print(" [cyan]prompd ns use @my-company[/cyan] # Use your team namespace") - - except Exception as e: - console.print(f"[red]Failed to get current namespace:[/red] {e}") - sys.exit(1) - - -@namespace.command('use') -@click.argument('namespace_name', required=True) -@click.option('--registry', help='Registry to use') -def namespace_use(namespace_name: str, registry: Optional[str]): - """Switch to a different namespace context.""" - try: - from .registry import RegistryClient - - # Normalize namespace name - if not namespace_name.startswith('@'): - namespace_name = '@' + namespace_name - - client = RegistryClient(registry_name=registry) - - # Just set the namespace context - # In a real system, the registry will validate access when you try to publish - client.set_current_namespace(namespace_name) - - console.print(f"[green]Success:[/green] Switched to namespace [cyan]{namespace_name}[/cyan]") - console.print("\n[dim]Future publishes will use this namespace unless overridden with the -ns flag[/dim]") - - except Exception as e: - console.print(f"[red]Failed to switch namespace:[/red] {e}") - sys.exit(1) - - -@namespace.command('create') -@click.argument('namespace_name', required=True) -@click.option('--description', '-d', help='Description for the namespace') -@click.option('--organization', '-o', help='Organization ID to create namespace under') -@click.option('--visibility', type=click.Choice(['public', 'private']), default='public', help='Namespace visibility') -@click.option('--registry', help='Registry to create namespace in') -def namespace_create(namespace_name: str, description: Optional[str], organization: Optional[str], - visibility: str, registry: Optional[str]): - """Create a new namespace.""" - try: - from .registry import RegistryClient - - # Normalize namespace name - if not namespace_name.startswith('@'): - namespace_name = '@' + namespace_name - - client = RegistryClient(registry_name=registry) - - # Prepare namespace data - namespace_data = { - 'name': namespace_name, - 'visibility': visibility - } - - if description: - namespace_data['description'] = description - if organization: - namespace_data['organizationId'] = organization - - console.print(f"[bold]Creating namespace:[/bold] [cyan]{namespace_name}[/cyan]") - - result = client.create_namespace(namespace_data) - - if result.get('requiresVerification'): - console.print(f"[yellow]Namespace requires verification[/yellow]") - console.print(f"Reason: {result.get('reason')}") - console.print(f"Request ID: {result.get('requestId')}") - console.print("\nCheck verification status: [cyan]prompd ns verify-status @namespace[/cyan]") - else: - console.print(f"[green]Success:[/green] Namespace [cyan]{namespace_name}[/cyan] created successfully") - - # Automatically switch to the new namespace - client.set_current_namespace(namespace_name) - console.print(f"[dim]Automatically switched to namespace context[/dim]") - - except Exception as e: - console.print(f"[red]Failed to create namespace:[/red] {e}") - sys.exit(1) - - -# ================================================================================ -# NS ALIAS COMMANDS -# ================================================================================ - -@ns.command('list') -@click.option('--registry', help='Registry to query') -@click.option('--show-permissions', '-p', is_flag=True, help='Show detailed permissions for each namespace') -def ns_list(registry: Optional[str], show_permissions: bool): - """List accessible namespaces.""" - # Call the original function - namespace_list(registry, show_permissions) - - -@ns.command('current') -@click.option('--registry', help='Registry to query') -def ns_current(registry: Optional[str]): - """Show current namespace context.""" - # Call the original function - namespace_current(registry) - - -@ns.command('use') -@click.argument('namespace_name', required=True) -@click.option('--registry', help='Registry to use') -def ns_use(namespace_name: str, registry: Optional[str]): - """Switch to a different namespace context.""" - # Call the original function - namespace_use(namespace_name, registry) - - -@ns.command('create') -@click.argument('namespace_name', required=True) -@click.option('--description', '-d', help='Description for the namespace') -@click.option('--organization', '-o', help='Organization ID to create namespace under') -@click.option('--visibility', type=click.Choice(['public', 'private']), default='public', help='Namespace visibility') -@click.option('--registry', help='Registry to create namespace in') -def ns_create(namespace_name: str, description: Optional[str], organization: Optional[str], - visibility: str, registry: Optional[str]): - """Create a new namespace.""" - # Call the original function - namespace_create(namespace_name, description, organization, visibility, registry) - - -@cli.command() -@click.argument('package_name', required=True) -@click.option('--registry', help='Registry to query') -def versions(package_name: str, registry: Optional[str]): - """List available versions of a package.""" - try: - from .registry import RegistryClient - - client = RegistryClient(registry_name=registry) - versions_list = client.get_package_versions(package_name) - - if not versions_list: - console.print(f"[yellow]No versions found for {package_name}[/yellow]") - return - - console.print(f"\n[bold]Available versions for {package_name}:[/bold]\n") - - table = Table(show_header=True, header_style="bold magenta") - table.add_column("Version", style="green") - table.add_column("Published", style="blue") - table.add_column("Tags", style="yellow") - - for version_info in versions_list: - table.add_row( - version_info.get('version', 'Unknown'), - version_info.get('published_at', 'Unknown')[:10], # Just date - ', '.join(version_info.get('tags', [])) - ) - - console.print(table) - - except Exception as e: - console.print(f"[red]Failed to get versions:[/red] {e}") - sys.exit(1) - - -@cli.group() -def cache(): - """Package cache management commands.""" - pass - - -@cache.command('list') -@click.option('--global-only', is_flag=True, help='Show only global cache') -@click.option('--local-only', is_flag=True, help='Show only local cache') -def list_cache(global_only: bool, local_only: bool): - """List cached packages.""" - try: - from .package_resolver import PackageResolver - - resolver = PackageResolver() - - if local_only: - packages_dict = {'local': resolver.project_cache.list_packages(), 'global': []} - elif global_only: - packages_dict = {'local': [], 'global': resolver.global_cache.list_packages()} - else: - packages_dict = resolver.list_cached_packages() - - if packages_dict['local']: - console.print("\n[bold cyan]Local Project Cache (./.prompd/cache/):[/bold cyan]") - for pkg in packages_dict['local']: - console.print(f" {pkg.to_string()}") - - if packages_dict['global']: - console.print("\n[bold green]Global Cache (~/.cache/prompd/):[/bold green]") - for pkg in packages_dict['global']: - console.print(f" {pkg.to_string()}") - - if not packages_dict['local'] and not packages_dict['global']: - console.print("[yellow]No cached packages found[/yellow]") - - except Exception as e: - console.print(f"[red]Failed to list cache:[/red] {e}") - sys.exit(1) - - -@cache.command('clear') -@click.option('--global', 'clear_global', is_flag=True, help='Clear global cache') -@click.option('--local', 'clear_local', is_flag=True, help='Clear local cache') -@click.option('--all', 'clear_all', is_flag=True, help='Clear both caches') -def clear_cache(clear_global: bool, clear_local: bool, clear_all: bool): - """Clear package cache.""" - try: - from .package_resolver import PackageResolver - - resolver = PackageResolver() - - if clear_all: - clear_global = clear_local = True - elif not clear_global and not clear_local: - clear_local = True # Default to local - - resolver.clear_cache(clear_global=clear_global, clear_local=clear_local) - - cleared = [] - if clear_local: - cleared.append("local") - if clear_global: - cleared.append("global") - - console.print(f"[green]Success:[/green] Cleared {' and '.join(cleared)} cache(s)") - - except Exception as e: - console.print(f"[red]Failed to clear cache:[/red] {e}") - sys.exit(1) - - -@cli.group() -def registry(): - """Registry management commands.""" - pass - - -@registry.command('info') -@click.argument('package_name', required=True) -@click.option('--registry', help='Registry to query') -def registry_info(package_name: str, registry: Optional[str]): - """Get detailed package information.""" - try: - from .registry import RegistryClient - - client = RegistryClient(registry_name=registry) - info = client.get_package_info(package_name) - - console.print(Panel( - f"[bold cyan]{info.get('name')}[/bold cyan] v{info.get('version')}\n\n" - f"[bold]Description:[/bold] {info.get('description', 'No description')}\n" - f"[bold]Author:[/bold] {info.get('author', 'Unknown')}\n" - f"[bold]License:[/bold] {info.get('license', 'Unknown')}\n" - f"[bold]Homepage:[/bold] {info.get('homepage', 'None')}\n" - f"[bold]Downloads:[/bold] {info.get('downloads', 0):,}\n" - f"[bold]Published:[/bold] {info.get('published_at', 'Unknown')}\n\n" - f"[bold]Tags:[/bold] {', '.join(info.get('tags', []))}\n" - f"[bold]Dependencies:[/bold] {len(info.get('dependencies', {}))}\n", - title=f"Package Information", - border_style="blue" - )) - - if info.get('dependencies'): - console.print("\n[bold]Dependencies:[/bold]") - for dep, version in info.get('dependencies', {}).items(): - console.print(f" {dep}: {version}") - - except Exception as e: - console.print(f"[red]Failed to get package info:[/red] {e}") - sys.exit(1) - - -@cli.command('deps') -@click.argument('package', required=False) -@click.option('--tree', is_flag=True, help='Show dependency tree') -@click.option('--conflicts', is_flag=True, help='Show version conflicts') -@click.option('--dev', is_flag=True, help='Include dev dependencies') -@click.option('--peer', is_flag=True, help='Include peer dependencies') -@click.option('--depth', default=3, help='Maximum tree depth to display') -def dependencies(package: Optional[str], tree: bool, conflicts: bool, dev: bool, peer: bool, depth: int): - """Analyze package dependencies.""" - from .dependency_resolver import DependencyResolver - - console = Console() - - # Use current directory package if not specified - if not package: - config_file = Path.cwd() / '.prompd' / 'config.yaml' - if config_file.exists(): - import yaml - with open(config_file) as f: - config = yaml.safe_load(f) - package = f"{config.get('name', 'unknown')}@{config.get('version', 'latest')}" - else: - console.print("[red]No package specified and no .prompd/config.yaml found[/red]") - sys.exit(1) - - try: - resolver = DependencyResolver() - - with console.status(f"[bold green]Resolving dependencies for {package}..."): - resolved = resolver.resolve(package, dev_dependencies=dev, peer_dependencies=peer) - - if tree: - # Show dependency tree - tree_str = resolver.get_dependency_tree() - console.print(Panel(tree_str, title="Dependency Tree", border_style="green")) - - if conflicts: - # Show conflicts - conflicts_list = resolver.find_conflicts() - if conflicts_list: - console.print("\n[bold red]Version Conflicts Found:[/bold red]") - for conflict in conflicts_list: - console.print(f"\n {conflict['package']}:") - console.print(f" Resolved: {conflict['resolved_version']}") - for c in conflict['conflicts']: - console.print(f" - {c['requester']} requires {c['constraint']}") - else: - console.print("[green]No version conflicts found[/green]") - - if not tree and not conflicts: - # Default: show summary - console.print(f"\n[bold]Dependencies for {package}:[/bold]") - console.print(f"Total packages: {len(resolved)}") - - # Group by depth - by_depth = {} - for node in resolved.values(): - if node.depth not in by_depth: - by_depth[node.depth] = [] - by_depth[node.depth].append(node) - - for d in sorted(by_depth.keys())[:depth]: - if d == 0: - console.print(f"\n[bold]Root package:[/bold]") - else: - console.print(f"\n[bold]Depth {d} dependencies:[/bold]") - - for node in by_depth[d]: - console.print(f" - {node.name}@{node.resolved_version}") - - except Exception as e: - console.print(f"[red]Dependency resolution failed:[/red] {e}") - sys.exit(1) - - -@cli.command('deps-install') -@click.argument('package') -@click.option('--save', is_flag=True, help='Save to dependencies') -@click.option('--save-dev', is_flag=True, help='Save to dev dependencies') -@click.option('--target', type=click.Path(), help='Installation directory') -@click.option('--parallel/--sequential', default=True, help='Parallel installation') -def install_dependencies(package: str, save: bool, save_dev: bool, target: Optional[str], parallel: bool): - """Install package with all dependencies.""" - from .dependency_resolver import DependencyResolver - - console = Console() - - try: - resolver = DependencyResolver() - - # Resolve dependencies - with console.status(f"[bold green]Resolving dependencies for {package}..."): - resolved = resolver.resolve(package, dev_dependencies=save_dev) - - console.print(f"[green]Resolved {len(resolved)} packages[/green]") - - # Install all dependencies - target_dir = Path(target) if target else Path.cwd() / '.prompd' / 'packages' - - with console.status(f"[bold green]Installing {len(resolved)} packages..."): - installed = resolver.install_all(target_dir, parallel=parallel) - - console.print(f"[green]Successfully installed {len(installed)} packages to {target_dir}[/green]") - - # Generate lock file - lock_data = resolver.generate_lock_file() - lock_file = Path.cwd() / '.prompd' / 'lock.json' - lock_file.parent.mkdir(parents=True, exist_ok=True) - - with open(lock_file, 'w') as f: - json.dump(lock_data, f, indent=2) - - console.print(f"[green]Lock file saved to {lock_file}[/green]") - - # Update project config if --save or --save-dev - if save or save_dev: - from .package_resolver import PackageResolver - resolver_inst = PackageResolver() - config = resolver_inst.get_or_create_project_config() - - ref = PackageReference.parse(package) - dep_name = ref.to_string().split('@')[0] - - if save: - config.dependencies[dep_name] = ref.version - elif save_dev: - config.dev_dependencies[dep_name] = ref.version - - resolver_inst.save_project_config(config) - console.print(f"[green]Updated project configuration[/green]") - - except Exception as e: - console.print(f"[red]Installation failed:[/red] {e}") - sys.exit(1) - - -@cli.command('deps-update') -@click.option('--dry-run', is_flag=True, help='Show what would be updated') -@click.option('--latest', is_flag=True, help='Update to latest versions') -def update_dependencies(dry_run: bool, latest: bool): - """Update all dependencies to latest compatible versions.""" - from .dependency_resolver import DependencyResolver - from .package_resolver import PackageResolver - - console = Console() - - try: - # Load current project config - resolver_inst = PackageResolver() - config = resolver_inst.get_or_create_project_config() - - if not config.dependencies: - console.print("[yellow]No dependencies to update[/yellow]") - return - - updates = [] - - for dep_name, current_version in config.dependencies.items(): - # Check for newer versions - try: - package_info = resolver_inst.registries[resolver_inst.registry_urls[0]].get_package_info(dep_name) - available_versions = package_info.get('versions', {}).keys() - - if latest: - # Get absolute latest version - latest_version = max(available_versions) - else: - # Get latest compatible version - from .dependency_resolver import VersionConstraint - constraint = VersionConstraint.parse(current_version) - compatible = [v for v in available_versions if constraint.matches(v)] - latest_version = max(compatible) if compatible else current_version - - if latest_version != current_version: - updates.append({ - 'package': dep_name, - 'current': current_version, - 'new': latest_version - }) - except Exception as e: - console.print(f"[yellow]Could not check {dep_name}: {e}[/yellow]") - - if not updates: - console.print("[green]All dependencies are up to date[/green]") - return - - # Show updates - console.print("\n[bold]Available updates:[/bold]") - for update in updates: - console.print(f" {update['package']}: {update['current']} -> {update['new']}") - - if not dry_run: - # Apply updates - for update in updates: - config.dependencies[update['package']] = update['new'] - - resolver_inst.save_project_config(config) - console.print(f"\n[green]Updated {len(updates)} dependencies in config[/green]") - console.print("[yellow]Run 'prompd deps-install' to install updated versions[/yellow]") - else: - console.print("\n[yellow]Dry run - no changes made[/yellow]") - - except Exception as e: - console.print(f"[red]Update check failed:[/red] {e}") - sys.exit(1) - - -# Package operations - imports moved to function level for faster startup - - -@cli.group() -def package(): - """Package management commands.""" - pass - - -@package.command('create') -@click.argument('source', type=click.Path(exists=True, path_type=Path)) -@click.argument('output_path', type=click.Path(path_type=Path), required=False) -@click.option('-n', '--name', help='Package name (overrides manifest.json)') -@click.option('-V', '--version', help='Package version (overrides manifest.json)') -@click.option('-d', '--description', help='Package description (overrides manifest.json)') -@click.option('-a', '--author', help='Package author (overrides manifest.json)') -def package_create(source: Path, output_path: Optional[Path], name: Optional[str], version: Optional[str], description: Optional[str], author: Optional[str]): - """Create a .pdpkg package from a directory. Uses manifest.json if present, smart defaults otherwise.""" - try: - from prompd.registry import create_pdpkg, validate_pdpkg - # Source must be a directory (no longer support .pdproj files) - if not source.is_dir(): - console.print("[red]ERROR[/red] Source must be a directory") - sys.exit(1) - - source_dir = source - - # Check for existing manifest.json in the directory - manifest_path = source_dir / 'manifest.json' - manifest_data = {} - - if manifest_path.exists(): - # Load existing manifest.json - try: - import json - with open(manifest_path, 'r', encoding='utf-8') as f: - manifest_data = json.load(f) - console.print(f"[dim]Found existing manifest.json[/dim]") - except (json.JSONDecodeError, Exception) as e: - console.print(f"[yellow]Warning: Could not read manifest.json: {e}[/yellow]") - manifest_data = {} - - # Generate smart defaults with CLI overrides taking precedence - proj_name = name or manifest_data.get('name', source_dir.name.lower().replace(' ', '-').replace('_', '-')) - proj_version = version or manifest_data.get('version', '1.0.0') - proj_description = description or manifest_data.get('description', f'Package created from {source_dir.name}') - proj_author = author or manifest_data.get('author', 'unknown') - - # Default output path - if not output_path: - output_path = source_dir / f"{proj_name}-{proj_version}.pdpkg" - - # Ensure output has .pdpkg extension - if not output_path.suffix: - output_path = output_path.with_suffix('.pdpkg') - elif output_path.suffix != '.pdpkg': - output_path = output_path.with_suffix('.pdpkg') - - # Create manifest (matches backend PdpkgManifestSchema) - manifest = { - 'name': proj_name, - 'version': proj_version, - 'description': proj_description, - 'license': 'MIT', - 'tags': [], - 'dependencies': {}, - 'keywords': [] - } - - if proj_author: - manifest['author'] = proj_author - - # Find .prmd and .pdflow files (ensure they are actual files, not directories) - prompd_files = [f for f in source_dir.glob('**/*.prmd') if f.is_file()] - pdflow_files = [f for f in source_dir.glob('**/*.pdflow') if f.is_file()] - - if prompd_files: - # Set main file (first .prmd file found) - main_file = str(prompd_files[0].relative_to(source_dir)).replace('\\', '/') - manifest['main'] = main_file - - # If there are additional .prmd files, add them to files array - if len(prompd_files) > 1: - additional_files = [str(f.relative_to(source_dir)).replace('\\', '/') for f in prompd_files[1:]] - manifest['files'] = additional_files - - if pdflow_files: - manifest['workflows'] = [str(f.relative_to(source_dir)).replace('\\', '/') for f in pdflow_files] - - # Create package - create_pdpkg(source_dir, output_path, manifest) - - console.print(f"[bold green]Package created successfully![/bold green]") - console.print(f" Package: [cyan]{output_path}[/cyan]") - console.print(f" Size: {output_path.stat().st_size / 1024:.1f} KB") - - # Validate the created package - validate_pdpkg(output_path) - console.print("[green]Package validation passed[/green]") - - except Exception as e: - console.print(f"[bold red]Package creation failed:[/bold red] {e}") - sys.exit(1) - - -@package.command('validate') -@click.argument('package_path', type=click.Path(exists=True, path_type=Path)) -def package_validate(package_path: Path): - """Validate a .pdpkg package archive.""" - try: - from prompd.package_validator import validate_package - # Check file extension - only accept package archives - if not package_path.name.endswith('.pdpkg'): - console.print(f"[red]ERROR[/red] [bold red]Invalid package format![/bold red]") - console.print(f" File: {package_path.name}") - console.print(" Expected: .pdpkg archive file") - console.print(" Note: .prmd files are individual prompts, not packages") - console.print(" Use 'prompd validate' to validate individual .prmd files") - sys.exit(1) - - console.print(f"[blue]INFO[/blue] Validating package: [cyan]{package_path.name}[/cyan]") - - result = validate_package(package_path) - - if result.is_valid: - console.print("[green]SUCCESS[/green] [bold green]Package validation passed![/bold green]") - - # Show package info if available - if result.package_info: - info = result.package_info - console.print(f" Package: [cyan]{info.get('name', 'unknown')}[/cyan]") - console.print(f" Version: [green]{info.get('version', 'unknown')}[/green]") - console.print(f" Description: {info.get('description', 'No description')}") - - if 'parameters' in info: - console.print(f" Parameters: {len(info['parameters'])}") - else: - console.print("[red]ERROR[/red] [bold red]Package validation failed![/bold red]") - - for error in result.errors: - console.print(f" - [red]{error}[/red]") - - # Show warnings if any - if result.warnings: - console.print("\n[yellow]WARNINGS:[/yellow]") - for warning in result.warnings: - console.print(f" - [yellow]{warning}[/yellow]") - - if not result.is_valid: - sys.exit(1) - - except Exception as e: - console.print(f"[red]ERROR[/red] [bold red]Validation failed:[/bold red] {e}") - sys.exit(1) - - -# Alias for package create -@cli.command('pack') -@click.argument('source', type=click.Path(exists=True, path_type=Path)) -@click.argument('output_path', type=click.Path(path_type=Path), required=False) -@click.option('-n', '--name', help='Package name (overrides manifest.json)') -@click.option('-V', '--version', help='Package version (overrides manifest.json)') -@click.option('-d', '--description', help='Package description (overrides manifest.json)') -@click.option('-a', '--author', help='Package author (overrides manifest.json)') -def pack_alias(source: Path, output_path: Optional[Path], name: Optional[str], version: Optional[str], description: Optional[str], author: Optional[str]): - """Create a .pdpkg package from a directory (alias for 'package create').""" - # Call the same logic as package create directly - package_create.callback(source, output_path, name, version, description, author) - - -@cli.command("create") -@click.argument("file", type=click.Path(path_type=Path)) -@click.option("-i", "--interactive", is_flag=True, help="Interactive mode with prompts") -@click.option("-n", "--name", help="Prompt name") -@click.option("-d", "--description", help="Prompt description") -@click.option("-a", "--author", help="Author name") -@click.option("-v", "--version", default="1.0.0", help="Version (default: 1.0.0)") -@click.option("-t", "--template", type=click.Choice(['basic', 'analysis', 'security', 'code-review', 'creative']), - help="Use a predefined template") -def create_command(file: Path, interactive: bool, name: str, description: str, - author: str, version: str, template: str): - """Create a new .prmd file""" - from prompd.commands.create import create_prmd_file - - try: - create_prmd_file( - file_path=file, - interactive=interactive, - name=name, - description=description, - author=author, - version=version, - template=template - ) - console.print(f"[green]OK[/green] Created {file}") - except Exception as e: - console.print(f"[red]Error:[/red] {e}") - sys.exit(1) - - -@cli.command() -@click.argument('path', default='.', type=click.Path(path_type=Path)) -@click.option('--name', help='Project name (default: directory name)') -@click.option('--version', default='1.0.0', help='Initial version (default: 1.0.0)') -@click.option('--description', help='Project description') -@click.option('--author', help='Project author') -def init(path: Path, name: Optional[str], version: str, description: Optional[str], author: Optional[str]): - """Initialize a new Prompd project with manifest.json.""" - from rich.console import Console - - console = Console() - - # Resolve the path - project_dir = path.resolve() - - # Create directory if it doesn't exist - if not project_dir.exists(): - project_dir.mkdir(parents=True, exist_ok=True) - console.print(f"[green]OK[/green] Created directory: {project_dir}") - - # Check if manifest.json already exists - manifest_path = project_dir / 'manifest.json' - if manifest_path.exists(): - console.print(f"[yellow]Warning:[/yellow] manifest.json already exists in {project_dir}") - if not click.confirm("Overwrite existing manifest.json?"): - console.print("[red]Aborted[/red]") - return - - # Generate smart defaults - default_name = name or project_dir.name.lower().replace(' ', '-').replace('_', '-') - default_description = description or f"Prompd project: {default_name}" - default_author = author or "unknown" - - # Create manifest.json - manifest_data = { - "name": default_name, - "version": version, - "description": default_description, - "author": default_author, - "files": [ - "*.prmd", - "*.md", - "templates/", - "docs/", - "examples/" - ], - "ignore": [ - "*.log", - "*.tmp", - ".env*" - ], - "dependencies": {}, - "devDependencies": {} - } - - # Write manifest.json - import json - with open(manifest_path, 'w', encoding='utf-8') as f: - json.dump(manifest_data, f, indent=2, ensure_ascii=False) - - console.print(f"[green]OK[/green] Created manifest.json") - console.print(f"[green]OK[/green] Initialized Prompd project: {default_name}") - - # Create a sample .prmd file if none exists - sample_prmd = project_dir / 'example.prmd' - if not any(project_dir.glob('*.prmd')): - sample_content = f"""--- -name: {default_name}-example -version: {version} -description: Example prompt for {default_name} -parameters: - name: - type: string - required: true - description: Name to greet ---- - -# Example Prompt - -Hello {{{{ name }}}}! Welcome to {default_name}. - -This is an example .prmd file to get you started. - -## Usage -```bash -prompd run example.prmd --provider openai --model gpt-4o -p name="World" -``` -""" - - with open(sample_prmd, 'w', encoding='utf-8') as f: - f.write(sample_content) - - console.print(f"[green]OK[/green] Created example.prmd") - - console.print(f"\n[bold]Project initialized![/bold]") - console.print(f" Directory: {project_dir}") - console.print(f" Name: {default_name}") - console.print(f" Version: {version}") - console.print(f"\n[dim]Next steps:[/dim]") - console.print(f" cd {project_dir.name if project_dir != Path.cwd() else '.'}") - console.print(f" prompd validate example.prmd") - console.print(f" prompd pack . -o {default_name}-{version}.pdpkg") - - -def main(): - """Main entry point.""" - cli() - - -if __name__ == "__main__": - main() +cli.add_command(mcp) +cli.add_command(git) +cli.add_command(version) +cli.add_command(namespace) +cli.add_command(ns) +cli.add_command(cache) +cli.add_command(registry) +cli.add_command(package) + +# Register stand-alone commands +cli.add_command(run) +cli.add_command(validate) +cli.add_command(list_prompts) +cli.add_command(shell_command) +cli.add_command(chat_command) +cli.add_command(compile_command) +cli.add_command(show) +cli.add_command(login) +cli.add_command(logout) +cli.add_command(install) +cli.add_command(uninstall) +cli.add_command(search) +cli.add_command(publish) +cli.add_command(versions) +cli.add_command(dependencies) +cli.add_command(install_dependencies) +cli.add_command(update_dependencies) +cli.add_command(pack_alias) +cli.add_command(create_command) +cli.add_command(init) + + +__all__ = ["cli"] diff --git a/cli/python/prompd/commands/cache.py b/cli/python/prompd/commands/cache.py new file mode 100644 index 0000000..4d5095f --- /dev/null +++ b/cli/python/prompd/commands/cache.py @@ -0,0 +1,81 @@ +"""Cache management commands for Prompd.""" +from __future__ import annotations + +from typing import Optional + +import click + +from prompd.commands.common import console + + +@click.group(name="cache") +def cache(): + """Package cache management commands.""" + pass + + +@cache.command("list") +@click.option("--global-only", is_flag=True, help="Show only global cache") +@click.option("--local-only", is_flag=True, help="Show only local cache") +def list_cache(global_only: bool, local_only: bool): + """List cached packages.""" + try: + from prompd.package_resolver import PackageResolver + + resolver = PackageResolver() + + if local_only: + packages_dict = {"local": resolver.project_cache.list_packages(), "global": []} + elif global_only: + packages_dict = {"local": [], "global": resolver.global_cache.list_packages()} + else: + packages_dict = resolver.list_cached_packages() + + if packages_dict["local"]: + console.print("\n[bold cyan]Local Project Cache (./.prompd/cache/):[/bold cyan]") + for pkg in packages_dict["local"]: + console.print(f" {pkg.to_string()}") + + if packages_dict["global"]: + console.print("\n[bold green]Global Cache (~/.cache/prompd/):[/bold green]") + for pkg in packages_dict["global"]: + console.print(f" {pkg.to_string()}") + + if not packages_dict["local"] and not packages_dict["global"]: + console.print("[yellow]No cached packages found[/yellow]") + except Exception as exc: + console.print(f"[red]Failed to list cache:[/red] {exc}") + raise SystemExit(1) + + +@cache.command("clear") +@click.option("--global", "clear_global", is_flag=True, help="Clear global cache") +@click.option("--local", "clear_local", is_flag=True, help="Clear local cache") +@click.option("--all", "clear_all", is_flag=True, help="Clear both caches") +def clear_cache(clear_global: bool, clear_local: bool, clear_all: bool): + """Clear package cache.""" + try: + from prompd.package_resolver import PackageResolver + + resolver = PackageResolver() + + if clear_all: + clear_global = clear_local = True + elif not clear_global and not clear_local: + clear_local = True + + resolver.clear_cache(clear_global=clear_global, clear_local=clear_local) + + cleared = [] + if clear_local: + cleared.append("local") + if clear_global: + cleared.append("global") + + console.print(f"[green]Success:[/green] Cleared {' and '.join(cleared)} cache(s)") + except Exception as exc: + console.print(f"[red]Failed to clear cache:[/red] {exc}") + raise SystemExit(1) + + +__all__ = ["cache"] diff --git a/cli/python/prompd/commands/chat.py b/cli/python/prompd/commands/chat.py new file mode 100644 index 0000000..dab33e1 --- /dev/null +++ b/cli/python/prompd/commands/chat.py @@ -0,0 +1,26 @@ +"""Chat-focused shell command for the Prompd CLI.""" +from __future__ import annotations + +import click + +from prompd.commands.common import console + + +@click.command(name="chat") +def chat_command(): + """Start the Prompd shell directly in chat mode. [BETA FEATURE]""" + try: + from prompd.shell import PrompdShell + + shell = PrompdShell() + shell.enter_chat_mode() + shell.start() + except Exception as exc: + try: + console.print(f"[red]Error launching chat:[/red] {exc}") + except Exception: + print(f"Error launching chat: {exc}") + raise SystemExit(1) + + +__all__ = ["chat_command"] diff --git a/cli/python/prompd/commands/common.py b/cli/python/prompd/commands/common.py new file mode 100644 index 0000000..fd6b937 --- /dev/null +++ b/cli/python/prompd/commands/common.py @@ -0,0 +1,17 @@ +"""Shared helpers used across CLI command modules.""" +from __future__ import annotations + +import re +from typing import Dict + +from prompd.console import console + +SEMVER_PATTERN = re.compile(r"^(\d+)\.(\d+)\.(\d+)$") + + +def is_valid_semver(version: str) -> bool: + """Return True if the provided version string matches semantic versioning.""" + return bool(SEMVER_PATTERN.match(version)) + + +__all__ = ["console", "is_valid_semver"] diff --git a/cli/python/prompd/commands/compile.py b/cli/python/prompd/commands/compile.py new file mode 100644 index 0000000..1ba9dfd --- /dev/null +++ b/cli/python/prompd/commands/compile.py @@ -0,0 +1,185 @@ +"""Implementation of the `prompd compile` command.""" +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any, Dict, Optional, Tuple + +import click + +from prompd.commands.common import console + + +@click.command(name="compile", context_settings=dict(ignore_unknown_options=True, allow_extra_args=True)) +@click.argument("source", type=str) +@click.option( + "--to", + "output_format", + default="markdown", + help="Output format (markdown | provider-json [openai|anthropic] | provider-json:openai)", +) +@click.option("--to-markdown", is_flag=True, help="Shorthand for --to markdown") +@click.option( + "--to-provider-json", + type=click.Choice(["openai", "anthropic"]), + help="Shorthand for --to provider-json ", +) +@click.option("-p", "--param", multiple=True, help="Parameter in format key=value (repeat for multiple)") +@click.option( + "-f", + "--params-file", + type=click.Path(exists=True, path_type=Path), + multiple=True, + help="Load parameters from JSON file (repeatable)", +) +@click.option("-o", "--output", type=click.Path(), help="Write compiled output to file") +@click.option("-v", "--verbose", is_flag=True, help="Verbose output") +@click.pass_context +def compile_command( + ctx, + source: str, + output_format: str, + to_markdown: bool, + to_provider_json: Optional[str], + param: Tuple[str, ...], + params_file: Tuple[Path, ...], + output: Optional[str], + verbose: bool, +): + """Compile a .prmd file or package reference to a target format.""" + try: + source_path = Path(source) + package_pattern = r"^(@[\w.-]+/[\w.-]+|[\w.-]+)@([\w.-]+)/(.+\.prmd)$" + match = re.match(package_pattern, source) + + if match: + package_ref = f"{match.group(1)}@{match.group(2)}" + file_path_in_package = match.group(3) + + if verbose: + console.print(f"[cyan]Resolving package:[/cyan] {package_ref}") + console.print(f"[cyan]File path:[/cyan] {file_path_in_package}") + + from prompd.package_resolver import PackageResolver + + resolver = PackageResolver() + try: + package_path = resolver.resolve_package(package_ref) + source_path = package_path / file_path_in_package + + if not source_path.exists(): + console.print(f"[red]File not found in package:[/red] {file_path_in_package}") + console.print(f"[yellow]Package location:[/yellow] {package_path}") + raise SystemExit(1) + + if verbose: + console.print(f"[green]Resolved to:[/green] {source_path}") + except Exception as exc: + console.print(f"[red]Failed to resolve package:[/red] {exc}") + raise SystemExit(1) + elif not source_path.exists(): + if "@" in source and "/" not in source.split("@")[-1]: + from prompd.package_resolver import PackageResolver + + resolver = PackageResolver() + try: + package_path = resolver.resolve_package(source) + manifest_file = package_path / "manifest.json" + if manifest_file.exists(): + with open(manifest_file, "r", encoding="utf-8") as mf: + manifest = json.load(mf) + main_file = manifest.get("main") + if main_file: + source_path = package_path / main_file + if verbose: + console.print(f"[green]Using main file:[/green] {main_file}") + except Exception: + pass + + if not source_path.exists(): + console.print(f"[red]File not found:[/red] {source}") + raise SystemExit(1) + + parameters: Dict[str, Any] = {} + if params_file: + for pf in params_file: + try: + data = json.loads(Path(pf).read_text(encoding="utf-8")) + if isinstance(data, dict): + parameters.update(data) + except Exception as exc: + console.print(f"[red]Error loading params file {pf}:[/red] {exc}") + raise SystemExit(1) + + if param: + for kv in param: + if "=" not in kv: + console.print(f"[red]Invalid parameter:[/red] {kv}. Use key=value") + raise SystemExit(1) + key, value = kv.split("=", 1) + + try: + if value.strip().startswith(("{", "[")): + parameters[key] = json.loads(value) + elif value.lower() in ("true", "false"): + parameters[key] = value.lower() == "true" + elif value.replace(".", "").replace("-", "").isdigit() and value.count(".") <= 1: + parameters[key] = float(value) if "." in value else int(value) + else: + parameters[key] = value + except json.JSONDecodeError: + parameters[key] = value + + if to_markdown: + output_format = "markdown" + elif to_provider_json: + output_format = f"provider-json:{to_provider_json}" + else: + extra = list(getattr(ctx, "args", []) or []) + if output_format.strip().lower() == "provider-json" and extra: + next_tok = extra[0] + if next_tok and not next_tok.startswith("-"): + output_format = f"provider-json:{next_tok}" + try: + ctx.args = extra[1:] + except Exception: + pass + + if verbose: + try: + console.print( + f"[dim]Compiling {source} -> {output_format} with params: {list(parameters.keys())}[/dim]" + ) + except Exception: + pass + + from prompd.compiler import PrompdCompiler + + compiler = PrompdCompiler() + result = compiler.compile( + source=source, + output_format=output_format, + parameters=parameters, + output_file=Path(output) if output else None, + verbose=verbose, + ) + + if output: + try: + console.print(f"[green]OK[/green] Compiled output written to {output}") + except Exception: + print(f"OK - Compiled output written to {output}") + else: + print(result) + except SystemExit: + raise + except Exception as exc: + try: + console.print(f"[red]Error compiling:[/red] {exc}") + except Exception: + print(f"Error compiling: {exc}") + raise SystemExit(1) + + +__all__ = ["compile_command"] diff --git a/cli/python/prompd/commands/create.py b/cli/python/prompd/commands/create.py index 20e8a30..518f608 100644 --- a/cli/python/prompd/commands/create.py +++ b/cli/python/prompd/commands/create.py @@ -328,4 +328,51 @@ def get_template_body(template: Optional[str], name: str, description: str, para # Get template or use basic template_body = templates.get(template, templates['basic']) - return template_body \ No newline at end of file + return template_body + + +@click.command(name="create") +@click.argument("file", type=click.Path(path_type=Path)) +@click.option("-i", "--interactive", is_flag=True, help="Interactive mode with prompts") +@click.option("-n", "--name", help="Prompt name") +@click.option("-d", "--description", help="Prompt description") +@click.option("-a", "--author", help="Author name") +@click.option("-v", "--version", default="1.0.0", help="Version (default: 1.0.0)") +@click.option( + "-t", + "--template", + type=click.Choice(["basic", "analysis", "security", "code-review", "creative"]), + help="Use a predefined template", +) +def create_command( + file: Path, + interactive: bool, + name: Optional[str], + description: Optional[str], + author: Optional[str], + version: str, + template: Optional[str], +): + """Create a new .prmd file.""" + try: + create_prmd_file( + file_path=file, + interactive=interactive, + name=name, + description=description, + author=author, + version=version, + template=template, + ) + console.print(f"[green]OK[/green] Created {file}") + except Exception as exc: + console.print(f"[red]Error:[/red] {exc}") + raise SystemExit(1) + + +__all__ = [ + "create_prmd_file", + "generate_prmd_content", + "get_template_body", + "create_command", +] diff --git a/cli/python/prompd/commands/deps.py b/cli/python/prompd/commands/deps.py new file mode 100644 index 0000000..44ca92c --- /dev/null +++ b/cli/python/prompd/commands/deps.py @@ -0,0 +1,78 @@ +"""Dependency analysis command for Prompd.""" +from __future__ import annotations + +import json +from pathlib import Path +from typing import Optional + +import click +from rich.panel import Panel + +from prompd.commands.common import console + + +@click.command(name="deps") +@click.argument("package", required=False) +@click.option("--tree", is_flag=True, help="Show dependency tree") +@click.option("--conflicts", is_flag=True, help="Show version conflicts") +@click.option("--dev", is_flag=True, help="Include dev dependencies") +@click.option("--peer", is_flag=True, help="Include peer dependencies") +@click.option("--depth", default=3, help="Maximum tree depth to display") +def dependencies(package: Optional[str], tree: bool, conflicts: bool, dev: bool, peer: bool, depth: int): + """Analyze package dependencies.""" + from prompd.dependency_resolver import DependencyResolver + + if not package: + config_file = Path.cwd() / ".prompd" / "config.yaml" + if config_file.exists(): + import yaml + + with open(config_file, "r", encoding="utf-8") as cfg: + config = yaml.safe_load(cfg) + package = f"{config.get('name', 'unknown')}@{config.get('version', 'latest')}" + else: + console.print("[red]No package specified and no .prompd/config.yaml found[/red]") + raise SystemExit(1) + + try: + resolver = DependencyResolver() + + with console.status(f"[bold green]Resolving dependencies for {package}..."): + resolved = resolver.resolve(package, dev_dependencies=dev, peer_dependencies=peer) + + if tree: + tree_str = resolver.get_dependency_tree() + console.print(Panel(tree_str, title="Dependency Tree", border_style="green")) + + if conflicts: + conflicts_list = resolver.find_conflicts() + if conflicts_list: + console.print("\n[bold red]Version Conflicts Found:[/bold red]") + for conflict in conflicts_list: + console.print(f"\n {conflict['package']}:") + console.print(f" Resolved: {conflict['resolved_version']}") + for c in conflict["conflicts"]: + console.print(f" - {c['requester']} requires {c['constraint']}") + else: + console.print("[green]No version conflicts found[/green]") + + if not tree and not conflicts: + console.print(f"\n[bold]Dependencies for {package}:[/bold]") + console.print(f"Total packages: {len(resolved)}") + + by_depth = {} + for node in resolved.values(): + by_depth.setdefault(node.depth, []).append(node) + + for level in sorted(by_depth.keys())[:depth]: + heading = "Root package" if level == 0 else f"Depth {level} dependencies" + console.print(f"\n[bold]{heading}:[/bold]") + + for node in by_depth[level]: + console.print(f" - {node.name}@{node.resolved_version}") + except Exception as exc: + console.print(f"[red]Dependency resolution failed:[/red] {exc}") + raise SystemExit(1) + + +__all__ = ["dependencies"] diff --git a/cli/python/prompd/commands/deps_install.py b/cli/python/prompd/commands/deps_install.py new file mode 100644 index 0000000..4ce7708 --- /dev/null +++ b/cli/python/prompd/commands/deps_install.py @@ -0,0 +1,67 @@ +"""Install dependencies command for Prompd.""" +from __future__ import annotations + +import json +from pathlib import Path +from typing import Optional + +import click + +from prompd.commands.common import console + + +@click.command(name="deps-install") +@click.argument("package") +@click.option("--save", is_flag=True, help="Save to dependencies") +@click.option("--save-dev", is_flag=True, help="Save to dev dependencies") +@click.option("--target", type=click.Path(), help="Installation directory") +@click.option("--parallel/--sequential", default=True, help="Parallel installation") +def install_dependencies(package: str, save: bool, save_dev: bool, target: Optional[str], parallel: bool): + """Install package with all dependencies.""" + from prompd.dependency_resolver import DependencyResolver + from prompd.package_resolver import PackageResolver, PackageReference + + try: + resolver = DependencyResolver() + + with console.status(f"[bold green]Resolving dependencies for {package}..."): + resolved = resolver.resolve(package, dev_dependencies=save_dev) + + console.print(f"[green]Resolved {len(resolved)} packages[/green]") + + target_dir = Path(target) if target else Path.cwd() / ".prompd" / "packages" + + with console.status(f"[bold green]Installing {len(resolved)} packages..."): + installed = resolver.install_all(target_dir, parallel=parallel) + + console.print(f"[green]Successfully installed {len(installed)} packages to {target_dir}") + + lock_data = resolver.generate_lock_file() + lock_file = Path.cwd() / ".prompd" / "lock.json" + lock_file.parent.mkdir(parents=True, exist_ok=True) + + with open(lock_file, "w", encoding="utf-8") as lock: + json.dump(lock_data, lock, indent=2) + + console.print(f"[green]Lock file saved to {lock_file}") + + if save or save_dev: + resolver_inst = PackageResolver() + config = resolver_inst.get_or_create_project_config() + + ref = PackageReference.parse(package) + dep_name = ref.to_string().split("@")[0] + + if save: + config.dependencies[dep_name] = ref.version + elif save_dev: + config.dev_dependencies[dep_name] = ref.version + + resolver_inst.save_project_config(config) + console.print("[green]Updated project configuration[/green]") + except Exception as exc: + console.print(f"[red]Installation failed:[/red] {exc}") + raise SystemExit(1) + + +__all__ = ["install_dependencies"] diff --git a/cli/python/prompd/commands/deps_update.py b/cli/python/prompd/commands/deps_update.py new file mode 100644 index 0000000..bd8d624 --- /dev/null +++ b/cli/python/prompd/commands/deps_update.py @@ -0,0 +1,70 @@ +"""Update dependencies command for Prompd.""" +from __future__ import annotations + +from typing import List + +import click + +from prompd.commands.common import console + + +@click.command(name="deps-update") +@click.option("--dry-run", is_flag=True, help="Show what would be updated") +@click.option("--latest", is_flag=True, help="Update to latest versions") +def update_dependencies(dry_run: bool, latest: bool): + """Update all dependencies to latest compatible versions.""" + from prompd.dependency_resolver import DependencyResolver, VersionConstraint + from prompd.package_resolver import PackageResolver + + try: + resolver_inst = PackageResolver() + config = resolver_inst.get_or_create_project_config() + + if not config.dependencies: + console.print("[yellow]No dependencies to update[/yellow]") + return + + updates: List[dict] = [] + + for dep_name, current_version in config.dependencies.items(): + try: + package_info = ( + resolver_inst.registries[resolver_inst.registry_urls[0]].get_package_info(dep_name) + ) + available_versions = package_info.get("versions", {}).keys() + + if latest: + latest_version = max(available_versions) + else: + constraint = VersionConstraint.parse(current_version) + compatible = [v for v in available_versions if constraint.matches(v)] + latest_version = max(compatible) if compatible else current_version + + if latest_version != current_version: + updates.append({"package": dep_name, "current": current_version, "new": latest_version}) + except Exception as exc: + console.print(f"[yellow]Could not check {dep_name}: {exc}[/yellow]") + + if not updates: + console.print("[green]All dependencies are up to date[/green]") + return + + console.print("\n[bold]Available updates:[/bold]") + for update in updates: + console.print(f" {update['package']}: {update['current']} -> {update['new']}") + + if not dry_run: + for update in updates: + config.dependencies[update["package"]] = update["new"] + + resolver_inst.save_project_config(config) + console.print(f"\n[green]Updated {len(updates)} dependencies in config[/green]") + console.print("[yellow]Run 'prompd deps-install' to install updated versions[/yellow]") + else: + console.print("\n[yellow]Dry run - no changes made[/yellow]") + except Exception as exc: + console.print(f"[red]Update check failed:[/red] {exc}") + raise SystemExit(1) + + +__all__ = ["update_dependencies"] diff --git a/cli/python/prompd/commands/git.py b/cli/python/prompd/commands/git.py new file mode 100644 index 0000000..24ccacb --- /dev/null +++ b/cli/python/prompd/commands/git.py @@ -0,0 +1,215 @@ +"""Git-related commands for the Prompd CLI.""" +from __future__ import annotations + +import subprocess +from pathlib import Path +from typing import Optional, Tuple + +import click + +from prompd.commands.common import console, is_valid_semver +from prompd.security import SecurityError, validate_git_file_path, validate_git_message + + +@click.group(name="git") +def git(): + """Git operations for .prmd files.""" + pass + + +@git.command("add") +@click.argument("files", nargs=-1, required=True, type=click.Path(exists=True, path_type=Path)) +@click.option("--verbose", "-v", is_flag=True, help="Show git output") +def git_add(files: Tuple[Path, ...], verbose: bool): + """Add .prmd files to git staging area.""" + try: + for file_path in files: + file_path = Path(file_path) + if file_path.suffix != ".prmd": + console.print(f"[yellow]Skipping non-.prmd file:[/yellow] {file_path}") + continue + + result = subprocess.run(["git", "add", str(file_path)], capture_output=True, text=True, check=True) + + console.print(f"[green]OK[/green] Added {file_path}") + if verbose and result.stdout: + console.print(f"[dim]{result.stdout}[/dim]") + except subprocess.CalledProcessError as exc: + console.print(f"[red]Error adding files:[/red] {exc.stderr}") + raise SystemExit(1) + + +@git.command("remove") +@click.argument("files", nargs=-1, required=True, type=click.Path(exists=True, path_type=Path)) +@click.option("--cached", is_flag=True, help="Only remove from index, keep in working directory") +@click.option("--verbose", "-v", is_flag=True, help="Show git output") +def git_remove(files: Tuple[Path, ...], cached: bool, verbose: bool): + """Remove .prmd files from git tracking.""" + try: + for file_path in files: + file_path = Path(file_path) + if file_path.suffix != ".prmd": + console.print(f"[yellow]Skipping non-.prmd file:[/yellow] {file_path}") + continue + + cmd = ["git", "rm"] + if cached: + cmd.append("--cached") + cmd.append(str(file_path)) + + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + + action = "Removed from index" if cached else "Removed" + console.print(f"[green]OK[/green] {action}: {file_path}") + if verbose and result.stdout: + console.print(f"[dim]{result.stdout}[/dim]") + except subprocess.CalledProcessError as exc: + console.print(f"[red]Error removing files:[/red] {exc.stderr}") + raise SystemExit(1) + + +@git.command("status") +@click.option("--path", "-p", type=click.Path(exists=True, path_type=Path), help="Check status for specific path") +def git_status(path: Optional[Path]): + """Show git status for .prmd files.""" + try: + cmd = ["git", "status", "--short"] + if path: + cmd.append(str(path)) + + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + + if not result.stdout: + console.print("[green]No changes to .prmd files[/green]") + return + + prompd_changes = [line for line in result.stdout.strip().split("\n") if ".prmd" in line] + + if prompd_changes: + console.print("[bold]Git status for .prmd files:[/bold]") + for change in prompd_changes: + status_code = change[:2] + file_path = change[3:] + + if "M" in status_code: + status_color = "yellow" + status_text = "Modified" + elif "A" in status_code: + status_color = "green" + status_text = "Added" + elif "D" in status_code: + status_color = "red" + status_text = "Deleted" + elif "?" in status_code: + status_color = "blue" + status_text = "Untracked" + else: + status_color = "white" + status_text = status_code + + console.print(f" [{status_color}]{status_text:10}[/{status_color}] {file_path}") + else: + console.print("[dim]No .prmd file changes[/dim]") + except subprocess.CalledProcessError as exc: + console.print(f"[red]Error checking status:[/red] {exc.stderr}") + raise SystemExit(1) + + +@git.command("commit") +@click.option("--message", "-m", required=True, help="Commit message") +@click.option("--all", "-a", is_flag=True, help="Automatically stage all modified .prmd files") +def git_commit(message: str, all: bool): + """Commit staged .prmd files.""" + try: + if all: + result = subprocess.run( + ["git", "status", "--porcelain"], + capture_output=True, + text=True, + check=True, + ) + + for line in result.stdout.strip().split("\n"): + if line and ".prmd" in line and line[0] == " " and line[1] == "M": + file_path = line[3:] + try: + safe_path = validate_git_file_path(file_path) + subprocess.run(["git", "add", safe_path], check=True) + console.print(f"[dim]Auto-staging: {safe_path}[/dim]") + except SecurityError as exc: + console.print(f"[red]Security warning: Skipping unsafe file path: {exc}[/red]") + continue + + try: + safe_message = validate_git_message(message) + except SecurityError as exc: + console.print(f"[red]Error: Invalid commit message: {exc}[/red]") + raise click.Abort() + + result = subprocess.run( + ["git", "commit", "-m", safe_message], + capture_output=True, + text=True, + check=True, + ) + + console.print(f"[green]OK[/green] Committed changes") + if result.stdout: + for line in result.stdout.strip().split("\n"): + if "file" in line and "changed" in line: + console.print(f"[dim]{line}[/dim]") + except subprocess.CalledProcessError as exc: + if exc.stdout and "nothing to commit" in exc.stdout: + console.print("[yellow]Nothing to commit[/yellow]") + else: + console.print(f"[red]Error committing:[/red] {exc.stderr}") + raise SystemExit(1) + + +@git.command("checkout") +@click.argument("file", type=click.Path(path_type=Path)) +@click.argument("version") +@click.option("--output", "-o", type=click.Path(), help="Output to different file instead of overwriting") +def git_checkout(file: Path, version: str, output: Optional[str]): + """Checkout a specific version of a .prmd file.""" + try: + if file.suffix != ".prmd": + console.print(f"[red]Error:[/red] {file} is not a .prmd file") + raise SystemExit(1) + + if is_valid_semver(version): + tag_name = f"{file.stem}-v{version}" + tag_check = subprocess.run(["git", "tag", "-l", tag_name], capture_output=True, text=True) + version_ref = tag_name if tag_check.stdout.strip() else version + else: + version_ref = version + + git_path = str(file).replace("\\", "/") + result = subprocess.run( + ["git", "show", f"{version_ref}:{git_path}"], + capture_output=True, + text=True, + check=True, + ) + + content = result.stdout + + if output: + output_path = Path(output) + output_path.write_text(content, encoding="utf-8") + console.print(f"[green]OK[/green] Checked out {file} @ {version} to {output_path}") + else: + file.write_text(content, encoding="utf-8") + console.print(f"[green]OK[/green] Checked out {file} @ {version}") + console.print("[yellow]Note:[/yellow] Working directory has been modified. Use 'git diff' to see changes.") + except subprocess.CalledProcessError as exc: + stderr = exc.stderr or "" + if "does not exist" in stderr: + console.print(f"[red]Error:[/red] Version '{version}' not found for {file}") + console.print("[dim]Try 'prompd version history' to see available versions[/dim]") + else: + console.print(f"[red]Error checking out version:[/red] {stderr}") + raise SystemExit(1) + + +__all__ = ["git"] diff --git a/cli/python/prompd/commands/init.py b/cli/python/prompd/commands/init.py new file mode 100644 index 0000000..c96150e --- /dev/null +++ b/cli/python/prompd/commands/init.py @@ -0,0 +1,90 @@ +"""Project initialisation command for Prompd.""" +from __future__ import annotations + +import json +from pathlib import Path +from typing import Optional + +import click +from rich.console import Console + + +@click.command(name="init") +@click.argument("path", default=".", type=click.Path(path_type=Path)) +@click.option("--name", help="Project name (default: directory name)") +@click.option("--version", default="1.0.0", help="Initial version (default: 1.0.0)") +@click.option("--description", help="Project description") +@click.option("--author", help="Project author") +def init(path: Path, name: Optional[str], version: str, description: Optional[str], author: Optional[str]): + """Initialize a new Prompd project with manifest.json.""" + console = Console() + + project_dir = path.resolve() + + if not project_dir.exists(): + project_dir.mkdir(parents=True, exist_ok=True) + console.print(f"[green]OK[/green] Created directory: {project_dir}") + + manifest_path = project_dir / "manifest.json" + if manifest_path.exists(): + console.print(f"[yellow]Warning:[/yellow] manifest.json already exists in {project_dir}") + if not click.confirm("Overwrite existing manifest.json?"): + console.print("[red]Aborted[/red]") + return + + default_name = name or project_dir.name.lower().replace(" ", "-").replace("_", "-") + default_description = description or f"Prompd project: {default_name}" + default_author = author or "unknown" + + manifest_data = { + "name": default_name, + "version": version, + "description": default_description, + "author": default_author, + "files": [ + "*.prmd", + "*.md", + "templates/", + "docs/", + "examples/", + ], + "ignore": ["*.log", "*.tmp", ".env*"], + "dependencies": {}, + "devDependencies": {}, + } + + with open(manifest_path, "w", encoding="utf-8") as manifest_file: + json.dump(manifest_data, manifest_file, indent=2, ensure_ascii=False) + + console.print(f"[green]OK[/green] Created manifest.json") + console.print(f"[green]OK[/green] Initialized Prompd project: {default_name}") + + sample_prmd = project_dir / "example.prmd" + if not any(project_dir.glob("*.prmd")): + sample_content = f"""--- +name: {default_name}-example +version: {version} +description: Example prompt for {default_name} +parameters: + name: + type: string + required: true + description: Name to greet +--- + +# Example Prompt + +Hello {{{{ name }}}}! Welcome to {default_name}. + +This is an example .prmd file to get you started. + +## Usage +```bash +prompd run example.prmd --provider openai --model gpt-4o -p name="World" +``` +""" + sample_prmd.write_text(sample_content, encoding="utf-8") + console.print(f"[green]OK[/green] Created sample prompt: {sample_prmd.name}") + + +__all__ = ["init"] diff --git a/cli/python/prompd/commands/install.py b/cli/python/prompd/commands/install.py new file mode 100644 index 0000000..81e4181 --- /dev/null +++ b/cli/python/prompd/commands/install.py @@ -0,0 +1,197 @@ +"""Install command for Prompd packages.""" +from __future__ import annotations + +import json +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from typing import Optional, Tuple + +import click + +from prompd.commands.common import console + + +@click.command(name="install") +@click.argument("packages", nargs=-1, required=False) +@click.option("-g", "--global", "global_install", is_flag=True, help="Install packages globally") +@click.option("--save", is_flag=True, default=True, help="Save to dependencies (default behavior)") +@click.option("--save-dev", is_flag=True, help="Save to development dependencies") +@click.option("--registry", help="Registry to install from") +def install( + packages: Tuple[str, ...], + global_install: bool, + save: bool, + save_dev: bool, + registry: Optional[str], +): + """Install packages from registry.""" + try: + from prompd.package_resolver import PackageResolver + + dev = save_dev + manifest_path = Path.cwd() / "manifest.json" + + if not packages: + if not manifest_path.exists(): + console.print("[yellow]No manifest.json found and no packages specified[/yellow]") + console.print("[dim]Run 'prompd install ' to create a new project[/dim]") + return + + with open(manifest_path, "r", encoding="utf-8") as f: + manifest = json.load(f) + + dependencies = manifest.get("dependencies", {}) + dev_dependencies = manifest.get("devDependencies", {}) + + if not dependencies and not dev_dependencies: + console.print("[yellow]No dependencies found in manifest.json[/yellow]") + return + + resolver = PackageResolver(registry_urls=[registry] if registry else None, global_mode=global_install) + + all_packages = [] + for package_name, version in dependencies.items(): + package_ref = f"{package_name}@{version}" if version != "latest" else package_name + all_packages.append((package_ref, False)) + + for package_name, version in dev_dependencies.items(): + package_ref = f"{package_name}@{version}" if version != "latest" else package_name + all_packages.append((package_ref, True)) + + console.print(f"[bold]Installing {len(all_packages)} packages in parallel...[/bold]\n") + + def install_single_package(package_info): + package_ref, is_dev = package_info + try: + if global_install: + package_path = resolver.install_package(package_ref, force_global=True, save_to_lock=False) + else: + resolver.add_dependency(package_ref, dev=is_dev, global_install=False) + package_path = resolver.resolve_package(package_ref) + return (package_ref, is_dev, True, str(package_path)) + except Exception as exc: + return (package_ref, is_dev, False, str(exc)) + + with ThreadPoolExecutor(max_workers=5) as executor: + results = list(executor.map(install_single_package, all_packages)) + + success_count = sum(1 for _, _, success, _ in results if success) + for package_ref, is_dev, success, result in results: + dev_tag = " (dev)" if is_dev else "" + if success: + console.print(f"[green]OK[/green] {package_ref}{dev_tag}") + else: + console.print(f"[red]FAILED[/red] {package_ref}{dev_tag}: {result}") + + console.print(f"\n[green]Successfully installed {success_count}/{len(all_packages)} packages[/green]") + return + + if not manifest_path.exists(): + project_name = Path.cwd().name.lower().replace(" ", "-") + manifest = { + "name": project_name, + "version": "1.0.0", + "description": "", + "dependencies": {}, + "devDependencies": {}, + } + console.print(f"[green]Created manifest.json for {manifest['name']}[/green]") + else: + with open(manifest_path, "r", encoding="utf-8") as f: + manifest = json.load(f) + manifest.setdefault("dependencies", {}) + manifest.setdefault("devDependencies", {}) + + resolver = PackageResolver(registry_urls=[registry] if registry else None, global_mode=global_install) + + if len(packages) > 1: + console.print(f"[bold]Installing {len(packages)} packages in parallel...[/bold]\n") + + def install_single_package(package_ref: str): + try: + if "@" in package_ref and not package_ref.startswith("@"): + package_name, package_version = package_ref.rsplit("@", 1) + else: + parts = package_ref.split("@") + if len(parts) == 3: + package_name = f"@{parts[1]}" + package_version = parts[2] + elif len(parts) == 2 and parts[0] == "": + package_name = package_ref + package_version = "latest" + else: + package_name = package_ref + package_version = "latest" + + if global_install: + package_path = resolver.install_package(package_ref, force_global=True, save_to_lock=False) + else: + resolver.add_dependency(package_ref, dev=dev, global_install=False) + package_path = resolver.resolve_package(package_ref) + + if dev: + manifest["devDependencies"][package_name] = package_version + else: + manifest["dependencies"][package_name] = package_version + + return (package_ref, package_name, package_version, True, str(package_path)) + except Exception as exc: + return (package_ref, None, None, False, str(exc)) + + with ThreadPoolExecutor(max_workers=5) as executor: + results = list(executor.map(install_single_package, packages)) + + success_count = sum(1 for _, _, _, success, _ in results if success) + for package_ref, _, _, success, result in results: + if success: + console.print(f"[green]OK[/green] {package_ref}") + console.print(f" Location: {result}") + else: + console.print(f"[red]FAILED[/red] {package_ref}: {result}") + + if success_count == len(packages): + console.print(f"\n[green]All {len(packages)} packages installed successfully[/green]") + else: + console.print(f"\n[yellow]Installed {success_count}/{len(packages)} packages[/yellow]") + else: + package_ref = packages[0] + console.print(f"Installing {package_ref} {'globally' if global_install else 'locally'}...") + + if "@" in package_ref and not package_ref.startswith("@"): + package_name, package_version = package_ref.rsplit("@", 1) + else: + parts = package_ref.split("@") + if len(parts) == 3: + package_name = f"@{parts[1]}" + package_version = parts[2] + elif len(parts) == 2 and parts[0] == "": + package_name = package_ref + package_version = "latest" + else: + package_name = package_ref + package_version = "latest" + + if global_install: + package_path = resolver.install_package(package_ref, force_global=True, save_to_lock=False) + else: + resolver.add_dependency(package_ref, dev=dev, global_install=False) + package_path = resolver.resolve_package(package_ref) + + if dev: + manifest["devDependencies"][package_name] = package_version + else: + manifest["dependencies"][package_name] = package_version + + console.print(f"[green]OK[/green] Installed {package_ref}") + console.print(f" Location: {package_path}") + + if not global_install: + with open(manifest_path, "w", encoding="utf-8") as f: + json.dump(manifest, f, indent=2) + console.print("\n[dim]Updated manifest.json and .prompd/lock.json[/dim]") + except Exception as exc: + console.print(f"[red]Installation failed:[/red] {exc}") + raise SystemExit(1) + + +__all__ = ["install"] diff --git a/cli/python/prompd/commands/list_prompts.py b/cli/python/prompd/commands/list_prompts.py new file mode 100644 index 0000000..62ef613 --- /dev/null +++ b/cli/python/prompd/commands/list_prompts.py @@ -0,0 +1,82 @@ +"""Implementation of the `prompd list` command.""" +from __future__ import annotations + +from pathlib import Path + +import click +from rich.table import Table + +from prompd.commands.common import console + + +@click.command(name="list") +@click.option( + "--path", + "-p", + type=click.Path(exists=True, path_type=Path), + default=Path("."), + help="Directory to search for .prmd files", +) +@click.option("--detailed", "-d", is_flag=True, help="Show detailed information") +@click.option("--recursive", "-r", is_flag=True, help="Search recursively in subdirectories") +def list_prompts(path: Path, detailed: bool, recursive: bool): + """List available .prmd files.""" + try: + from prompd.parser import PrompdParser + + prompd_files = list(Path(path).glob("**/*.prmd")) if recursive else list(Path(path).glob("*.prmd")) + + if not prompd_files: + console.print(f"No .prmd files found in {path}") + return + + parser = PrompdParser() + + if detailed: + from rich.panel import Panel + + for prompd_file in prompd_files: + try: + prompd = parser.parse_file(prompd_file) + metadata = prompd.metadata + + console.print( + Panel( + f"[bold]{metadata.name or prompd_file.stem}[/bold]\n" + f"[dim]File:[/dim] {prompd_file}\n" + f"[dim]Description:[/dim] {metadata.description or 'No description'}\n" + f"[dim]Version:[/dim] {metadata.version or 'N/A'}\n" + f"[dim]Variables:[/dim] {', '.join(p.name for p in metadata.parameters)}", + border_style="blue", + ) + ) + except Exception as exc: + console.print(f"[red]Error reading {prompd_file}:[/red] {exc}") + else: + table = Table(title=f"Prompd Files in {path}") + table.add_column("Name", style="cyan") + table.add_column("File", style="green") + table.add_column("Description") + + for prompd_file in prompd_files: + try: + prompd = parser.parse_file(prompd_file) + metadata = prompd.metadata + description = metadata.description or "" + if len(description) > 60: + description = description[:60] + "..." + table.add_row( + metadata.name or prompd_file.stem, + str(prompd_file), + description, + ) + except Exception: + table.add_row(prompd_file.stem, str(prompd_file), "[red]Error reading file[/red]") + + console.print(table) + except Exception as exc: + console.print(f"[red]Error listing files:[/red] {exc}") + raise SystemExit(1) + + +__all__ = ["list_prompts"] diff --git a/cli/python/prompd/commands/login.py b/cli/python/prompd/commands/login.py new file mode 100644 index 0000000..1ee209d --- /dev/null +++ b/cli/python/prompd/commands/login.py @@ -0,0 +1,42 @@ +"""Package registry login command.""" +from __future__ import annotations + +from typing import Optional + +import click + +from prompd.commands.common import console + + +@click.command(name="login") +@click.option("-k", "--api-key", help="API key for authentication") +@click.option("-u", "--username", help="Username for credential authentication") +@click.option("--password", help="Password for credential authentication") +@click.option("--registry", help="Registry to login to") +def login(api_key: Optional[str], username: Optional[str], password: Optional[str], registry: Optional[str]): + """Login to package registry.""" + try: + from prompd.registry import RegistryClient + + client = RegistryClient(registry_name=registry) + + if api_key: + result = client.login_with_token(api_key) + elif username and password: + result = client.login_with_credentials(username, password) + else: + import getpass + + username = click.prompt("Username") + password = getpass.getpass("Password: ") + result = client.login_with_credentials(username, password) + + console.print( + f"[green]Success:[/green] Logged in to {client.registry_name} as {result.get('username', 'user')}" + ) + except Exception as exc: + console.print(f"[red]Login failed:[/red] {exc}") + raise SystemExit(1) + + +__all__ = ["login"] diff --git a/cli/python/prompd/commands/logout.py b/cli/python/prompd/commands/logout.py new file mode 100644 index 0000000..6cf602e --- /dev/null +++ b/cli/python/prompd/commands/logout.py @@ -0,0 +1,27 @@ +"""Package registry logout command.""" +from __future__ import annotations + +from typing import Optional + +import click + +from prompd.commands.common import console + + +@click.command(name="logout") +@click.option("--registry", help="Registry to logout from") +def logout(registry: Optional[str]): + """Logout from package registry.""" + try: + from prompd.registry import RegistryClient + + client = RegistryClient(registry_name=registry) + client.logout() + + console.print(f"[green]Success:[/green] Logged out from {client.registry_name}") + except Exception as exc: + console.print(f"[red]Logout failed:[/red] {exc}") + raise SystemExit(1) + + +__all__ = ["logout"] diff --git a/cli/python/prompd/commands/mcp.py b/cli/python/prompd/commands/mcp.py new file mode 100644 index 0000000..197bab3 --- /dev/null +++ b/cli/python/prompd/commands/mcp.py @@ -0,0 +1,117 @@ +"""Model Context Protocol (MCP) utilities.""" +from __future__ import annotations + +from pathlib import Path +from typing import Optional + +import click + +from prompd.commands.common import console + + +@click.group() +def mcp(): + """Model Context Protocol (MCP) utilities.""" + pass + + +@mcp.command("serve") +@click.argument("path", type=click.Path(exists=True, path_type=Path)) +@click.option("--host", default="0.0.0.0", help="Bind host", show_default=True) +@click.option("--port", type=int, default=3333, help="Bind port", show_default=True) +@click.option("--oauth-client-id", default=None, help="OAuth client id") +@click.option("--auth-url", default=None, help="OAuth authorization URL") +@click.option("--token-url", default=None, help="OAuth token URL") +@click.option("--scopes", default=None, help="OAuth scopes (comma separated)") +def mcp_serve( + path: Path, + host: str, + port: int, + oauth_client_id: Optional[str], + auth_url: Optional[str], + token_url: Optional[str], + scopes: Optional[str], +): + """Serve a .prmd or .pdflow over HTTP with simple MCP-style endpoints.""" + try: + try: + from prompd.mcp_server import serve_app + except Exception as imp_err: + console.print("[red]FastAPI/uvicorn not installed.[/red] Install with: [cyan]pip install fastapi uvicorn[/cyan]") + console.print(f"[dim]{imp_err}[/dim]") + raise SystemExit(1) + + scope_list = [s.strip() for s in scopes.split(",")] if scopes else None + serve_app( + file_path=path, + host=host, + port=port, + oauth={ + "client_id": oauth_client_id, + "auth_url": auth_url, + "token_url": token_url, + "scopes": scope_list, + }, + ) + except SystemExit: + raise + except Exception as exc: + console.print(f"[red]Failed to start MCP server:[/red] {exc}") + raise SystemExit(1) + + +@mcp.command("dockerize") +@click.option("--dockerfile", default="Dockerfile.prmd-mcp", help="Output Dockerfile name", show_default=True) +@click.option("--compose", default="docker-compose.prmd-mcp.yml", help="Output docker-compose file name", show_default=True) +@click.option("--port", type=int, default=3333, help="Container port to expose", show_default=True) +def mcp_dockerize(dockerfile: str, compose: str, port: int): + """Scaffold Docker + Compose files to serve a .prmd/.pdflow via MCP.""" + try: + from textwrap import dedent + + dockerfile_content = dedent( + f""" + # Prompd MCP server image + FROM python:3.11-slim + WORKDIR /app + # Install Prompd with MCP extras from PyPI (requires published package) + RUN pip install --no-cache-dir "prompd[mcp]" + # Default env; override at runtime + ENV PROMPD_DEFAULT_PROVIDER=openai \\ + PROMPD_DEFAULT_MODEL=gpt-3.5-turbo + EXPOSE {port} + # Serve any mounted file under /data; override the path with docker run args or compose command + CMD ["prompd", "mcp", "serve", "/data/prompt.prmd", "--host", "0.0.0.0", "--port", "{port}"] + """ + ) + + compose_content = dedent( + f""" + version: "3.9" + services: + prompd-mcp: + build: + context: . + dockerfile: {dockerfile} + environment: + - OPENAI_API_KEY=${{OPENAI_API_KEY}} + - ANTHROPIC_API_KEY=${{ANTHROPIC_API_KEY}} + - PROMPD_DEFAULT_PROVIDER=${{PROMPD_DEFAULT_PROVIDER:-openai}} + - PROMPD_DEFAULT_MODEL=${{PROMPD_DEFAULT_MODEL:-gpt-3.5-turbo}} + volumes: + - ./prompds:/data + ports: + - "{port}:{port}" + """ + ) + + Path(dockerfile).write_text(dockerfile_content.strip() + "\n", encoding="utf-8") + Path(compose).write_text(compose_content.strip() + "\n", encoding="utf-8") + + console.print(f"[green]OK[/green] Wrote {dockerfile} and {compose}") + except Exception as exc: + console.print(f"[red]Failed to scaffold Docker files:[/red] {exc}") + raise SystemExit(1) + + +__all__ = ["mcp"] diff --git a/cli/python/prompd/commands/namespace.py b/cli/python/prompd/commands/namespace.py new file mode 100644 index 0000000..3f4208d --- /dev/null +++ b/cli/python/prompd/commands/namespace.py @@ -0,0 +1,250 @@ +"""Namespace management commands for Prompd.""" +from __future__ import annotations + +from typing import Optional + +import click +from rich.panel import Panel +from rich.table import Table + +from prompd.commands.common import console + + +@click.group(name="namespace") +def namespace(): + """Manage namespaces for organizations.""" + pass + + +@click.group(name="ns") +def ns(): + """Alias for namespace commands.""" + pass + + +@namespace.command("list") +@click.option("--registry", help="Registry to query") +@click.option( + "--show-permissions", "-p", is_flag=True, help="Show detailed permissions for each namespace" +) +def namespace_list(registry: Optional[str], show_permissions: bool): + """List accessible namespaces.""" + try: + from prompd.registry import RegistryClient + + client = RegistryClient(registry_name=registry) + namespaces = client.list_user_namespaces() + + if not namespaces: + console.print("[yellow]No namespaces available[/yellow]") + console.print("\nTo get started:") + console.print("-Free users can publish to @public automatically") + console.print("-Create a team namespace: [cyan]prompd namespace create @my-company[/cyan]") + return + + current_ns = client.get_current_namespace() + + console.print(f"[bold]Available namespaces ({len(namespaces)} total):[/bold]") + + table = Table(show_header=True, header_style="bold magenta") + table.add_column("NAMESPACE", style="cyan") + table.add_column("PACKAGES", justify="right") + table.add_column("DOWNLOADS", justify="right") + table.add_column("ROLE", style="green") + if show_permissions: + table.add_column("PERMISSIONS", style="dim") + table.add_column("STATUS", justify="center") + + for ns_info in namespaces: + status = "[bold green]CURRENT[/bold green]" if ns_info["name"] == current_ns else "" + if ns_info.get("verified"): + status += " [OK]" if status else "[OK]" + + permissions_str = "" + if show_permissions: + perms = ns_info.get("permissions", {}) + perm_list = [] + if perms.get("canPublish"): + perm_list.append("publish") + if perms.get("canManage"): + perm_list.append("manage") + if perms.get("canInvite"): + perm_list.append("invite") + if perms.get("canDelete"): + perm_list.append("delete") + permissions_str = ", ".join(perm_list) or "read" + + row = [ + ns_info["name"], + str(ns_info.get("packageCount", 0)), + str(ns_info.get("downloadCount", 0)), + ns_info.get("role", "read").upper(), + ] + if show_permissions: + row.append(permissions_str) + row.append(status) + + table.add_row(*row) + + console.print(table) + + if current_ns: + console.print(f"\n[dim]Current namespace context: [cyan]{current_ns}[/cyan][/dim]") + else: + console.print("\n[dim]No current namespace context set[/dim]") + + console.print("\n[dim]Switch namespace: [cyan]prompd ns use [/cyan][/dim]") + except Exception as exc: + console.print(f"[red]Failed to list namespaces:[/red] {exc}") + raise SystemExit(1) + + +@namespace.command("current") +@click.option("--registry", help="Registry to query") +def namespace_current(registry: Optional[str]): + """Show current namespace context.""" + try: + from prompd.registry import RegistryClient + + client = RegistryClient(registry_name=registry) + current_ns = client.get_current_namespace() + + if current_ns: + details = client.get_namespace_details(current_ns) + console.print(f"[bold]Current namespace:[/bold] [cyan]{current_ns}[/cyan]") + + if details: + console.print(f" Description: {details.get('description', 'No description')}") + console.print(f" Packages: {details.get('packageCount', 0)}") + console.print(f" Downloads: {details.get('downloadCount', 0)}") + console.print(f" Role: {details.get('role', 'unknown').upper()}") + if details.get("verified"): + console.print(" Status: [green]Verified [OK][/green]") + else: + console.print("[yellow]No current namespace context set[/yellow]") + console.print("\nSet a namespace context:") + console.print(" [cyan]prompd ns use @public[/cyan] # Use public namespace") + console.print(" [cyan]prompd ns use @my-company[/cyan] # Use your team namespace") + except Exception as exc: + console.print(f"[red]Failed to get current namespace:[/red] {exc}") + raise SystemExit(1) + + +@namespace.command("use") +@click.argument("namespace_name", required=True) +@click.option("--registry", help="Registry to use") +def namespace_use(namespace_name: str, registry: Optional[str]): + """Switch to a different namespace context.""" + try: + from prompd.registry import RegistryClient + + if not namespace_name.startswith("@"): + namespace_name = "@" + namespace_name + + client = RegistryClient(registry_name=registry) + client.set_current_namespace(namespace_name) + + console.print(f"[green]Success:[/green] Switched to namespace [cyan]{namespace_name}[/cyan]") + console.print("\n[dim]Future publishes will use this namespace unless overridden with the -ns flag[/dim]") + except Exception as exc: + console.print(f"[red]Failed to switch namespace:[/red] {exc}") + raise SystemExit(1) + + +@namespace.command("create") +@click.argument("namespace_name", required=True) +@click.option("--description", "-d", help="Description for the namespace") +@click.option("--organization", "-o", help="Organization ID to create namespace under") +@click.option( + "--visibility", + type=click.Choice(["public", "private"]), + default="public", + help="Namespace visibility", +) +@click.option("--registry", help="Registry to create namespace in") +def namespace_create( + namespace_name: str, + description: Optional[str], + organization: Optional[str], + visibility: str, + registry: Optional[str], +): + """Create a new namespace.""" + try: + from prompd.registry import RegistryClient + + if not namespace_name.startswith("@"): + namespace_name = "@" + namespace_name + + client = RegistryClient(registry_name=registry) + + namespace_data = {"name": namespace_name, "visibility": visibility} + if description: + namespace_data["description"] = description + if organization: + namespace_data["organizationId"] = organization + + console.print(f"[bold]Creating namespace:[/bold] [cyan]{namespace_name}[/cyan]") + + result = client.create_namespace(namespace_data) + + if result.get("requiresVerification"): + console.print(f"[yellow]Namespace requires verification[/yellow]") + console.print(f"Reason: {result.get('reason')}") + console.print(f"Request ID: {result.get('requestId')}") + console.print("\nCheck verification status: [cyan]prompd ns verify-status @namespace[/cyan]") + else: + console.print(f"[green]Success:[/green] Namespace [cyan]{namespace_name}[/cyan] created successfully") + client.set_current_namespace(namespace_name) + console.print("[dim]Automatically switched to namespace context[/dim]") + except Exception as exc: + console.print(f"[red]Failed to create namespace:[/red] {exc}") + raise SystemExit(1) + + +# Alias commands delegate to namespace implementations + +@ns.command("list") +@click.option("--registry", help="Registry to query") +@click.option( + "--show-permissions", "-p", is_flag=True, help="Show detailed permissions for each namespace" +) +def ns_list(registry: Optional[str], show_permissions: bool): + namespace_list(registry, show_permissions) + + +@ns.command("current") +@click.option("--registry", help="Registry to query") +def ns_current(registry: Optional[str]): + namespace_current(registry) + + +@ns.command("use") +@click.argument("namespace_name", required=True) +@click.option("--registry", help="Registry to use") +def ns_use(namespace_name: str, registry: Optional[str]): + namespace_use(namespace_name, registry) + + +@ns.command("create") +@click.argument("namespace_name", required=True) +@click.option("--description", "-d", help="Description for the namespace") +@click.option("--organization", "-o", help="Organization ID to create namespace under") +@click.option( + "--visibility", + type=click.Choice(["public", "private"]), + default="public", + help="Namespace visibility", +) +@click.option("--registry", help="Registry to create namespace in") +def ns_create( + namespace_name: str, + description: Optional[str], + organization: Optional[str], + visibility: str, + registry: Optional[str], +): + namespace_create(namespace_name, description, organization, visibility, registry) + + +__all__ = ["namespace", "ns"] diff --git a/cli/python/prompd/commands/pack.py b/cli/python/prompd/commands/pack.py new file mode 100644 index 0000000..95b25d2 --- /dev/null +++ b/cli/python/prompd/commands/pack.py @@ -0,0 +1,38 @@ +"""Alias command `prompd pack` for package creation.""" +from __future__ import annotations + +from pathlib import Path +from typing import Optional + +import click + +from prompd.commands.common import console +from prompd.commands.package import package_create + + +@click.command(name="pack") +@click.argument("source", type=click.Path(exists=True, path_type=Path)) +@click.argument("output_path", type=click.Path(path_type=Path), required=False) +@click.option("-n", "--name", help="Package name (overrides manifest.json)") +@click.option("-V", "--version", help="Package version (overrides manifest.json)") +@click.option("-d", "--description", help="Package description (overrides manifest.json)") +@click.option("-a", "--author", help="Package author (overrides manifest.json)") +def pack_alias( + source: Path, + output_path: Optional[Path], + name: Optional[str], + version: Optional[str], + description: Optional[str], + author: Optional[str], +): + """Create a .pdpkg package from a directory (alias for `package create`).""" + try: + package_create.callback(source, output_path, name, version, description, author) # type: ignore[attr-defined] + except SystemExit: + raise + except Exception as exc: + console.print(f"[red]Package creation failed:[/red] {exc}") + raise SystemExit(1) + + +__all__ = ["pack_alias"] diff --git a/cli/python/prompd/commands/package.py b/cli/python/prompd/commands/package.py index 4f838b5..e058215 100644 --- a/cli/python/prompd/commands/package.py +++ b/cli/python/prompd/commands/package.py @@ -1,173 +1,155 @@ -"""Package management commands.""" +"""Package management commands for Prompd.""" +from __future__ import annotations import json -import zipfile from pathlib import Path -from typing import Dict, Any +from typing import Optional import click -from rich.console import Console -from rich.table import Table -from ..registry import RegistryClient, validate_pdpkg -from ..exceptions import PrompdError -from ..security import validate_file_path, SecurityError +from prompd.commands.common import console -console = Console() - -@click.group() +@click.group(name="package") def package(): - """Package management (create, validate, install).""" + """Package management commands.""" pass -@package.command('create') -@click.argument('directory', type=click.Path(exists=True, file_okay=False)) -@click.option('-o', '--output', help='Output package file (.pdpkg)') -@click.option('--exclude', multiple=True, help='Patterns to exclude') -def create_package(directory: str, output: str, exclude: tuple): +@package.command("create") +@click.argument("source", type=click.Path(exists=True, path_type=Path)) +@click.argument("output_path", type=click.Path(path_type=Path), required=False) +@click.option("-n", "--name", help="Package name (overrides manifest.json)") +@click.option("-V", "--version", help="Package version (overrides manifest.json)") +@click.option("-d", "--description", help="Package description (overrides manifest.json)") +@click.option("-a", "--author", help="Package author (overrides manifest.json)") +def package_create( + source: Path, + output_path: Optional[Path], + name: Optional[str], + version: Optional[str], + description: Optional[str], + author: Optional[str], +): """Create a .pdpkg package from a directory.""" try: - dir_path = Path(directory) - safe_dir = validate_file_path(dir_path) - - if not output: - output = f"{dir_path.name}.pdpkg" - - output_path = Path(output) - if not output_path.suffix == '.pdpkg': - output_path = output_path.with_suffix('.pdpkg') - - # Find all .prmd and related files - files_to_include = [] - - # Include patterns - include_patterns = [ - '*.prmd', - '*.prompd', - '*.pdflow', - '*.json', - '*.yaml', - '*.yml', - 'README.md', - 'LICENSE*' - ] - - for pattern in include_patterns: - files_to_include.extend(safe_dir.glob(pattern)) - files_to_include.extend(safe_dir.glob(f"**/{pattern}")) - - # Remove duplicates and filter exclusions - files_to_include = list(set(files_to_include)) - - # Apply exclusions - for exclude_pattern in exclude: - files_to_include = [f for f in files_to_include if not f.match(exclude_pattern)] - - # Exclude .pdproj files (like .csproj from NuGet) - files_to_include = [f for f in files_to_include if not f.suffix == '.pdproj'] - - if not files_to_include: - console.print(f"[red]No files found to package in {directory}[/red]") - return - - # Generate manifest + from prompd.registry import create_pdpkg, validate_pdpkg + + if not source.is_dir(): + console.print("[red]ERROR[/red] Source must be a directory") + raise SystemExit(1) + + source_dir = source + manifest_path = source_dir / "manifest.json" + manifest_data = {} + + if manifest_path.exists(): + try: + with open(manifest_path, "r", encoding="utf-8") as f: + manifest_data = json.load(f) + console.print("[dim]Found existing manifest.json[/dim]") + except (json.JSONDecodeError, Exception) as exc: + console.print(f"[yellow]Warning: Could not read manifest.json: {exc}[/yellow]") + manifest_data = {} + + proj_name = name or manifest_data.get("name", source_dir.name.lower().replace(" ", "-").replace("_", "-")) + proj_version = version or manifest_data.get("version", "1.0.0") + proj_description = description or manifest_data.get( + "description", f"Package created from {source_dir.name}" + ) + proj_author = author or manifest_data.get("author", "unknown") + + if not output_path: + output_path = source_dir / f"{proj_name}-{proj_version}.pdpkg" + + if not output_path.suffix or output_path.suffix != ".pdpkg": + output_path = output_path.with_suffix(".pdpkg") + manifest = { - "name": dir_path.name, - "version": "1.0.0", # Default version - "description": f"Package created from {directory}", - "files": [] + "name": proj_name, + "version": proj_version, + "description": proj_description, + "license": "MIT", + "tags": [], + "dependencies": {}, + "keywords": [], } - - # Look for existing manifest or .prmd with metadata - manifest_file = safe_dir / 'manifest.json' - if manifest_file.exists(): - try: - with open(manifest_file, 'r', encoding='utf-8') as f: - existing_manifest = json.load(f) - manifest.update(existing_manifest) - except Exception as e: - console.print(f"[yellow]Warning: Could not read existing manifest: {e}[/yellow]") - - # Create package - with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zipf: - # Add files - for file_path in files_to_include: - arcname = file_path.relative_to(safe_dir) - zipf.write(file_path, arcname) - manifest["files"].append(str(arcname)) - - # Add manifest - zipf.writestr('manifest.json', json.dumps(manifest, indent=2)) - - console.print(f"[green]✓[/green] Created package: {output_path}") - console.print(f" Files included: {len(files_to_include)}") - - # Show contents - table = Table(title="Package Contents") - table.add_column("File", style="cyan") - table.add_column("Size", style="yellow") - - for file_path in sorted(files_to_include): - size = file_path.stat().st_size - table.add_row(str(file_path.relative_to(safe_dir)), f"{size:,} bytes") - - console.print(table) - - except SecurityError as e: - console.print(f"[red]Security error: {e}[/red]") - except Exception as e: - console.print(f"[red]Error creating package: {e}[/red]") - - -@package.command('validate') -@click.argument('package_file', type=click.Path(exists=True)) -def validate_package(package_file: str): - """Validate a .pdpkg package.""" + + if proj_author: + manifest["author"] = proj_author + + prompd_files = [f for f in source_dir.glob("**/*.prmd") if f.is_file()] + pdflow_files = [f for f in source_dir.glob("**/*.pdflow") if f.is_file()] + + if prompd_files: + main_file = str(prompd_files[0].relative_to(source_dir)).replace("\\", "/") + manifest["main"] = main_file + if len(prompd_files) > 1: + additional_files = [str(f.relative_to(source_dir)).replace("\\", "/") for f in prompd_files[1:]] + manifest["files"] = additional_files + + if pdflow_files: + manifest["workflows"] = [ + str(f.relative_to(source_dir)).replace("\\", "/") for f in pdflow_files + ] + + create_pdpkg(source_dir, output_path, manifest) + + console.print("[bold green]Package created successfully![/bold green]") + console.print(f" Package: [cyan]{output_path}[/cyan]") + console.print(f" Size: {output_path.stat().st_size / 1024:.1f} KB") + + validate_pdpkg(output_path) + console.print("[green]Package validation passed[/green]") + except Exception as exc: + console.print(f"[bold red]Package creation failed:[/bold red] {exc}") + raise SystemExit(1) + + +@package.command("validate") +@click.argument("package_path", type=click.Path(exists=True, path_type=Path)) +def package_validate(package_path: Path): + """Validate a .pdpkg package archive.""" try: - package_path = Path(package_file) - safe_path = validate_file_path(package_path) - - if not safe_path.suffix == '.pdpkg': - console.print(f"[red]File must have .pdpkg extension[/red]") - return - - # Validate package - result = validate_pdpkg(safe_path) - - if result["valid"]: - console.print(f"[green]✓[/green] Package is valid") - - # Show package info - manifest = result.get("manifest", {}) - - info_content = f""" -[bold cyan]Name:[/bold cyan] {manifest.get('name', 'Unknown')} -[bold cyan]Version:[/bold cyan] {manifest.get('version', 'Unknown')} -[bold cyan]Description:[/bold cyan] {manifest.get('description', 'No description')} -[bold cyan]Files:[/bold cyan] {len(manifest.get('files', []))} -""" - - from rich.panel import Panel - console.print(Panel(info_content.strip(), title="Package Information")) - - # Show files - if manifest.get('files'): - table = Table(title="Package Files") - table.add_column("File", style="cyan") - - for file_name in sorted(manifest['files']): - table.add_row(file_name) - - console.print(table) - + from prompd.package_validator import validate_package + + if not package_path.name.endswith(".pdpkg"): + console.print("[red]ERROR[/red] [bold red]Invalid package format![/bold red]") + console.print(f" File: {package_path.name}") + console.print(" Expected: .pdpkg archive file") + console.print(" Note: .prmd files are individual prompts, not packages") + console.print(" Use 'prompd validate' to validate individual .prmd files") + raise SystemExit(1) + + console.print(f"[blue]INFO[/blue] Validating package: [cyan]{package_path.name}[/cyan]") + + result = validate_package(package_path) + + if result.is_valid: + console.print("[green]SUCCESS[/green] [bold green]Package validation passed![/bold green]") + + if result.package_info: + info = result.package_info + console.print(f" Package: [cyan]{info.get('name', 'unknown')}[/cyan]") + console.print(f" Version: [green]{info.get('version', 'unknown')}[/green]") + console.print(f" Description: {info.get('description', 'No description')}") + if "parameters" in info: + console.print(f" Parameters: {len(info['parameters'])}") else: - console.print(f"[red]✗[/red] Package validation failed") - for error in result.get("errors", []): - console.print(f" [red]•[/red] {error}") - - except SecurityError as e: - console.print(f"[red]Security error: {e}[/red]") - except Exception as e: - console.print(f"[red]Error validating package: {e}[/red]") \ No newline at end of file + console.print("[red]ERROR[/red] [bold red]Package validation failed![/bold red]") + for error in result.errors: + console.print(f" - [red]{error}[/red]") + + if result.warnings: + console.print("\n[yellow]WARNINGS:[/yellow]") + for warning in result.warnings: + console.print(f" - [yellow]{warning}[/yellow]") + + if not result.is_valid: + raise SystemExit(1) + except Exception as exc: + console.print(f"[red]ERROR[/red] [bold red]Validation failed:[/bold red] {exc}") + raise SystemExit(1) + + +__all__ = ["package"] diff --git a/cli/python/prompd/commands/publish.py b/cli/python/prompd/commands/publish.py new file mode 100644 index 0000000..76b6a50 --- /dev/null +++ b/cli/python/prompd/commands/publish.py @@ -0,0 +1,80 @@ +"""Publish command for Prompd packages.""" +from __future__ import annotations + +import json +import zipfile +from pathlib import Path +from typing import Optional + +import click + +from prompd.commands.common import console + + +@click.command(name="publish") +@click.argument("package_file", type=click.Path(exists=True, path_type=Path)) +@click.option("--registry", help="Registry to publish to") +@click.option("-ns", "--namespace", help="Namespace to publish to (overrides current namespace context)") +@click.option("-n", "--dry-run", is_flag=True, help="Show what would be published without actually doing it") +def publish(package_file: Path, registry: Optional[str], namespace: Optional[str], dry_run: bool): + """Publish package to registry.""" + try: + if dry_run: + console.print(f"[yellow]DRY RUN: Would publish {package_file}[/yellow]") + return + + package_name = "unknown" + package_version = "unknown" + try: + with zipfile.ZipFile(package_file, "r") as zf: + if "manifest.json" in zf.namelist(): + manifest_bytes = zf.read("manifest.json") + manifest_text = manifest_bytes.decode("utf-8", errors="replace") + manifest_data = json.loads(manifest_text) + package_name = manifest_data.get("id", manifest_data.get("name", "unknown")) + package_version = manifest_data.get("version", "unknown") + + if namespace and not package_name.startswith("@"): + package_name = f"{namespace}/{package_name}" + except Exception: + pass + + console.print(f"[blue]Publishing {package_name}@{package_version}...[/blue]") + console.print(f"[dim]Package: {package_file}[/dim]") + + from prompd.registry import RegistryClient + + client = RegistryClient(registry_name=registry) + + current_ns = client.get_current_namespace() + if namespace: + console.print(f"[dim]Namespace: {namespace} (override)[/dim]") + elif current_ns: + console.print(f"[dim]Namespace: {current_ns} (current)[/dim]") + else: + console.print(f"[dim]Namespace: none (will use package scope or registry default)[/dim]") + + file_size = package_file.stat().st_size + console.print(f"[dim]Size: {file_size:,} bytes[/dim]") + console.print("[yellow]Uploading...[/yellow]") + + if namespace: + result = client.publish_package(package_file, target_namespace=namespace) + else: + result = client.publish_package(package_file) + + published_name = result.get("package", {}).get("fullName") or result.get("name") or package_name + published_version = result.get("package", {}).get("version") or result.get("version") or package_version + + console.print(f"[green]SUCCESS[/green] Published {published_name}@{published_version}") + console.print(f" Registry: {client.registry_name}") + if "package_url" in result: + console.print(f" URL: {result['package_url']}") + elif "url" in result: + console.print(f" URL: {result['url']}") + except Exception as exc: + console.print(f"[red]Publish failed:[/red] {exc}") + raise SystemExit(1) + + +__all__ = ["publish"] diff --git a/cli/python/prompd/commands/run.py b/cli/python/prompd/commands/run.py new file mode 100644 index 0000000..b3571e1 --- /dev/null +++ b/cli/python/prompd/commands/run.py @@ -0,0 +1,288 @@ +"""Implementation of the `prompd run` command.""" +from __future__ import annotations + +import subprocess +from pathlib import Path +from typing import Dict, Optional, Tuple + +import click + +from prompd.commands.common import console, is_valid_semver +from prompd.exceptions import ConfigurationError, PrompdError, ProviderError + + +def _parse_metadata_overrides(ctx) -> Dict[str, str]: + """Parse dynamic --meta:* flags from Click's context arguments.""" + metadata_overrides: Dict[str, str] = {} + try: + extra_args = list(ctx.args) if hasattr(ctx, "args") else [] + i = 0 + while i < len(extra_args): + token = extra_args[i] + if isinstance(token, str) and token.startswith("--meta:"): + section = token.split(":", 1)[1] + if i + 1 < len(extra_args): + val = extra_args[i + 1] + metadata_overrides[f"meta:{section}"] = str(val) + i += 2 + continue + i += 1 + except Exception: + # Best-effort; ignore parsing errors + pass + return metadata_overrides + + +def _resolve_defaults(provider: Optional[str], model: Optional[str], verbose: bool) -> Tuple[Optional[str], Optional[str]]: + """Resolve default provider/model values from configuration when omitted.""" + try: + from prompd.config import PrompdConfig + + cfg = PrompdConfig.load() + + if not provider: + provider = cfg.default_provider + if not provider: + for cand in ["openai", "anthropic", "ollama"]: + if cand == "ollama": + provider = cand + break + if cfg.get_api_key(cand): + provider = cand + break + if verbose and provider: + console.print(f"[dim]Using default provider: {provider}[/dim]") + + if not model: + model = cfg.default_model + if not model and provider: + if provider == "openai": + model = "gpt-4o" + elif provider == "anthropic": + model = "claude-3-haiku-20240307" + elif provider == "ollama": + model = "llama2" + if verbose and model: + console.print(f"[dim]Using default model: {model}[/dim]") + except Exception: + pass + + return provider, model + + +def _run_impl( + ctx, + file: Path, + provider: Optional[str], + model: Optional[str], + param: Tuple[str, ...], + param_file: Tuple[Path, ...], + api_key: Optional[str], + output: Optional[str], + output_format: str, + version: Optional[str], + verbose: bool, + show_usage: bool, +): + import asyncio + import json + import tempfile + + actual_file = file + temp_file: Optional[Path] = None + + try: + if version: + with tempfile.NamedTemporaryFile(mode="w", suffix=".prmd", delete=False, encoding="utf-8") as tmp: + temp_file = Path(tmp.name) + + if is_valid_semver(version): + tag_name = f"{file.stem}-v{version}" + tag_check = subprocess.run( + ["git", "tag", "-l", tag_name], + capture_output=True, + text=True, + ) + version_ref = tag_name if tag_check.stdout.strip() else version + else: + version_ref = version + + git_path = str(file).replace("\\", "/") + result = subprocess.run( + ["git", "show", f"{version_ref}:{git_path}"], + capture_output=True, + text=True, + check=True, + ) + + tmp.write(result.stdout) + actual_file = temp_file + + if verbose: + console.print(f"[dim]Using version {version} of {file}[/dim]") + + metadata_overrides = _parse_metadata_overrides(ctx) + + from prompd.executor import PrompdExecutor + + provider, model = _resolve_defaults(provider, model, verbose) + + cli_params = list(param) if param else None + param_files = [Path(p) for p in param_file] if param_file else None + + response = asyncio.run( + PrompdExecutor().execute( + prompd_file=actual_file, + provider=provider, + model=model, + cli_params=cli_params, + param_files=param_files, + api_key=api_key, + metadata_overrides=metadata_overrides or None, + ) + ) + + if temp_file and temp_file.exists(): + temp_file.unlink() + + if output_format == "json": + result = { + "response": response.content, + "provider": provider, + "model": model, + "file": str(file), + } + if response.usage: + result["usage"] = response.usage + + json_output = json.dumps(result, indent=2, ensure_ascii=False) + + if output: + with open(output, "w", encoding="utf-8") as f: + f.write(json_output) + try: + console.print(f"[green]OK[/green] JSON response written to {output}") + except UnicodeEncodeError: + print(f"OK - JSON response written to {output}") + else: + print(json_output) + else: + if output: + with open(output, "w", encoding="utf-8") as f: + f.write(response.content) + try: + console.print(f"[green]OK[/green] Response written to {output}") + except UnicodeEncodeError: + print(f"OK - Response written to {output}") + else: + from rich.panel import Panel + + try: + console.print( + Panel( + response.content, + title=f"Response from {provider}/{model}", + border_style="green", + ) + ) + except UnicodeEncodeError: + print(f"\n--- Response from {provider}/{model} ---") + print(response.content) + print("-" * 50) + + if (verbose or show_usage) and response.usage: + try: + console.print(f"\n[dim]Usage: {response.usage}[/dim]") + except UnicodeEncodeError: + print(f"\nUsage: {response.usage}") + except ConfigurationError as exc: + try: + console.print(f"[red]Configuration Error:[/red] {exc}") + except UnicodeEncodeError: + print(f"Configuration Error: {exc}") + raise SystemExit(1) + except ProviderError as exc: + try: + console.print(f"[red]Provider Error:[/red] {exc}") + except UnicodeEncodeError: + print(f"Provider Error: {exc}") + raise SystemExit(1) + except PrompdError as exc: + try: + console.print(f"[red]Error:[/red] {exc}") + except UnicodeEncodeError: + print(f"Error: {exc}") + raise SystemExit(1) + except Exception as exc: + try: + console.print(f"[red]Unexpected error:[/red] {exc}") + if verbose: + import traceback + + console.print(traceback.format_exc()) + except UnicodeEncodeError: + print(f"Unexpected error: {exc}") + if verbose: + import traceback + + print(traceback.format_exc()) + raise SystemExit(1) + + +@click.command(name="run", context_settings=dict(ignore_unknown_options=True)) +@click.argument("file", type=click.Path(exists=True, path_type=Path)) +@click.option("--provider", required=False, help="LLM provider (openai, anthropic, ollama). Defaults from config if omitted") +@click.option("--model", required=False, help="Model name. Defaults from config/provider if omitted") +@click.option("--param", "-p", multiple=True, help="Parameter in format key=value") +@click.option( + "--param-file", + "-f", + type=click.Path(exists=True, path_type=Path), + multiple=True, + help="JSON parameter file", +) +@click.option("--api-key", help="API key override") +@click.option("--output", "-o", type=click.Path(), help="Output file path") +@click.option( + "--format", + "output_format", + type=click.Choice(["text", "json"]), + default="text", + help="Output format", +) +@click.option("--version", help="Execute a specific version (e.g., '1.2.3', 'HEAD', commit hash)") +@click.option("--verbose", "-v", is_flag=True, help="Verbose output") +@click.option("--show-usage", is_flag=True, help="Show token usage statistics") +@click.pass_context +def run( + ctx, + file: Path, + provider: Optional[str], + model: Optional[str], + param: Tuple[str, ...], + param_file: Tuple[str, ...], + api_key: Optional[str], + output: Optional[str], + output_format: str, + version: Optional[str], + verbose: bool, + show_usage: bool, +): + """Run a .prmd file with an LLM provider (supports --meta:* flags).""" + return _run_impl( + ctx, + file, + provider, + model, + param, + param_file, + api_key, + output, + output_format, + version, + verbose, + show_usage, + ) + + +__all__ = ["run"] diff --git a/cli/python/prompd/commands/search.py b/cli/python/prompd/commands/search.py new file mode 100644 index 0000000..ef39086 --- /dev/null +++ b/cli/python/prompd/commands/search.py @@ -0,0 +1,58 @@ +"""Search command for Prompd registry packages.""" +from __future__ import annotations + +from typing import Optional + +import click +from rich.table import Table + +from prompd.commands.common import console + + +@click.command(name="search") +@click.argument("query", required=True) +@click.option("-l", "--limit", default=20, help="Maximum number of results") +@click.option("--registry", help="Registry to search in") +def search(query: str, limit: int, registry: Optional[str]): + """Search packages in registry.""" + try: + from prompd.registry import RegistryClient + + client = RegistryClient(registry_name=registry) + results = client.search(query, limit=limit) + + if not results: + console.print(f"[yellow]No packages found matching '{query}'[/yellow]") + return + + console.print(f"\n[bold]Found {len(results)} packages:[/bold]\n") + + table = Table(show_header=True, header_style="bold magenta") + table.add_column("Package", style="cyan") + table.add_column("Version", style="green") + table.add_column("Description", style="white") + table.add_column("Downloads", justify="right", style="yellow") + + for pkg in results: + package_name = pkg.get("fullName", pkg.get("name", "Unknown")) + version = ( + pkg.get("latestVersion") + or pkg.get("latest_version") + or pkg.get("version") + or pkg.get("currentVersion") + or "Unknown" + ) + downloads = pkg.get("downloads30d", pkg.get("downloads", 0)) + description = pkg.get("description", "") + if len(description) > 50: + description = description[:50] + "..." + + table.add_row(package_name, version, description, str(downloads)) + + console.print(table) + except Exception as exc: + console.print(f"[red]Search failed:[/red] {exc}") + raise SystemExit(1) + + +__all__ = ["search"] diff --git a/cli/python/prompd/commands/shell_cmd.py b/cli/python/prompd/commands/shell_cmd.py new file mode 100644 index 0000000..8b8da79 --- /dev/null +++ b/cli/python/prompd/commands/shell_cmd.py @@ -0,0 +1,30 @@ +"""Interactive shell command for the Prompd CLI.""" +from __future__ import annotations + +import click + +from prompd.commands.common import console + + +@click.command(name="shell") +@click.option("--simple", is_flag=True, help="Use the simple REPL (no AI chat UI)") +def shell_command(simple: bool): + """Start the interactive Prompd shell (REPL). [AI features in BETA]""" + try: + if simple: + from prompd.interactive_simple import SimplePrompdREPL + + SimplePrompdREPL().start() + else: + from prompd.shell import PrompdShell + + PrompdShell().start() + except Exception as exc: + try: + console.print(f"[red]Error launching shell:[/red] {exc}") + except Exception: + print(f"Error launching shell: {exc}") + raise SystemExit(1) + + +__all__ = ["shell_command"] diff --git a/cli/python/prompd/commands/show.py b/cli/python/prompd/commands/show.py new file mode 100644 index 0000000..74a9e57 --- /dev/null +++ b/cli/python/prompd/commands/show.py @@ -0,0 +1,118 @@ +"""Implementation of the `prompd show` command.""" +from __future__ import annotations + +from pathlib import Path + +import click +from rich.panel import Panel +from rich.table import Table + +from prompd.commands.common import console + + +@click.command(name="show") +@click.argument("file", type=click.Path(exists=True, path_type=Path)) +@click.option("--sections", is_flag=True, help="Show available section IDs for override reference") +@click.option("--verbose", is_flag=True, help="Show detailed section information") +def show(file: Path, sections: bool, verbose: bool): + """Show the structure and parameters of a .prmd file.""" + try: + from prompd.parser import PrompdParser + + parser = PrompdParser() + prompd = parser.parse_file(file) + metadata = prompd.metadata + + console.print(Panel(f"[bold cyan]{metadata.name}[/bold cyan]", subtitle=f"Version: {metadata.version or 'N/A'}")) + + if metadata.description: + console.print(f"\n[bold]Description:[/bold] {metadata.description}\n") + + if metadata.parameters: + table = Table(title="Parameters") + table.add_column("Name", style="cyan") + table.add_column("Type", style="green") + table.add_column("Required", style="yellow") + table.add_column("Default") + table.add_column("Description") + + for param in metadata.parameters: + table.add_row( + param.name, + param.type.value, + "Yes" if param.required else "No", + str(param.default or "")[:20], + param.description[:40] if param.description else "", + ) + console.print(table) + + content_info = [] + if metadata.system: + content_info.append(f"System: {metadata.system}") + if metadata.context: + content_info.append(f"Context: {metadata.context}") + if metadata.user: + content_info.append(f"User: {metadata.user}") + if metadata.response: + content_info.append(f"Response: {metadata.response}") + + if content_info: + console.print("\n[bold]Content Structure:[/bold]") + for info in content_info: + console.print(f" -{info}") + + if sections: + try: + section_summary = parser.get_section_summary(file) + + if section_summary: + sections_table = Table(title="Available Sections for Override") + sections_table.add_column("Section ID", style="cyan", min_width=20) + sections_table.add_column("Heading Text", style="green", min_width=30) + if verbose: + sections_table.add_column("Content Length", style="yellow", justify="right") + + for section_id, heading_text, content_length in section_summary: + if verbose: + sections_table.add_row(section_id, heading_text, f"{content_length:,} chars") + else: + sections_table.add_row(section_id, heading_text) + + console.print("\n") + console.print(sections_table) + + console.print("\n[bold]Override Usage Example:[/bold]") + console.print("[dim]override:[/dim]") + if section_summary: + example_id = section_summary[0][0] + console.print(f"[dim] {example_id}: \"./custom-{example_id}.md\"[/dim]") + console.print("[dim] another-section: null # Remove section[/dim]") + else: + console.print(f"\n[yellow]No sections found in {file.name}[/yellow]") + console.print("[dim]Note: Only markdown headings (# Header) create sections[/dim]") + except Exception as exc: + console.print(f"\n[red]Error extracting sections:[/red] {exc}") + else: + if prompd.sections: + console.print("\n[bold]Available Sections:[/bold]") + for section_name in prompd.sections: + console.print(f" -#{section_name}") + + if metadata and hasattr(metadata, "inherits") and metadata.inherits: + console.print(f"\n[bold]Inherits from:[/bold] {metadata.inherits}") + if hasattr(metadata, "override") and metadata.override: + console.print("\n[bold]Section Overrides:[/bold]") + for section_id, override_path in metadata.override.items(): + if override_path is None: + console.print(f" -[red]{section_id}[/red]: [removed]") + else: + console.print(f" -[cyan]{section_id}[/cyan]: {override_path}") + + if metadata.requires: + console.print(f"\n[bold]Requirements:[/bold] {', '.join(metadata.requires)}") + except Exception as exc: + console.print(f"[red]Error reading file:[/red] {exc}") + raise SystemExit(1) + + +__all__ = ["show"] diff --git a/cli/python/prompd/commands/uninstall.py b/cli/python/prompd/commands/uninstall.py new file mode 100644 index 0000000..c961e2e --- /dev/null +++ b/cli/python/prompd/commands/uninstall.py @@ -0,0 +1,56 @@ +"""Uninstall command for Prompd packages.""" +from __future__ import annotations + +from pathlib import Path +from typing import Tuple + +import click + +from prompd.commands.common import console + + +@click.command(name="uninstall") +@click.argument("packages", nargs=-1, required=True) +@click.option("-g", "--global", "global_uninstall", is_flag=True, help="Uninstall packages globally") +@click.option("--save-dev", is_flag=True, help="Remove from development dependencies") +def uninstall(packages: Tuple[str, ...], global_uninstall: bool, save_dev: bool): + """Uninstall packages.""" + try: + from prompd.package_resolver import PackageResolver + + resolver = PackageResolver(global_mode=global_uninstall) + + for package_name in packages: + console.print(f"Uninstalling {package_name}{' globally' if global_uninstall else ''}...") + + if global_uninstall: + cached_packages = resolver.global_cache.list_packages() + matching = [ + p for p in cached_packages if p.name == package_name or f"@{p.namespace}/{p.name}" == package_name + ] + + if not matching: + console.print(f"[yellow]Package {package_name} not found in global cache[/yellow]") + continue + + if len(matching) > 1: + console.print(f"[yellow]Multiple versions found. Please specify version:[/yellow]") + for pkg in matching: + console.print(f" {pkg.to_string()}") + continue + + removed = resolver.uninstall_package(matching[0].to_string(), force_global=True) + else: + resolver.remove_dependency(package_name, dev=save_dev, global_uninstall=False) + removed = True + + if removed: + console.print(f"[green]OK[/green] Uninstalled {package_name}") + else: + console.print(f"[yellow]Package {package_name} not found[/yellow]") + except Exception as exc: + console.print(f"[red]Uninstall failed:[/red] {exc}") + raise SystemExit(1) + + +__all__ = ["uninstall"] diff --git a/cli/python/prompd/commands/validate.py b/cli/python/prompd/commands/validate.py new file mode 100644 index 0000000..d079817 --- /dev/null +++ b/cli/python/prompd/commands/validate.py @@ -0,0 +1,113 @@ +"""Implementation of the `prompd validate` command.""" +from __future__ import annotations + +from pathlib import Path +from typing import Optional + +import click + +from prompd.commands.common import console + + +@click.command(name="validate") +@click.argument("file", type=click.Path(exists=True, path_type=Path)) +@click.option("--verbose", "-v", is_flag=True, help="Show detailed validation results") +@click.option("--git", is_flag=True, help="Include git history consistency checks") +@click.option("--version-only", is_flag=True, help="Only validate version-related aspects") +@click.option("--check-overrides", is_flag=True, help="Validate section overrides against parent template") +def validate( + file: Path, + verbose: bool, + git: bool, + version_only: bool, + check_overrides: bool, +): + """Validate a .prmd file syntax and structure.""" + try: + from prompd.validator import PrompdValidator + + validator = PrompdValidator() + + if version_only: + issues = validator.validate_version_consistency(file, check_git=git) + else: + issues = validator.validate_file(file) + if git: + git_issues = validator.validate_version_consistency(file, check_git=True) + issues.extend(git_issues) + + override_warnings = [] + if check_overrides: + try: + from prompd.parser import PrompdParser + + parser = PrompdParser() + prompd = parser.parse_file(file) + metadata = prompd.metadata + + if metadata and hasattr(metadata, "inherits") and metadata.inherits: + if hasattr(metadata, "override") and metadata.override: + parent_path = metadata.inherits + base_dir = file.parent + + parent_file = ( + base_dir / parent_path if not Path(parent_path).is_absolute() else Path(parent_path) + ) + + if parent_file.exists(): + override_warnings = parser.validate_overrides_against_parent(file, parent_file) + + if verbose and override_warnings: + console.print("\n[yellow]Override Validation Results:[/yellow]") + for warning in override_warnings: + console.print(f" [yellow]![/yellow] {warning}") + + for warning in override_warnings: + issues.append({"level": "warning", "message": f"Override validation: {warning}"}) + else: + issues.append({ + "level": "error", + "message": f"Parent template not found: {parent_file}", + }) + elif verbose: + console.print( + f"\n[blue]Override Check:[/blue] File inherits from {metadata.inherits} but has no overrides" + ) + elif verbose: + console.print("\n[blue]Override Check:[/blue] File does not use inheritance") + + except Exception as exc: + issues.append({"level": "error", "message": f"Override validation failed: {exc}"}) + + if not issues: + console.print(f"[green]OK[/green] {file} is valid") + return + + errors = [i for i in issues if i.get("level") == "error"] + warnings = [i for i in issues if i.get("level") == "warning"] + info = [i for i in issues if i.get("level") == "info"] + + if errors: + console.print(f"[red]ERRORS[/red] ({len(errors)}):") + for issue in errors: + console.print(f" [red]-[/red] {issue['message']}") + + if warnings: + console.print(f"[yellow]WARNINGS[/yellow] ({len(warnings)}):") + for issue in warnings: + console.print(f" [yellow]-[/yellow] {issue['message']}") + + if info and verbose: + console.print(f"[blue]INFO[/blue] ({len(info)}):") + for issue in info: + console.print(f" [blue]-[/blue] {issue['message']}") + + raise SystemExit(1 if errors else 0) + except SystemExit: + raise + except Exception as exc: + console.print(f"[red]Error validating file:[/red] {exc}") + raise SystemExit(1) + + +__all__ = ["validate"] diff --git a/cli/python/prompd/commands/version_cmds.py b/cli/python/prompd/commands/version_cmds.py new file mode 100644 index 0000000..ec13ddb --- /dev/null +++ b/cli/python/prompd/commands/version_cmds.py @@ -0,0 +1,296 @@ +"""Version management commands for Prompd.""" +from __future__ import annotations + +import subprocess +from pathlib import Path +from typing import Dict, List, Optional + +import click +from rich.panel import Panel +from rich.syntax import Syntax +from rich.table import Table + +from prompd.commands.common import console, is_valid_semver + + +@click.group(name="version") +def version(): + """Version management commands.""" + pass + + +@version.command("bump") +@click.argument("file", type=click.Path(exists=True, path_type=Path)) +@click.argument("bump_type", type=click.Choice(["major", "minor", "patch"])) +@click.option("--message", "-m", help="Commit message") +@click.option("--dry-run", is_flag=True, help="Show what would be done without making changes") +def version_bump(file: Path, bump_type: str, message: Optional[str], dry_run: bool): + """Bump version in a .prmd file and create git tag.""" + try: + from prompd.parser import PrompdParser + + parser = PrompdParser() + prompd = parser.parse_file(file) + + current_version = prompd.metadata.version or "0.0.0" + new_version = _bump_version(current_version, bump_type) + + if dry_run: + console.print(f"[dim]Would bump {file} from {current_version} to {new_version}[/dim]") + return + + _update_version_in_file(file, new_version) + + commit_msg = message or f"Bump {file.name} to {new_version}" + _git_commit_and_tag(file, new_version, commit_msg) + + console.print(f"[green]OK[/green] Bumped {file.name} from {current_version} to {new_version}") + except Exception as exc: + console.print(f"[red]Error:[/red] {exc}") + raise SystemExit(1) + + +@version.command("history") +@click.argument("file", type=click.Path(exists=True, path_type=Path)) +@click.option("--limit", "-n", type=int, default=10, help="Number of versions to show") +def version_history(file: Path, limit: int): + """Show version history for a .prmd file.""" + try: + tags = _get_git_tags(file, limit) + + if not tags: + console.print(f"[yellow]No version tags found for {file}[/yellow]") + return + + table = Table(title=f"Version History for {file}") + table.add_column("Version", style="cyan") + table.add_column("Date", style="green") + table.add_column("Commit", style="yellow") + table.add_column("Message") + + for tag_info in tags: + table.add_row(tag_info["tag"], tag_info["date"], tag_info["commit"][:8], tag_info["message"][:60]) + + console.print(table) + except Exception as exc: + console.print(f"[red]Error:[/red] {exc}") + raise SystemExit(1) + + +@version.command("diff") +@click.argument("file", type=click.Path(exists=True, path_type=Path)) +@click.argument("version1") +@click.argument("version2", required=False) +def version_diff(file: Path, version1: str, version2: Optional[str]): + """Show differences between versions of a .prmd file.""" + try: + version2 = version2 or "HEAD" + diff_output = _git_diff_versions(file, version1, version2) + + if not diff_output: + console.print(f"[green]No differences between {version1} and {version2}[/green]") + return + + syntax = Syntax(diff_output, "diff", theme="monokai", line_numbers=True) + console.print(Panel(syntax, title=f"Diff: {version1} -> {version2}")) + except Exception as exc: + console.print(f"[red]Error:[/red] {exc}") + raise SystemExit(1) + + +@version.command("validate") +@click.argument("file", type=click.Path(exists=True, path_type=Path)) +@click.option("--git", is_flag=True, help="Validate against git history") +def version_validate(file: Path, git: bool): + """Validate version consistency.""" + try: + from prompd.parser import PrompdParser + + parser = PrompdParser() + prompd = parser.parse_file(file) + + current_version = prompd.metadata.version + if not current_version: + console.print(f"[yellow]WARNING[/yellow] No version specified in {file}") + return + + if not is_valid_semver(current_version): + console.print(f"[red]ERROR[/red] Invalid semantic version: {current_version}") + raise SystemExit(1) + + if git: + latest_tag = _get_latest_git_tag(file) + if latest_tag and latest_tag != current_version: + console.print(f"[yellow]WARNING[/yellow] Version mismatch:") + console.print(f" File version: {current_version}") + console.print(f" Latest git tag: {latest_tag}") + + console.print(f"[green]OK[/green] Version {current_version} is valid") + except SystemExit: + raise + except Exception as exc: + console.print(f"[red]Error:[/red] {exc}") + raise SystemExit(1) + + +@version.command("suggest") +@click.argument("file", type=click.Path(exists=True, path_type=Path)) +@click.option("--changes", help="Description of changes made") +def version_suggest(file: Path, changes: Optional[str]): + """Suggest appropriate version bump based on changes.""" + try: + from prompd.parser import PrompdParser + from prompd.validator import PrompdValidator + + parser = PrompdParser() + validator = PrompdValidator() + prompd = parser.parse_file(file) + + current_version = prompd.metadata.version or "0.0.0" + suggestion = validator.suggest_version_bump(current_version, changes or "") + + console.print( + Panel( + f"[bold cyan]Current Version:[/bold cyan] {suggestion['suggestions']['current']}\n\n" + f"[bold green]Suggested Bump:[/bold green] {suggestion['recommended']} -> " + f"{suggestion['suggestions'][suggestion['recommended']]}\n\n" + f"[bold]All Options:[/bold]\n" + f" - Patch: {suggestion['suggestions']['patch']} (bug fixes)\n" + f" - Minor: {suggestion['suggestions']['minor']} (new features)\n" + f" - Major: {suggestion['suggestions']['major']} (breaking changes)\n\n" + f"[dim]{suggestion['reason']}[/dim]", + title="Version Bump Suggestions", + ) + ) + except Exception as exc: + console.print(f"[red]Error:[/red] {exc}") + raise SystemExit(1) + + +def _bump_version(version: str, bump_type: str) -> str: + parts = version.split(".") + if len(parts) != 3: + raise ValueError(f"Invalid semantic version: {version}") + + major, minor, patch = map(int, parts) + + if bump_type == "major": + major += 1 + minor = 0 + patch = 0 + elif bump_type == "minor": + minor += 1 + patch = 0 + elif bump_type == "patch": + patch += 1 + + return f"{major}.{minor}.{patch}" + + +def _update_version_in_file(file_path: Path, new_version: str): + content = file_path.read_text(encoding="utf-8") + + import re + + if content.startswith("---\n"): + end_match = re.search(r"\n---\n", content[4:]) + if end_match: + yaml_end = end_match.end() + 4 + frontmatter = content[4 : yaml_end - 5] + markdown_content = content[yaml_end:] + + import yaml + + metadata = yaml.safe_load(frontmatter) or {} + metadata["version"] = new_version + updated_content = f"---\n{yaml.dump(metadata, default_flow_style=False)}---\n{markdown_content}" + file_path.write_text(updated_content, encoding="utf-8") + + +def _git_commit_and_tag(file_path: Path, version: str, message: str): + try: + from prompd.security import SecurityError, validate_git_file_path, validate_git_message, validate_version_string + + safe_path = validate_git_file_path(str(file_path)) + safe_message = validate_git_message(message) + safe_version = validate_version_string(version) + + subprocess.run(["git", "add", safe_path], check=True, capture_output=True) + subprocess.run(["git", "commit", "-m", safe_message], check=True, capture_output=True) + + safe_stem = validate_git_file_path(file_path.stem) + tag_name = f"{safe_stem}-v{safe_version}" + subprocess.run(["git", "tag", tag_name], check=True, capture_output=True) + except SecurityError as exc: + raise Exception(f"Security validation failed: {exc}") from exc + except subprocess.CalledProcessError as exc: + raise Exception(f"Git operation failed: {exc.stderr.decode() if exc.stderr else exc}") from exc + + +def _get_git_tags(file_path: Path, limit: int) -> List[Dict[str, str]]: + try: + result = subprocess.run( + [ + "git", + "log", + "--tags", + "--simplify-by-decoration", + "--pretty=format:%d|%H|%ai|%s", + "-n", + str(limit), + "--", + str(file_path), + ], + capture_output=True, + text=True, + check=True, + ) + + tags: List[Dict[str, str]] = [] + for line in result.stdout.split("\n"): + if line.strip(): + parts = line.split("|", 3) + if len(parts) == 4 and "tag:" in parts[0]: + import re + + tag_match = re.search(r"tag: ([^,)]+)", parts[0]) + if tag_match: + tags.append( + { + "tag": tag_match.group(1).strip(), + "commit": parts[1], + "date": parts[2][:10], + "message": parts[3], + } + ) + return tags + except subprocess.CalledProcessError: + return [] + + +def _get_latest_git_tag(file_path: Path) -> Optional[str]: + tags = _get_git_tags(file_path, 1) + return tags[0]["tag"] if tags else None + + +def _git_diff_versions(file_path: Path, version1: str, version2: str) -> str: + try: + result = subprocess.run( + [ + "git", + "diff", + f"{file_path.stem}-v{version1}", + f"{file_path.stem}-v{version2}", + "--", + str(file_path), + ], + capture_output=True, + text=True, + check=True, + ) + return result.stdout + except subprocess.CalledProcessError as exc: + raise Exception(f"Git diff failed: {exc.stderr.decode() if exc.stderr else exc}") from exc + + +__all__ = ["version"] diff --git a/cli/python/prompd/commands/versions.py b/cli/python/prompd/commands/versions.py new file mode 100644 index 0000000..3e2a8d5 --- /dev/null +++ b/cli/python/prompd/commands/versions.py @@ -0,0 +1,47 @@ +"""List package versions command.""" +from __future__ import annotations + +from typing import Optional + +import click +from rich.table import Table + +from prompd.commands.common import console + + +@click.command(name="versions") +@click.argument("package_name", required=True) +@click.option("--registry", help="Registry to query") +def versions(package_name: str, registry: Optional[str]): + """List available versions of a package.""" + try: + from prompd.registry import RegistryClient + + client = RegistryClient(registry_name=registry) + versions_list = client.get_package_versions(package_name) + + if not versions_list: + console.print(f"[yellow]No versions found for {package_name}[/yellow]") + return + + console.print(f"\n[bold]Available versions for {package_name}:[/bold]\n") + + table = Table(show_header=True, header_style="bold magenta") + table.add_column("Version", style="green") + table.add_column("Published", style="blue") + table.add_column("Tags", style="yellow") + + for version_info in versions_list: + table.add_row( + version_info.get("version", "Unknown"), + version_info.get("published_at", "Unknown")[:10], + ", ".join(version_info.get("tags", [])), + ) + + console.print(table) + except Exception as exc: + console.print(f"[red]Failed to get versions:[/red] {exc}") + raise SystemExit(1) + + +__all__ = ["versions"] diff --git a/cli/python/prompd/console.py b/cli/python/prompd/console.py new file mode 100644 index 0000000..024417f --- /dev/null +++ b/cli/python/prompd/console.py @@ -0,0 +1,23 @@ +"""Shared Rich console configuration for the Prompd CLI.""" +from __future__ import annotations + +import platform +import sys +from rich.console import Console + + +def _create_console() -> Console: + """Create a Rich console with consistent settings across commands.""" + try: + if platform.system() == "Windows": + # Force UTF-8 with Rich's legacy Windows handling to avoid encoding glitches + return Console(file=sys.stdout, legacy_windows=True, width=120, force_terminal=True) + return Console(file=sys.stdout, force_terminal=True, width=120) + except Exception: + # Fallback configuration if Rich cannot initialise with preferred settings + return Console(file=sys.stdout, legacy_windows=True, width=120) + + +console: Console = _create_console() + +__all__ = ["console"] From ba69259046331cb6e2283554d800bb91e7c0b832 Mon Sep 17 00:00:00 2001 From: Michael Gwilt Date: Tue, 30 Sep 2025 09:25:48 -0700 Subject: [PATCH 2/2] Update cli/python/prompd/commands/common.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- cli/python/prompd/commands/common.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/cli/python/prompd/commands/common.py b/cli/python/prompd/commands/common.py index fd6b937..46d3a0a 100644 --- a/cli/python/prompd/commands/common.py +++ b/cli/python/prompd/commands/common.py @@ -2,8 +2,6 @@ from __future__ import annotations import re -from typing import Dict - from prompd.console import console SEMVER_PATTERN = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")