Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <kind>` — 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
Expand Down
255 changes: 250 additions & 5 deletions src/vouch/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1018,21 +1018,266 @@ 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.
"""
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<!-- citations required: attach at least one claim or source "
"(--claim <id> / --source <id> on vouch new) -->\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)
Comment thread
jsdevninja marked this conversation as resolved.

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
Comment thread
jsdevninja marked this conversation as resolved.

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})
Comment thread
jsdevninja marked this conversation as resolved.
return
click.echo(pr.id)


@cli.group(name="schema")
def schema() -> None:
"""inspect and validate config-declared page kinds (issue #234)."""
Expand Down
Loading
Loading