repo-manager/src/repo_manager/cli.py

415 lines
17 KiB
Python
Raw Normal View History

"""CLI entry point ``rmgr``."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from repo_manager.commands.rapp import add_rapp_parser
from repo_manager.commands.rapp import init as rapp_init
from repo_manager.commands.rapp import pin_image as rapp_pin_image
from repo_manager.commands.rapp import place as rapp_place
from repo_manager.commands.rapp import skeleton as rapp_skeleton
from repo_manager.commands.rapp import validate as rapp_validate
from repo_manager.commands.rapp import wrap as rapp_wrap
def _json_object(raw: str) -> dict:
try:
value = json.loads(raw)
except json.JSONDecodeError as exc:
raise argparse.ArgumentTypeError(str(exc)) from exc
if not isinstance(value, dict):
raise argparse.ArgumentTypeError("value must be a JSON object")
return value
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="rmgr",
description="Repo Manager CLI (helixforge.repo-manager)",
)
parser.add_argument("--version", action="store_true", help="Print package version")
sub = parser.add_subparsers(dest="command")
sub.add_parser("version", help="Print version")
sub.add_parser("dual-run-status", help="Show dual-run flags and mutation meter")
p_obs = sub.add_parser("observe", help="Observe repository + print snapshot JSON")
p_obs.add_argument("--path", default=".", help="Repository checkout path")
p_obs.add_argument("--slug", default=None, help="Override repo slug")
p_rec = sub.add_parser("reconcile", help="Rebuild local work-record index from files")
p_rec.add_argument("--path", default=".", help="Repository checkout path")
p_rec.add_argument("--slug", default=None)
p_rec.add_argument("--no-write-index", action="store_true", help="Do not write index file")
p_cmd = sub.add_parser(
"update-task-status",
help="Command repo.work.update_task_status (file + git commit)",
)
p_cmd.add_argument("--path", default=".", help="Repository checkout path")
p_cmd.add_argument(
"--task-id",
required=True,
help="Canonical task id or State Hub task UUID",
)
p_cmd.add_argument(
"--status",
required=True,
choices=["wait", "todo", "progress", "done", "cancel"],
)
p_cmd.add_argument("--reason", default="rmgr CLI")
p_cmd.add_argument("--correlation-id", default=None)
p_cmd.add_argument("--idempotency-key", default=None)
p_cmd.add_argument("--expected-head-sha", default=None)
p_cmd.add_argument("--slug", default=None)
p_cmd.add_argument("--push", action="store_true", help="git push after commit (push-seal)")
p_cmd.add_argument(
"--no-commit",
action="store_true",
help="Patch file only (invalid as full applied evidence; for tests)",
)
p_wp = sub.add_parser("workplan", help="Governed file-backed workplan mutations")
wp_sub = p_wp.add_subparsers(dest="workplan_command")
p_wp_create = wp_sub.add_parser("create", help="Create a workplan file")
p_wp_create.add_argument("--path", default=".")
p_wp_create.add_argument("--workplan-id", required=True)
p_wp_create.add_argument("--title", required=True)
p_wp_create.add_argument("--goal", required=True)
p_wp_create.add_argument(
"--status",
choices=["proposed", "ready", "active", "blocked", "backlog", "finished", "archived"],
default="proposed",
)
p_wp_create.add_argument("--owner", default="codex")
p_wp_create.add_argument("--domain", default=None)
p_wp_create.add_argument("--topic-slug", default=None)
p_wp_create.add_argument("--filename", default=None)
p_wp_update = wp_sub.add_parser("update", help="Update workplan metadata or status")
p_wp_update.add_argument("--path", default=".")
p_wp_update.add_argument("--workplan-id", required=True)
p_wp_update.add_argument("--title", default=None)
p_wp_update.add_argument(
"--status",
choices=["proposed", "ready", "active", "blocked", "backlog", "finished", "archived"],
default=None,
)
p_wp_update.add_argument("--owner", default=None)
p_wp_update.add_argument("--domain", default=None)
p_wp_update.add_argument("--topic-slug", default=None)
p_wp_archive = wp_sub.add_parser(
"delete",
help="Recoverable delete: set archived and move to workplans/archived",
)
p_wp_archive.add_argument("--path", default=".")
p_wp_archive.add_argument("--workplan-id", required=True)
p_wp_archive.add_argument(
"--confirm",
action="store_true",
help="Confirm moving the workplan to the dated archive",
)
for wp_parser in (p_wp_create, p_wp_update, p_wp_archive):
wp_parser.add_argument("--reason", default="rmgr CLI")
wp_parser.add_argument("--correlation-id", default=None)
wp_parser.add_argument("--idempotency-key", default=None)
wp_parser.add_argument("--expected-head-sha", default=None)
wp_parser.add_argument("--slug", default=None)
wp_parser.add_argument("--push", action="store_true")
wp_parser.add_argument("--no-commit", action="store_true")
register_kinds = [
"sbom-inventory",
"repo-goals",
"upstream-contributions",
"technical-debt",
"extension-points",
"register-entries",
]
p_register = sub.add_parser("register", help="Repository-owned register spine")
register_sub = p_register.add_subparsers(dest="register_command")
p_reg_list = register_sub.add_parser("list", help="List indexed register entries")
p_reg_list.add_argument("--path", default=".")
p_reg_list.add_argument("--kind", choices=register_kinds, default=None)
p_reg_list.add_argument("--slug", default=None)
p_reg_put = register_sub.add_parser("put", help="Create or update a register entry")
p_reg_put.add_argument("--path", default=".")
p_reg_put.add_argument("--kind", required=True, choices=register_kinds)
p_reg_put.add_argument("--entry-id", required=True)
p_reg_put.add_argument("--title", default=None)
p_reg_put.add_argument("--status", default=None)
p_reg_put.add_argument("--data-json", type=_json_object, default=None)
p_reg_defer = register_sub.add_parser("defer", help="Defer a register entry")
p_reg_defer.add_argument("--path", default=".")
p_reg_defer.add_argument("--kind", required=True, choices=register_kinds)
p_reg_defer.add_argument("--entry-id", required=True)
p_reg_defer.add_argument("--status", default="deferred")
p_reg_note = register_sub.add_parser("note", help="Append a note to a register entry")
p_reg_note.add_argument("--path", default=".")
p_reg_note.add_argument("--kind", required=True, choices=register_kinds)
p_reg_note.add_argument("--entry-id", required=True)
p_reg_note.add_argument("--note", required=True)
p_reg_note.add_argument("--author", default="codex")
for register_parser in (p_reg_put, p_reg_defer, p_reg_note):
register_parser.add_argument("--reason", default="rmgr CLI")
register_parser.add_argument("--correlation-id", default=None)
register_parser.add_argument("--idempotency-key", default=None)
register_parser.add_argument("--expected-head-sha", default=None)
register_parser.add_argument("--slug", default=None)
register_parser.add_argument("--push", action="store_true")
register_parser.add_argument("--no-commit", action="store_true")
add_rapp_parser(sub)
p_conf = sub.add_parser("conform", help="Check a repository against flavor standards")
p_conf.add_argument("--path", default=".", help="Repository checkout path")
p_conf.add_argument("--slug", default=None)
p_pref = sub.add_parser(
"prefix-uniqueness",
help="Detect shared workplan prefixes and reused identifiers (ADR-007)",
)
p_pref.add_argument("--root", default=".", help="Fleet root or a single repository")
p_pref.add_argument("--registry", default=None, help="Override prefix registry YAML")
p_scaf = sub.add_parser("scaffold", help="Create flavor-correct repository baseline files")
p_scaf.add_argument("--path", required=True)
p_scaf.add_argument("--flavor", required=True, choices=["experimental", "research", "project", "tooling", "product", "business"])
p_scaf.add_argument("--slug", default=None)
p_scaf.add_argument("--domain", default="infotech")
p_scaf.add_argument("--wp-prefix", default=None)
p_scaf.add_argument("--force", action="store_true")
p_scaf.add_argument("--no-commit", action="store_true")
args = parser.parse_args(argv)
if args.version or args.command in (None, "version"):
from repo_manager import __version__
print(__version__)
return 0
if args.command == "dual-run-status":
from repo_manager.dual_run import flags_status
print(json.dumps(flags_status(), indent=2))
return 0
if args.command == "observe":
from repo_manager.observe import observe_repository
snap, _idx = observe_repository(Path(args.path), slug=args.slug)
print(json.dumps(snap, indent=2))
return 0
if args.command == "reconcile":
from repo_manager.dual_run import record_mutation
from repo_manager.index_store import append_event, save_index
from repo_manager.observe import observe_repository
root = Path(args.path)
snap, index = observe_repository(root, slug=args.slug)
append_event(
index,
{
"type": "repo.reconciled",
"workplan_count": snap["index"]["workplan_count"],
"task_count": snap["index"]["task_count"],
"source": "repo-manager",
},
)
record_mutation(
source="repo-manager",
kind="reconcile",
repo_slug=snap.get("slug"),
detail=snap["index"],
)
if not args.no_write_index:
path = save_index(index)
print(json.dumps({"ok": True, "index_path": str(path), "snapshot": snap}, indent=2))
else:
print(json.dumps({"ok": True, "snapshot": snap, "index": index.to_dict()}, indent=2))
return 0
if args.command == "update-task-status":
from repo_manager.commands.task_status import update_task_status
result = update_task_status(
Path(args.path),
args.task_id,
args.status,
correlation_id=args.correlation_id,
reason=args.reason,
commit=not args.no_commit,
push=args.push,
expected_head_sha=args.expected_head_sha,
idempotency_key=args.idempotency_key,
repo_slug=args.slug,
)
print(json.dumps(result.to_dict(), indent=2))
return 0 if result.status == "applied" else 1
if args.command == "workplan":
from repo_manager.commands.workplan import mutate_workplan
if not args.workplan_command:
p_wp.print_help()
return 2
operation = "archive" if args.workplan_command == "delete" else args.workplan_command
result = mutate_workplan(
Path(args.path),
args.workplan_id,
operation=operation,
title=getattr(args, "title", None),
goal=getattr(args, "goal", None),
status=getattr(args, "status", None),
owner=getattr(args, "owner", None),
topic_slug=getattr(args, "topic_slug", None),
domain=getattr(args, "domain", None),
filename=getattr(args, "filename", None),
confirm_archive=getattr(args, "confirm", False),
correlation_id=args.correlation_id,
reason=args.reason,
commit=not args.no_commit,
push=args.push,
expected_head_sha=args.expected_head_sha,
idempotency_key=args.idempotency_key,
repo_slug=args.slug,
)
print(json.dumps(result.to_dict(), indent=2))
return 0 if result.status == "applied" else 1
if args.command == "register":
if not args.register_command:
p_register.print_help()
return 2
if args.register_command == "list":
from repo_manager.observe import observe_repository
_snapshot, index = observe_repository(Path(args.path), slug=args.slug)
prefix = f"register:{args.kind}" if args.kind else "register:"
entries = [r.__dict__ for r in index.work_records if r.kind.startswith(prefix)]
print(json.dumps({"ok": True, "entries": entries}, indent=2))
return 0
from repo_manager.commands.register import mutate_register_entry
result = mutate_register_entry(
Path(args.path),
args.kind,
args.entry_id,
operation="upsert" if args.register_command == "put" else args.register_command,
title=getattr(args, "title", None),
status=getattr(args, "status", None),
data=getattr(args, "data_json", None),
note=getattr(args, "note", None),
note_author=getattr(args, "author", None),
correlation_id=args.correlation_id,
reason=args.reason,
commit=not args.no_commit,
push=args.push,
expected_head_sha=args.expected_head_sha,
idempotency_key=args.idempotency_key,
repo_slug=args.slug,
)
print(json.dumps(result.to_dict(), indent=2))
return 0 if result.status == "applied" else 1
if args.command == "rapp":
if args.rapp_command == "init":
result = rapp_init(
Path(args.path),
app=args.app,
ownership_repo=args.ownership_repo,
rail=args.rail,
classification=args.classification,
criticality=args.criticality,
package_type=args.package_type,
purpose=args.purpose,
force=args.force,
)
elif args.rapp_command == "validate":
result = rapp_validate(
Path(args.path),
family_root=Path(args.family_root) if args.family_root else None,
)
elif args.rapp_command == "skeleton":
result = rapp_skeleton(
Path(args.path),
from_app=Path(args.from_app),
app=args.app,
package_type=args.package_type,
force=args.force,
dedicated_postgres=args.dedicated_postgres,
)
elif args.rapp_command == "wrap":
result = rapp_wrap(
Path(args.path),
app=args.app,
ownership_repo=args.ownership_repo,
from_app=Path(args.from_app),
rail=args.rail,
classification=args.classification,
criticality=args.criticality,
package_type=args.package_type,
purpose=args.purpose,
force=args.force,
dedicated_postgres=args.dedicated_postgres,
family_root=Path(args.family_root) if args.family_root else None,
)
elif args.rapp_command == "place":
result = rapp_place(
Path(args.path),
reef=args.reef,
family_root=Path(args.family_root) if args.family_root else None,
)
elif args.rapp_command == "pin-image":
result = rapp_pin_image(Path(args.path), args.digest)
else:
parser.print_help()
return 2
print(json.dumps(result, indent=2))
return 0 if result.get("ok") else 1
if args.command == "conform":
from repo_manager.standards import check_repository
report = check_repository(Path(args.path), slug=args.slug)
print(json.dumps(report.to_dict(), indent=2))
return 0 if report.ok else 1
if args.command == "prefix-uniqueness":
from repo_manager.prefix_registry import scan_prefixes
registry = Path(args.registry) if args.registry else None
report = scan_prefixes(Path(args.root), registry_path=registry)
print(json.dumps(report, indent=2))
return 0 if report.get("ok") else 1
if args.command == "scaffold":
from repo_manager.commands.scaffold import scaffold_repository
result = scaffold_repository(
Path(args.path),
flavor=args.flavor,
slug=args.slug,
domain=args.domain,
wp_prefix=args.wp_prefix,
force=args.force,
commit=not args.no_commit,
)
print(json.dumps(result.to_dict(), indent=2))
return 0 if result.status == "applied" else 1
parser.print_help()
return 0
if __name__ == "__main__":
raise SystemExit(main())