diff --git a/CHANGELOG.md b/CHANGELOG.md index b03d5c95..48740def 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,11 @@ All notable changes to vouch are documented here. Format follows strategy) instead of skipping it, so the capture / recall hooks land on projects that already have a settings file. idempotent; user entries are preserved. +- `vouch new ` — scaffold a typed page or entity proposal from the + page-kind registry: stubs required frontmatter fields, supports + `--field key=value`, `--interactive`, `--dry-run`, and `--json`; entity + kinds (`person`, `project`, …) route to `propose_entity`, with page kinds + taking precedence on name collisions unless `--entity` is set (#330). - GitHub PR auto-labeling: a pull-request metadata-only labeler workflow now applies vouch surface labels from `.github/labeler.yml`, keeps those labels in sync as files change, and adds OpenClaw-style `size: XS` through diff --git a/src/vouch/cli.py b/src/vouch/cli.py index f8b4a0c8..7f47bf6a 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -16,7 +16,7 @@ from dataclasses import asdict from datetime import UTC, datetime from pathlib import Path -from typing import Any +from typing import Any, Literal import click import yaml @@ -1018,8 +1018,8 @@ def propose_page_cmd( click.echo(pr.id) -def _parse_meta(pairs: tuple[str, ...]) -> dict[str, Any]: - """Parse repeated ``--meta key=value`` pairs into a frontmatter dict. +def _parse_meta(pairs: tuple[str, ...], *, flag: str = "--meta") -> dict[str, Any]: + """Parse repeated ``key=value`` pairs into a frontmatter dict. Values run through ``yaml.safe_load`` so ``attendees=[a, b]`` and ``count=3`` arrive as a list / int rather than strings. @@ -1027,12 +1027,257 @@ def _parse_meta(pairs: tuple[str, ...]) -> dict[str, Any]: out: dict[str, Any] = {} for pair in pairs: if "=" not in pair: - raise click.BadParameter(f"--meta expects key=value, got {pair!r}") + raise click.BadParameter(f"{flag} expects key=value, got {pair!r}") key, _, raw = pair.partition("=") - out[key.strip()] = yaml.safe_load(raw) + key = key.strip() + try: + out[key] = yaml.safe_load(raw) + except yaml.YAMLError as e: + raise click.BadParameter( + f"{flag} value for {key!r} is invalid YAML: {e}", + ) from e return out +# Keep entity scaffolding intentionally narrow because `propose_entity` accepts +# arbitrary type strings; only well-known EntityType names are routed here. +_SCAFFOLD_ENTITY_TYPES: frozenset[str] = frozenset( + {"person", "project", "repo", "company", "concept", "decision", "workflow"} +) + +_CITATION_REMINDER = ( + "\n\n\n" +) + + +def _field_missing(value: Any) -> bool: + return value is None or value == "" or value == [] or value == {} + + +def _resolve_new_kind( + kind: str, + registry: Any, + *, + force_entity: bool, +) -> tuple[Literal["page", "entity"], str]: + if force_entity: + if kind not in _SCAFFOLD_ENTITY_TYPES: + known = ", ".join(sorted(_SCAFFOLD_ENTITY_TYPES)) + raise click.ClickException(f"unknown entity type {kind!r} (known: {known})") + return "entity", kind + if registry.is_known(kind): + return "page", kind + if kind in _SCAFFOLD_ENTITY_TYPES: + return "entity", kind + page_kinds = ", ".join(sorted(registry.known())) + entity_kinds = ", ".join(sorted(_SCAFFOLD_ENTITY_TYPES)) + raise click.ClickException( + f"unknown kind {kind!r}; page kinds: {page_kinds}; entity kinds: {entity_kinds}" + ) + + +def _stub_page_frontmatter( + registry: Any, + kind: str, + prefilled: dict[str, Any], +) -> tuple[dict[str, Any], list[str], bool]: + required, _schema, required_citations = registry.resolve(kind) + metadata = dict(prefilled) + for field in required: + metadata.setdefault(field, "") + missing = [f for f in required if _field_missing(metadata.get(f))] + return metadata, missing, required_citations + + +def _prompt_missing_fields( + missing: list[str], + metadata: dict[str, Any], +) -> list[str]: + still_missing: list[str] = [] + for field in missing: + raw = click.prompt(field, default="", show_default=False) + if raw: + try: + metadata[field] = yaml.safe_load(raw) + except yaml.YAMLError as e: + raise click.BadParameter( + f"interactive value for {field!r} is invalid YAML: {e}", + ) from e + else: + metadata[field] = "" + if _field_missing(metadata.get(field)): + still_missing.append(field) + return still_missing + + +def _print_new_page_draft(draft: dict[str, Any]) -> None: + click.echo(f"kind: {draft['kind']} (page)") + click.echo(f"title: {draft['title']}") + fm = yaml.safe_dump(draft["frontmatter"], default_flow_style=True).strip() + click.echo(f"frontmatter: {fm}") + missing = draft["missing_required_fields"] + if missing: + click.echo(f"missing required fields: {', '.join(missing)}") + else: + click.echo("missing required fields: (none)") + if draft["citation_reminder"]: + click.echo("citations: required (reminder appended to body)") + if draft.get("body"): + click.echo(f"body:\n{draft['body']}") + if draft.get("id"): + click.echo(f"proposal id (dry-run): {draft['id']}") + + +def _print_new_entity_draft(draft: dict[str, Any]) -> None: + click.echo(f"kind: {draft['kind']} (entity)") + click.echo(f"name: {draft['name']}") + if draft.get("id"): + click.echo(f"proposal id (dry-run): {draft['id']}") + + +@cli.command(name="new") +@click.argument("kind") +@click.option("--title", default=None, help="Page title (required for page kinds).") +@click.option("--name", default=None, help="Entity name (required for entity kinds).") +@click.option( + "--field", + "fields", + multiple=True, + help="Pre-fill a frontmatter field as key=value (repeatable). Value parsed as YAML.", +) +@click.option("--interactive", "-i", is_flag=True, help="Prompt for unfilled required fields.") +@click.option("--body", default="", help="Page body. Use `-` to read from stdin.") +@click.option("--claim", "claims", multiple=True) +@click.option("--source", "sources", multiple=True) +@click.option("--entity", "force_entity", is_flag=True, help="Force entity scaffold path.") +@click.option("--dry-run", is_flag=True, help="Print assembled draft without creating a proposal.") +@click.option("--json", "as_json", is_flag=True, help="Emit machine-readable JSON.") +def new_cmd( + kind: str, + title: str | None, + name: str | None, + fields: tuple[str, ...], + interactive: bool, + body: str, + claims: tuple[str, ...], + sources: tuple[str, ...], + force_entity: bool, + dry_run: bool, + as_json: bool, +) -> None: + """Scaffold a typed page or entity proposal from the page-kind registry.""" + store = _load_store() + registry = load_page_kind_registry(store) + target, resolved_kind = _resolve_new_kind(kind, registry, force_entity=force_entity) + + if target == "entity": + if not name or not name.strip(): + raise click.ClickException("--name is required for entity kinds") + draft: dict[str, Any] = { + "dry_run": dry_run, + "target": "entity", + "kind": resolved_kind, + "name": name.strip(), + } + if dry_run: + with _cli_errors(): + pr = propose_entity( + store, + name=name, + entity_type=resolved_kind, + proposed_by=_whoami(), + dry_run=True, + ) + draft["id"] = pr.id + if as_json: + _emit_json(draft) + else: + _print_new_entity_draft(draft) + return + with _cli_errors(): + pr = propose_entity( + store, + name=name, + entity_type=resolved_kind, + proposed_by=_whoami(), + ) + if as_json: + _emit_json({"id": pr.id}) + return + click.echo(pr.id) + return + + if not title or not title.strip(): + raise click.ClickException("--title is required for page kinds") + if body == "-": + body = sys.stdin.read() + + metadata = _parse_meta(fields, flag="--field") + metadata, missing, requires_citations = _stub_page_frontmatter( + registry, resolved_kind, metadata, + ) + if interactive and missing: + missing = _prompt_missing_fields(missing, metadata) + + citation_reminder = requires_citations and not (claims or sources) + if citation_reminder and not dry_run: + raise click.ClickException( + "this page kind requires citations; pass --claim/--source, " + "or rerun with --dry-run to print a draft with the citation reminder" + ) + if citation_reminder: + body = body + _CITATION_REMINDER + + page_draft: dict[str, Any] = { + "dry_run": dry_run, + "target": "page", + "kind": resolved_kind, + "title": title.strip(), + "frontmatter": metadata, + "body": body, + "missing_required_fields": missing, + "citation_reminder": citation_reminder, + } + + if dry_run: + if not missing and not (requires_citations and not (claims or sources)): + with _cli_errors(): + pr = propose_page( + store, + title=title, + body=body, + page_type=resolved_kind, + claim_ids=list(claims), + source_ids=list(sources), + metadata=metadata, + proposed_by=_whoami(), + dry_run=True, + ) + page_draft["id"] = pr.id + if as_json: + _emit_json(page_draft) + else: + _print_new_page_draft(page_draft) + return + + with _cli_errors(): + pr = propose_page( + store, + title=title, + body=body, + page_type=resolved_kind, + claim_ids=list(claims), + source_ids=list(sources), + metadata=metadata, + proposed_by=_whoami(), + ) + if as_json: + _emit_json({"id": pr.id}) + return + click.echo(pr.id) + + @cli.group(name="schema") def schema() -> None: """inspect and validate config-declared page kinds (issue #234).""" diff --git a/tests/test_cli.py b/tests/test_cli.py index 73901b00..50f8841e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -12,14 +12,16 @@ import json from pathlib import Path +from typing import Any from unittest.mock import patch import pytest +import yaml from click.testing import CliRunner from vouch import sessions as sess_mod from vouch.cli import cli -from vouch.models import ProposalStatus +from vouch.models import ProposalKind, ProposalStatus from vouch.proposals import propose_claim from vouch.storage import KBStore @@ -427,3 +429,176 @@ def test_propose_claim_corrupt_source_surfaces_real_error(store: KBStore) -> Non isinstance(excinfo.value, ProposalError) and "unknown source/evidence id" in str(excinfo.value) ), f"real parse error was masked: {excinfo.value}" + + +# --- vouch new (issue #330) ------------------------------------------------ + + +def _declare_page_kinds(store: KBStore, kinds: dict[str, Any]) -> None: + cfg = yaml.safe_load(store.config_path.read_text()) + assert isinstance(cfg, dict) + cfg["page_kinds"] = kinds + store.config_path.write_text(yaml.safe_dump(cfg)) + + +def test_new_decision_page_stubs_frontmatter(store: KBStore) -> None: + runner = CliRunner() + res = runner.invoke(cli, ["new", "decision", "--title", "pick X"]) + assert res.exit_code == 0, res.output + proposal_id = res.output.strip() + pr = store.get_proposal(proposal_id) + assert pr.kind == ProposalKind.PAGE + assert pr.status == ProposalStatus.PENDING + assert pr.payload["type"] == "decision" + assert pr.payload["title"] == "pick X" + assert pr.payload["metadata"] == {} + + +def test_new_person_entity(store: KBStore) -> None: + runner = CliRunner() + res = runner.invoke(cli, ["new", "person", "--name", "alice-example"]) + assert res.exit_code == 0, res.output + pr = store.get_proposal(res.output.strip()) + assert pr.kind == ProposalKind.ENTITY + assert pr.payload["type"] == "person" + assert pr.payload["name"] == "alice-example" + + +def test_new_field_prefill_parsed_as_yaml(store: KBStore) -> None: + _declare_page_kinds(store, {"meeting-notes": {"required_fields": ["attendees", "date"]}}) + runner = CliRunner() + res = runner.invoke( + cli, + [ + "new", + "meeting-notes", + "--title", + "Sync", + "--field", + "attendees=[alice-example, bob-example]", + "--field", + "date=2026-07-01", + ], + ) + assert res.exit_code == 0, res.output + pr = store.get_proposal(res.output.strip()) + assert pr.payload["metadata"]["attendees"] == ["alice-example", "bob-example"] + assert pr.payload["metadata"]["date"] == "2026-07-01" + + +def test_new_interactive_prompts_required_fields(store: KBStore) -> None: + _declare_page_kinds(store, {"meeting-notes": {"required_fields": ["attendees", "date"]}}) + runner = CliRunner() + res = runner.invoke( + cli, + ["new", "meeting-notes", "--title", "Sync", "--interactive"], + input="[alice-example, bob-example]\n2026-07-01\n", + ) + assert res.exit_code == 0, res.output + pending = store.list_proposals(ProposalStatus.PENDING) + assert len(pending) == 1 + pr = pending[0] + assert pr.payload["metadata"]["attendees"] == ["alice-example", "bob-example"] + assert pr.payload["metadata"]["date"] == "2026-07-01" + + +def test_new_dry_run_writes_nothing(store: KBStore) -> None: + _declare_page_kinds(store, {"meeting-notes": {"required_fields": ["attendees"]}}) + runner = CliRunner() + res = runner.invoke( + cli, + ["new", "meeting-notes", "--title", "Sync", "--dry-run"], + ) + assert res.exit_code == 0, res.output + assert "missing required fields: attendees" in res.output + assert store.list_proposals(ProposalStatus.PENDING) == [] + + +def test_new_dry_run_json(store: KBStore) -> None: + runner = CliRunner() + res = runner.invoke( + cli, + ["new", "decision", "--title", "pick X", "--dry-run", "--json"], + ) + assert res.exit_code == 0, res.output + body = json.loads(res.output) + assert body["dry_run"] is True + assert body["target"] == "page" + assert body["kind"] == "decision" + assert body["title"] == "pick X" + assert "id" in body + assert store.list_proposals(ProposalStatus.PENDING) == [] + + +def test_new_collision_decision_defaults_to_page(store: KBStore) -> None: + runner = CliRunner() + res = runner.invoke(cli, ["new", "decision", "--title", "pick Y"]) + assert res.exit_code == 0, res.output + pr = store.get_proposal(res.output.strip()) + assert pr.kind == ProposalKind.PAGE + + +def test_new_collision_decision_entity_flag(store: KBStore) -> None: + runner = CliRunner() + res = runner.invoke( + cli, + ["new", "decision", "--entity", "--name", "pick-z"], + ) + assert res.exit_code == 0, res.output + pr = store.get_proposal(res.output.strip()) + assert pr.kind == ProposalKind.ENTITY + assert pr.payload["type"] == "decision" + + +def test_new_project_routes_to_entity(store: KBStore) -> None: + runner = CliRunner() + res = runner.invoke(cli, ["new", "project", "--name", "vouch-example"]) + assert res.exit_code == 0, res.output + pr = store.get_proposal(res.output.strip()) + assert pr.kind == ProposalKind.ENTITY + assert pr.payload["type"] == "project" + + +def test_new_unknown_kind_lists_known(store: KBStore) -> None: + runner = CliRunner() + res = runner.invoke(cli, ["new", "not-a-kind", "--title", "X"]) + assert res.exit_code != 0, res.output + assert "unknown kind" in res.output + assert "decision" in res.output + assert "person" in res.output + + +def test_new_required_citations_reminder_in_body(store: KBStore) -> None: + _declare_page_kinds(store, {"cited": {"required_citations": True}}) + runner = CliRunner() + res = runner.invoke(cli, ["new", "cited", "--title", "needs cites", "--dry-run"]) + assert res.exit_code == 0, res.output + assert "citations required" in res.output + assert "attach at least one claim or source" in res.output + + +def test_new_required_citations_non_dry_run_errors(store: KBStore) -> None: + _declare_page_kinds(store, {"cited": {"required_citations": True}}) + runner = CliRunner() + res = runner.invoke(cli, ["new", "cited", "--title", "needs cites"]) + assert res.exit_code != 0, res.output + assert "requires citations" in res.output + + +def test_new_invalid_field_yaml(store: KBStore) -> None: + runner = CliRunner() + res = runner.invoke( + cli, + ["new", "decision", "--title", "X", "--field", "x=["], + ) + assert res.exit_code != 0, res.output + assert "invalid YAML" in res.output + + +def test_new_pending_not_approved(store: KBStore) -> None: + runner = CliRunner() + res = runner.invoke(cli, ["new", "concept", "--title", "draft page"]) + assert res.exit_code == 0, res.output + assert len(store.list_pages()) == 0 + pending = store.list_proposals(ProposalStatus.PENDING) + assert len(pending) == 1