generated from coulomb/repo-seed
- Migration d6e7f8a9b0c1: add terraform, ansible, tool to Ecosystem enum - ingest_sbom.py: new Ansible Galaxy requirements.yml parser (collections + roles) - ingest_sbom.py: new sbom-tools.yaml manifest parser (agent-generated tool deps) - ingest_sbom.py: promote .terraform.lock.hcl parser from ecosystem=other → terraform - ingest_sbom.py: detect_all() runs all four parsers in one comprehensive scan - capture_sbom_tools.py: agent-assisted tool manifest generator (claude -p) - prompts/sbom-capture-agent.md: parameterised prompt for repo tool discovery - Makefile: capture-tools target; ingest-sbom updated docs and DRY_RUN support - 29 unit tests covering all new parsers and detect_all() behaviour - canon/standards/sbom-convention_v0.1.md: updated with four-mechanism model and workflow Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
188 lines
5.9 KiB
Python
188 lines
5.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Invoke the SBOM capture agent to generate/update sbom-tools.yaml for a repo.
|
|
|
|
Usage:
|
|
python capture_sbom_tools.py --repo <slug> [--repo-path <path>] [--dry-run]
|
|
|
|
The script:
|
|
1. Resolves repo path from the state-hub API (if --repo-path is not given)
|
|
2. Loads the agent prompt from prompts/sbom-capture-agent.md
|
|
3. Substitutes {repo_slug}, {repo_path}, {date} placeholders
|
|
4. Invokes `claude -p "<prompt>"` non-interactively
|
|
5. Extracts the YAML block from the response
|
|
6. Writes (or shows diff of) sbom-tools.yaml in the repo root
|
|
|
|
Requirements:
|
|
- `claude` CLI must be on PATH (Claude Code)
|
|
- PyYAML must be available in the active venv
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import datetime
|
|
import difflib
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/")
|
|
SCRIPT_DIR = Path(__file__).parent
|
|
PROMPT_FILE = SCRIPT_DIR.parent / "prompts" / "sbom-capture-agent.md"
|
|
|
|
|
|
def resolve_repo_path(repo_slug: str) -> Path | None:
|
|
"""Look up the registered path for a repo slug via the state-hub API."""
|
|
url = f"{API_BASE}/repos/{repo_slug}/"
|
|
try:
|
|
with urllib.request.urlopen(url, timeout=10) as resp:
|
|
data = json.loads(resp.read())
|
|
path_str = data.get("local_path")
|
|
if path_str:
|
|
return Path(path_str)
|
|
except (urllib.error.URLError, KeyError):
|
|
pass
|
|
return None
|
|
|
|
|
|
def load_prompt(repo_slug: str, repo_path: Path) -> str:
|
|
if not PROMPT_FILE.exists():
|
|
print(f"Error: prompt file not found at {PROMPT_FILE}", file=sys.stderr)
|
|
sys.exit(1)
|
|
template = PROMPT_FILE.read_text()
|
|
today = datetime.date.today().isoformat()
|
|
return (
|
|
template
|
|
.replace("{repo_slug}", repo_slug)
|
|
.replace("{repo_path}", str(repo_path))
|
|
.replace("{date}", today)
|
|
)
|
|
|
|
|
|
def invoke_agent(prompt: str) -> str:
|
|
"""Run `claude -p <prompt>` and return stdout."""
|
|
try:
|
|
result = subprocess.run(
|
|
["claude", "-p", prompt],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=120,
|
|
)
|
|
except FileNotFoundError:
|
|
print("Error: `claude` CLI not found on PATH. Install Claude Code.", file=sys.stderr)
|
|
sys.exit(1)
|
|
except subprocess.TimeoutExpired:
|
|
print("Error: claude invocation timed out after 120s.", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
if result.returncode != 0:
|
|
print(f"Error: claude exited with code {result.returncode}", file=sys.stderr)
|
|
if result.stderr:
|
|
print(result.stderr, file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
return result.stdout
|
|
|
|
|
|
def extract_yaml(response: str) -> str:
|
|
"""Extract YAML content from the agent response.
|
|
|
|
Accepts:
|
|
- Raw YAML (starts with # or 'tools:')
|
|
- YAML wrapped in ```yaml ... ``` fences
|
|
"""
|
|
# Try fenced block first
|
|
m = re.search(r"```(?:yaml)?\s*\n(.*?)```", response, re.DOTALL)
|
|
if m:
|
|
return m.group(1).strip()
|
|
|
|
# Otherwise treat entire response as YAML
|
|
stripped = response.strip()
|
|
if stripped.startswith("#") or stripped.startswith("tools:"):
|
|
return stripped
|
|
|
|
print("Warning: could not extract YAML from agent response.", file=sys.stderr)
|
|
print("Raw response:", file=sys.stderr)
|
|
print(response[:500], file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
def show_diff(old: str | None, new: str, target: Path) -> None:
|
|
if old is None:
|
|
print(f"[new file] {target}")
|
|
for line in new.splitlines():
|
|
print(f" + {line}")
|
|
else:
|
|
diff = list(difflib.unified_diff(
|
|
old.splitlines(keepends=True),
|
|
new.splitlines(keepends=True),
|
|
fromfile=f"a/{target.name}",
|
|
tofile=f"b/{target.name}",
|
|
))
|
|
if diff:
|
|
print("".join(diff))
|
|
else:
|
|
print(f"[no changes] {target}")
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(
|
|
description="Generate/update sbom-tools.yaml for a repo using the SBOM capture agent."
|
|
)
|
|
parser.add_argument("--repo", required=True, help="Repo slug (e.g. 'railiance-infra')")
|
|
parser.add_argument("--repo-path", help="Path to repo root (auto-resolved from state-hub if omitted)")
|
|
parser.add_argument("--dry-run", action="store_true",
|
|
help="Show prompt and diff without writing sbom-tools.yaml")
|
|
parser.add_argument("--print-prompt", action="store_true",
|
|
help="Print the rendered prompt and exit (useful for inspection)")
|
|
args = parser.parse_args()
|
|
|
|
# Resolve repo path
|
|
if args.repo_path:
|
|
repo_path = Path(args.repo_path).resolve()
|
|
else:
|
|
repo_path = resolve_repo_path(args.repo)
|
|
if repo_path is None:
|
|
# Fall back to ~/repo_slug convention
|
|
repo_path = Path.home() / args.repo
|
|
print(f"Could not resolve path from API; trying {repo_path}", file=sys.stderr)
|
|
|
|
if not repo_path.exists():
|
|
print(f"Error: repo path does not exist: {repo_path}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
target = repo_path / "sbom-tools.yaml"
|
|
existing_content = target.read_text() if target.exists() else None
|
|
|
|
prompt = load_prompt(args.repo, repo_path)
|
|
|
|
if args.print_prompt:
|
|
print(prompt)
|
|
return
|
|
|
|
print(f"Running SBOM capture agent for {args.repo} ({repo_path})…")
|
|
response = invoke_agent(prompt)
|
|
yaml_content = extract_yaml(response)
|
|
|
|
# Ensure trailing newline
|
|
if not yaml_content.endswith("\n"):
|
|
yaml_content += "\n"
|
|
|
|
show_diff(existing_content, yaml_content, target)
|
|
|
|
if args.dry_run:
|
|
print("\n[dry-run] sbom-tools.yaml not written.")
|
|
return
|
|
|
|
target.write_text(yaml_content)
|
|
print(f"\nWritten: {target}")
|
|
print("Review the file, correct any 'confidence: low' entries, then commit.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|