feat: add CLI commands (build, clean, graph)

- build: executes all or specific target with dependency resolution
- clean: removes generated artifacts and state file, preserves inputs
- graph: prints dependency graph with build stages
- Config discovery: finds single *.bulkgen.yaml in working directory
This commit is contained in:
Konstantin Fickel 2026-02-13 20:14:16 +01:00
parent bb4b2e2b86
commit 1d98c0010a
Signed by: kfickel
GPG key ID: A793722F9933C1A5

101
bulkgen/cli.py Normal file
View file

@ -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)}")