diff --git a/bulkgen/cli.py b/bulkgen/cli.py new file mode 100644 index 0000000..6b59584 --- /dev/null +++ b/bulkgen/cli.py @@ -0,0 +1,101 @@ +"""Typer CLI for bulkgen: build, clean, graph commands.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import Annotated + +import typer + +from bulkgen.builder import run_build +from bulkgen.config import load_config +from bulkgen.graph import build_graph, get_build_order + +app = typer.Typer(name="bulkgen", help="AI artifact build tool.") + + +def _find_config(directory: Path) -> Path: + """Find the single ``*.bulkgen.yaml`` file in *directory*.""" + candidates = list(directory.glob("*.bulkgen.yaml")) + if len(candidates) == 0: + typer.echo("Error: No .bulkgen.yaml file found in current directory", err=True) + raise typer.Exit(code=1) + if len(candidates) > 1: + names = ", ".join(str(c.name) for c in candidates) + typer.echo(f"Error: Multiple .bulkgen.yaml files found: {names}", err=True) + raise typer.Exit(code=1) + return candidates[0] + + +@app.command() +def build( + target: Annotated[ + str | None, typer.Argument(help="Specific target to build.") + ] = None, +) -> None: + """Build all targets (or a specific target) in dependency order.""" + project_dir = Path.cwd() + config_path = _find_config(project_dir) + config = load_config(config_path) + + result = asyncio.run(run_build(config, project_dir, target)) + + if result.built: + typer.echo(f"\nBuilt {len(result.built)} target(s)") + if result.skipped: + typer.echo(f"Skipped {len(result.skipped)} target(s) (up to date)") + if result.failed: + typer.echo(f"Failed {len(result.failed)} target(s):", err=True) + for name, err in result.failed.items(): + typer.echo(f" {name}: {err}", err=True) + raise typer.Exit(code=1) + + +@app.command() +def clean() -> None: + """Remove generated artifacts (targets only, not input files).""" + project_dir = Path.cwd() + config_path = _find_config(project_dir) + config = load_config(config_path) + + removed = 0 + for target_name in config.targets: + target_path = project_dir / target_name + if target_path.exists(): + target_path.unlink() + typer.echo(f"Removed: {target_name}") + removed += 1 + + state_path = project_dir / ".bulkgen.state.yaml" + if state_path.exists(): + state_path.unlink() + typer.echo("Removed: .bulkgen.state.yaml") + + typer.echo(f"Cleaned {removed} artifact(s)") + + +@app.command() +def graph() -> None: + """Print the dependency graph with build stages.""" + project_dir = Path.cwd() + config_path = _find_config(project_dir) + config = load_config(config_path) + + dep_graph = build_graph(config, project_dir) + generations = get_build_order(dep_graph) + target_names = set(config.targets) + + for i, gen in enumerate(generations): + targets_in_gen = [n for n in gen if n in target_names] + externals_in_gen = [n for n in gen if n not in target_names] + + if externals_in_gen: + typer.echo(f"Stage {i} (inputs): {', '.join(externals_in_gen)}") + if targets_in_gen: + typer.echo(f"Stage {i} (targets): {', '.join(targets_in_gen)}") + + for node in gen: + predecessors = list(dep_graph.predecessors(node)) + if predecessors: + typer.echo(f" {node} <- {', '.join(predecessors)}")