"""Project management commands.""" import sys from pathlib import Path from ..manifest import ( load_manifest, save_manifest, create_manifest, find_manifest, MANIFEST_FILENAME ) from ..resolver import ( find_tool, install_from_registry, ToolSpec ) def cmd_deps(args): """Show project dependencies from cmdforge.yaml.""" # If 'tree' subcommand, delegate to that if hasattr(args, 'deps_cmd') and args.deps_cmd == 'tree': return cmd_deps_tree(args) manifest = load_manifest() if manifest is None: print("No cmdforge.yaml found in current project.") print("Create one with: cmdforge init") return 1 print(f"Project: {manifest.name} v{manifest.version}") print() if not manifest.dependencies: print("No dependencies defined.") print("Add one with: cmdforge add ") return 0 print(f"Dependencies ({len(manifest.dependencies)}):") print() for dep in manifest.dependencies: # Check if installed installed = find_tool(dep.name) status = "[installed]" if installed else "[not installed]" print(f" {dep.name}") print(f" Version: {dep.version}") print(f" Status: {status}") print() if manifest.overrides: print("Overrides:") for name, override in manifest.overrides.items(): if override.provider: print(f" {name}: provider={override.provider}") return 0 def cmd_deps_tree(args): """Show dependency tree with transitive dependencies.""" from ..dependency_graph import DependencyGraphBuilder from ..resolver import ToolResolver manifest = load_manifest() if manifest is None: print("No cmdforge.yaml found in current project.") print("Create one with: cmdforge init") return 1 if not manifest.dependencies: print("No dependencies in cmdforge.yaml") return 0 verbose = getattr(args, 'verbose', False) resolver = ToolResolver(auto_fetch=False) builder = DependencyGraphBuilder(resolver=resolver, verbose=verbose) print("Resolving dependencies...") graph = builder.build(manifest.dependencies) def print_tree(qualified_name: str, indent: int = 0, seen: set = None): seen = seen or set() prefix = " " * indent node = graph.get_node(qualified_name) if not node: print(f"{prefix}- {qualified_name} (not found)") return # Status indicator if node.source in ("local", "global"): status = "installed" elif node.source == "registry" and node.path: status = "installed" elif node.source == "registry": status = "registry" else: status = "missing" # Show resolved version if available, otherwise fall back to constraint if node.resolved_version: version = f"@{node.resolved_version}" elif node.version_constraint: version = f"@{node.version_constraint}" else: version = "" circular = " (circular)" if qualified_name in seen else "" print(f"{prefix}- {node.qualified_name}{version} [{status}]{circular}") if qualified_name not in seen: seen.add(qualified_name) for child in node.children: print_tree(child, indent + 1, seen.copy()) print() print("Dependency tree:") for root in graph.root_dependencies: print_tree(root) if graph.conflicts: print() print("Version conflicts:") for tool, v1, from1, v2, from2 in graph.conflicts: print(f" {tool}: {v1} (from {from1}) vs {v2} (from {from2})") if graph.cycles: print() print("Circular dependencies:") for cycle in graph.cycles: print(f" {' -> '.join(cycle)}") # Summary print() total = len(graph.nodes) installed = sum(1 for n in graph.nodes.values() if n.source in ("local", "global") or (n.source == "registry" and n.path)) to_install = sum(1 for n in graph.nodes.values() if n.source == "registry" and n.path is None) missing = sum(1 for n in graph.nodes.values() if not n.is_resolved) print(f"Total: {total} tools ({installed} installed, {to_install} to install, {missing} missing)") return 0 def cmd_install_deps(args): """Install all dependencies from cmdforge.yaml with transitive resolution.""" from ..dependency_graph import DependencyGraphBuilder from ..resolver import ToolResolver, install_from_registry, ToolNotFoundError from ..registry_client import RegistryError from ..lockfile import Lockfile from ..system_deps import prompt_install_missing manifest = load_manifest() if manifest is None: print("No cmdforge.yaml found in current project.") print("Create one with: cmdforge init") return 1 if not manifest.dependencies: print("No dependencies to install.") return 0 verbose = getattr(args, 'verbose', False) dry_run = getattr(args, 'dry_run', False) force = getattr(args, 'force', False) frozen = getattr(args, 'frozen', False) strict_frozen = getattr(args, 'strict_frozen', False) ignore_lock = getattr(args, 'ignore_lock', False) # Check for lock file lock = Lockfile.load() if frozen or strict_frozen: # Frozen mode: require lock file if lock is None: print("Error: --frozen requires cmdforge.lock") print("Run 'cmdforge lock' first.") return 1 if strict_frozen and lock.is_stale(): print("Error: cmdforge.lock is stale (manifest changed)") print("Run 'cmdforge lock' to update, or drop --strict-frozen") return 1 if lock and not ignore_lock: return _install_from_lock(lock, args) # Fall through to manifest-based installation # Build dependency graph resolver = ToolResolver(auto_fetch=False) builder = DependencyGraphBuilder(resolver=resolver, verbose=verbose) print("Resolving dependencies...") graph = builder.build(manifest.dependencies) # Check for issues if graph.has_cycles(): print() print("Error: Circular dependencies detected:") for cycle in graph.cycles: print(f" {' -> '.join(cycle)}") return 1 if graph.has_conflicts(): print() print("Warning: Version conflicts detected:") for tool, v1, from1, v2, from2 in graph.conflicts: print(f" {tool}: {v1} (from {from1}) vs {v2} (from {from2})") if not force: print() print("Use --force to install anyway (first constraint wins)") return 1 unresolved = graph.get_unresolved() if unresolved: print() print("Error: Missing dependencies:") for node in unresolved: constraint = f"@{node.version_constraint}" if node.version_constraint else "" print(f" {node.qualified_name}{constraint}") return 1 # Determine what needs installation (in correct order) to_install = [ node for node in graph.get_install_order() if node.source == "registry" and node.path is None ] if not to_install: print("All dependencies already installed.") return 0 if dry_run: print() print(f"Would install {len(to_install)} tool(s):") for node in to_install: version = node.resolved_version or "latest" print(f" {node.qualified_name}@{version}") return 0 print() print(f"Installing {len(to_install)} tool(s) in dependency order:") for node in to_install: version = node.resolved_version or "latest" print(f" {node.qualified_name}@{version}") # Install in topological order (dependencies first) installed = 0 failed = [] print() for node in to_install: try: print(f"Installing {node.qualified_name}...", end=" ", flush=True) spec = node.qualified_name install_version = node.resolved_version or node.version_constraint resolved = install_from_registry(spec, version=install_version) print(f"v{node.resolved_version or 'latest'}") installed += 1 if resolved.tool.system_dependencies: print() tool_ref = f"{resolved.owner}/{resolved.tool.name}" if resolved.owner else resolved.tool.name prompt_install_missing(resolved.tool.system_dependencies, tool_ref) except RegistryError as e: print(f"FAILED: {e.message}") failed.append((node.qualified_name, e.message)) except Exception as e: print(f"FAILED: {e}") failed.append((node.qualified_name, str(e))) # Summary print() print(f"Installed: {installed}/{len(to_install)}") if failed: print("Failed:") for name, err in failed: print(f" {name}: {err}") return 1 return 0 def _install_from_lock(lock: "Lockfile", args) -> int: """Install exact versions from lock file.""" from ..resolver import ToolResolver, ToolNotFoundError, install_from_registry from ..registry_client import RegistryError from ..system_deps import prompt_install_missing verbose = getattr(args, 'verbose', False) dry_run = getattr(args, 'dry_run', False) force = getattr(args, 'force', False) print("Installing from cmdforge.lock (reproducible)...") if lock.is_stale() and not force: print("Warning: Lock file may be outdated (manifest changed)") print("Run 'cmdforge lock' to update, or use --force to continue") return 1 # Check what needs installing resolver = ToolResolver(auto_fetch=False) resolver.manifest = None to_install = [] for name, locked in lock.packages.items(): try: resolved = resolver.resolve(name) # Check if version matches resolved_version = resolved.version or "" locked_version = locked.version or "" if resolved_version != locked_version: to_install.append(locked) except ToolNotFoundError: to_install.append(locked) except Exception: to_install.append(locked) if not to_install: print("All locked packages already installed.") return 0 print() print(f"Installing {len(to_install)} package(s):") for pkg in to_install: print(f" {pkg.name}@{pkg.version}") if dry_run: return 0 # Install exact versions installed = 0 failed = [] print() for pkg in to_install: if pkg.source == "local": print(f"{pkg.name}: skipping (local)") continue try: print(f"Installing {pkg.name}@{pkg.version}...", end=" ", flush=True) # Use correct API: install_from_registry(spec, version) resolved = install_from_registry(pkg.name, version=pkg.version) print("OK") installed += 1 if resolved.tool.system_dependencies: print() tool_ref = f"{resolved.owner}/{resolved.tool.name}" if resolved.owner else resolved.tool.name prompt_install_missing(resolved.tool.system_dependencies, tool_ref) except RegistryError as e: print(f"FAILED: {e.message}") failed.append((pkg.name, e.message)) except Exception as e: print(f"FAILED: {e}") failed.append((pkg.name, str(e))) print() print(f"Installed: {installed}/{len(to_install)}") if failed: print("Failed:") for name, err in failed: print(f" {name}: {err}") return 1 return 0 def cmd_add(args): """Add a tool to project dependencies.""" from ..registry_client import RegistryError tool_spec = args.tool version = args.version or "*" # Find or create manifest manifest_path = find_manifest() if manifest_path: manifest = load_manifest(manifest_path) else: # Create in current directory manifest = create_manifest(name=Path.cwd().name) manifest_path = Path.cwd() / MANIFEST_FILENAME # Parse tool spec parsed = ToolSpec.parse(tool_spec) full_name = parsed.full_name # Check if already installed locally before trying registry local_tool = find_tool(tool_spec) # Add dependency manifest.add_dependency(full_name, version) # Save save_manifest(manifest, manifest_path) print(f"Added {full_name}@{version} to {manifest_path.name}") # Install if requested if not args.no_install: # If already installed locally, no need to fetch from registry if local_tool: source_label = "locally" if local_tool.source == "local" else "globally" version_info = f"@{local_tool.version}" if local_tool.version else "" print(f"Already installed {source_label}: {local_tool.full_name}{version_info}") else: print(f"Installing {full_name}...") try: resolved = install_from_registry(tool_spec, version if version != "*" else None) print(f"Installed: {resolved.full_name}@{resolved.version}") except RegistryError as e: if e.code == "TOOL_NOT_FOUND": print(f"Not found in registry: {full_name}", file=sys.stderr) print(f"Dependency added - install manually or create the tool locally.", file=sys.stderr) elif e.code == "VERSION_NOT_FOUND": print(f"No published version of '{full_name}' found in registry.", file=sys.stderr) print(f"Dependency added - publish the tool or install manually.", file=sys.stderr) elif e.code == "CONNECTION_ERROR": print(f"Could not connect to registry.", file=sys.stderr) print("Run 'cmdforge install' to try again later.", file=sys.stderr) else: print(f"Install failed: {e.message}", file=sys.stderr) print("Run 'cmdforge install' to try again.", file=sys.stderr) except Exception as e: print(f"Install failed: {e}", file=sys.stderr) print("Run 'cmdforge install' to try again.", file=sys.stderr) return 0 def cmd_remove(args): """Remove a tool from project dependencies.""" tool_spec = args.tool # Find manifest manifest_path = find_manifest() if not manifest_path: print("No cmdforge.yaml found in current project.") print("Nothing to remove.") return 1 manifest = load_manifest(manifest_path) # Parse tool spec to get the name parsed = ToolSpec.parse(tool_spec) tool_name = parsed.full_name # Remove from manifest if manifest.remove_dependency(tool_name): save_manifest(manifest, manifest_path) print(f"Removed {tool_name} from {manifest_path.name}") else: # Try without owner prefix if manifest.remove_dependency(parsed.name): save_manifest(manifest, manifest_path) print(f"Removed {parsed.name} from {manifest_path.name}") else: print(f"Dependency '{tool_name}' not found in {manifest_path.name}") return 1 return 0 def cmd_init(args): """Initialize a new cmdforge.yaml.""" manifest_path = Path.cwd() / MANIFEST_FILENAME if manifest_path.exists() and not args.force: print(f"{MANIFEST_FILENAME} already exists. Use --force to overwrite.") return 1 # Get project name default_name = Path.cwd().name if args.name: name = args.name else: try: name = input(f"Project name [{default_name}]: ").strip() or default_name except (EOFError, KeyboardInterrupt): print() name = default_name # Get version if args.version: version = args.version else: try: version = input("Version [1.0.0]: ").strip() or "1.0.0" except (EOFError, KeyboardInterrupt): print() version = "1.0.0" # Create manifest manifest = create_manifest(name=name, version=version) save_manifest(manifest, manifest_path) print(f"Created {MANIFEST_FILENAME}") print() print("Add dependencies with: cmdforge add ") print("Install them with: cmdforge install") return 0 def cmd_lock(args): """Generate or update cmdforge.lock file.""" from ..lockfile import Lockfile, generate_lockfile from ..dependency_graph import DependencyGraphBuilder from ..resolver import ToolResolver from ..registry_client import get_client manifest = load_manifest() if manifest is None: print("No cmdforge.yaml found. Run 'cmdforge init' first.") return 1 verbose = getattr(args, 'verbose', False) force = getattr(args, 'force', False) # Check for existing lock existing_lock = Lockfile.load() if existing_lock and not force: if not existing_lock.is_stale(): print("Lock file is up to date. Use --force to regenerate.") return 0 print("Lock file is stale (manifest changed), regenerating...") # Resolve all dependencies print("Resolving dependencies...") resolver = ToolResolver(auto_fetch=True) # Allow fetching for resolution builder = DependencyGraphBuilder(resolver=resolver, verbose=verbose) try: graph = builder.build(manifest.dependencies) except Exception as e: print(f"Error resolving dependencies: {e}") return 1 if graph.has_cycles(): print("Error: Circular dependencies detected") for cycle in graph.cycles: print(f" {' -> '.join(cycle)}") return 1 unresolved = graph.get_unresolved() if unresolved and not force: print("Error: Some dependencies could not be resolved:") for node in unresolved: constraint = f"@{node.version_constraint}" if node.version_constraint else "" print(f" {node.qualified_name}{constraint}") print("Fix missing deps or re-run with --force to lock what can be resolved.") return 1 if unresolved and force: print("Warning: Some dependencies could not be resolved and will be skipped:") for node in unresolved: constraint = f"@{node.version_constraint}" if node.version_constraint else "" print(f" {node.qualified_name}{constraint}") # Generate lock file print("Generating lock file...") client = get_client() lock = generate_lockfile(manifest, graph, client) lock.save() # Summary direct = sum(1 for p in lock.packages.values() if p.direct) transitive = len(lock.packages) - direct print() print(f"Locked {len(lock.packages)} package(s):") print(f" Direct: {direct}") print(f" Transitive: {transitive}") print() print("Wrote cmdforge.lock") return 0 def cmd_verify(args): """Verify installed tools match lock file.""" from ..lockfile import Lockfile, verify_lockfile from ..registry_client import get_client lock = Lockfile.load() if lock is None: print("No cmdforge.lock found. Run 'cmdforge lock' first.") return 1 if lock.is_stale(): print("Warning: Lock file may be outdated (manifest changed)") print() print("Verifying lock file...") client = get_client() errors = verify_lockfile(lock, client) if not errors: print(f"All {len(lock.packages)} packages verified OK") return 0 print() print(f"{len(errors)} verification error(s):") for error in errors: print(f" {error}") return 1