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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,25 @@ the context binding, the local backlog and the event log sitting beside the
ticket directories are not names the command avoids — they are names it cannot
produce.

`wb surface` prints every group, action and flag, read off the live parser
rather than a list kept by hand. `wb surface <group>` narrows it, `--json` is
the form a session reads before composing an unfamiliar call:

```
$ wb surface pr
wb pr context branch, commits, plan summary, verification verdict
key [positional; required]
--base <value>
--target <value>
wb pr check reject filler, empty sections and placeholders in a draft
--file <value> [required]
--shape <value> [one of: trivial, small, large]
```

This is the schema MCP publishes for every tool in every session. Here it costs
nothing until something asks: no skill names it, and a flag that was never in
the parser cannot appear in it.

### Rigour proportional to risk

Ceremony has a cost that is not measured in minutes: a process too heavy for a
Expand Down
2 changes: 2 additions & 0 deletions lib/wb.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
from workbench.cli import route as route_cli # noqa: E402
from workbench.cli import sdd as sdd_cli # noqa: E402
from workbench.cli import status as status_cli # noqa: E402
from workbench.cli import surface as surface_cli # noqa: E402
from workbench.cli import task as task_cli # noqa: E402
from workbench.errors import EXIT_USAGE, UsageError, WbError # noqa: E402

Expand All @@ -56,6 +57,7 @@
"commit": commit_cli,
"pr": pr_cli,
"git": git_cli,
"surface": surface_cli,
}


Expand Down
139 changes: 139 additions & 0 deletions lib/workbench/cli/surface.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
"""``wb surface`` -- every group, action and flag, read off the live parser.

A session composing an unfamiliar call has otherwise to infer the flags from
prose in a SKILL.md, and prose is where a flag that never existed comes from:
``wb pr check --key ABC-1`` was written that way, and argparse refused it.

The alternative shape is the one MCP uses -- publish a typed schema for every
tool and pay for it in the system prompt of every session, used or not. This
plugin already refuses that trade for its own commands: ``wb route`` exists as a
command rather than an eleventh skill for exactly this reason. So the schema is
here, complete, and costs nothing until something asks for it.

Walked from ``wb.build_parser()`` rather than kept by hand, because a
hand-kept list of flags is a second description of the CLI, and a second
description is the thing that drifts.
"""

from __future__ import annotations

import argparse

from .. import contract

ACTIONS: list[str] = []


def register(subparsers: argparse._SubParsersAction) -> None:
parser = subparsers.add_parser("surface", help="every group, action and flag this CLI accepts")
# Not ``group``: the top-level parser already owns that dest, and a
# positional of the same name overwrote it -- `wb surface task` dispatched
# to `wb task` and failed there.
parser.add_argument("of", nargs="?", metavar="GROUP", help="one group; omit for all of them")
parser.add_argument("--json", action="store_true", help="machine-readable, for a session composing a call")


def run(args: argparse.Namespace) -> int:
groups = _walk()
if args.of:
groups = [entry for entry in groups if entry["group"] == args.of]
if not groups:
from ..errors import UsageError

known = ", ".join(entry["group"] for entry in _walk())
raise UsageError(f"no such group: {args.of}", fix=[f"groups: {known}"])

if args.json:
print(contract.emit("surface", {"groups": groups}))
return 0

for entry in groups:
for line in _render(entry):
print(line)
return 0


def _walk() -> list[dict]:
"""Every group, its actions, and the arguments each one accepts.

Imported here rather than at module scope: ``wb`` imports this module to
build the parser, so importing it back at load time would not resolve.
"""
import wb

parser = wb.build_parser()
groups = []
for name, sub in _choices(parser).items():
actions = _choices(sub)
if actions:
entries = [
{"action": action, "help": _help(sub, action), "arguments": _arguments(body)}
for action, body in actions.items()
]
else:
entries = [{"action": "", "help": sub.description or "", "arguments": _arguments(sub)}]
groups.append({"group": name, "actions": entries})
return groups


def _choices(parser: argparse.ArgumentParser) -> dict:
for action in parser._actions:
if isinstance(action, argparse._SubParsersAction):
return dict(action.choices)
return {}


def _help(parent: argparse.ArgumentParser, name: str) -> str:
"""The one-line help argparse recorded for a subcommand."""
for action in parent._actions:
if not isinstance(action, argparse._SubParsersAction):
continue
for choice in action._get_subactions():
if choice.dest == name:
return choice.help or ""
return ""


def _arguments(parser: argparse.ArgumentParser) -> list[dict]:
arguments = []
for action in parser._actions:
if isinstance(action, (argparse._SubParsersAction, argparse._HelpAction)):
continue
entry: dict = {
"name": action.option_strings[0] if action.option_strings else action.dest,
"positional": not action.option_strings,
"required": bool(action.required),
"help": action.help or "",
}
if len(action.option_strings) > 1:
entry["aliases"] = action.option_strings[1:]
if action.choices:
entry["choices"] = [str(choice) for choice in action.choices]
if action.nargs is not None:
entry["nargs"] = str(action.nargs)
# A flag that takes no value: worth saying, because the commonest
# invented call passes one to a switch.
entry["takes_value"] = not isinstance(
action, (argparse._StoreTrueAction, argparse._StoreFalseAction, argparse._CountAction)
)
arguments.append(entry)
return arguments


def _render(entry: dict) -> list[str]:
lines = []
for action in entry["actions"]:
name = f"wb {entry['group']} {action['action']}".rstrip()
lines.append(f"{name}{' ' + action['help'] if action['help'] else ''}")
for argument in action["arguments"]:
shape = "" if argument["positional"] or not argument["takes_value"] else " <value>"
marks = []
if argument["positional"]:
marks.append("positional")
if argument["required"]:
marks.append("required")
if argument.get("choices"):
marks.append("one of: " + ", ".join(argument["choices"]))
suffix = f" [{'; '.join(marks)}]" if marks else ""
lines.append(f" {argument['name']}{shape}{suffix}")
return lines
1 change: 1 addition & 0 deletions lib/workbench/contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"review.context": 1,
"review.gates": 1,
"pr.context": 1,
"surface": 1,
}

# The keys a consumer may rely on being present. Recorded rather than described,
Expand Down
48 changes: 48 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,54 @@ def test_work_that_started_before_a_ticket_cleans_the_same_way(self) -> None:
self.assertFalse(directory.exists())


class Surface_(CliBase):
"""What a session should read instead of inventing a flag from prose.

`wb pr check --key ABC-1` was composed that way and refused by argparse,
because --key was inferred from a SKILL.md rather than from the CLI.
"""

def test_every_group_is_listed(self) -> None:
payload = json.loads(run("surface", "--json")[1])
listed = {entry["group"] for entry in payload["groups"]}
self.assertEqual(set(wb.GROUPS), listed)

def test_it_names_the_flags_a_command_actually_takes(self) -> None:
payload = json.loads(run("surface", "pr", "--json")[1])
check = next(a for a in payload["groups"][0]["actions"] if a["action"] == "check")
names = {argument["name"] for argument in check["arguments"]}
self.assertEqual({"--file", "--shape"}, names)
self.assertNotIn("--key", names)

def test_a_switch_is_marked_as_taking_no_value(self) -> None:
payload = json.loads(run("surface", "task", "--json")[1])
clean = next(a for a in payload["groups"][0]["actions"] if a["action"] == "clean")
force = next(a for a in clean["arguments"] if a["name"] == "--force")
self.assertFalse(force["takes_value"])

def test_a_closed_choice_travels_with_the_flag(self) -> None:
payload = json.loads(run("surface", "pr", "--json")[1])
check = next(a for a in payload["groups"][0]["actions"] if a["action"] == "check")
shape = next(a for a in check["arguments"] if a["name"] == "--shape")
self.assertEqual(["trivial", "small", "large"], shape["choices"])

def test_naming_a_group_does_not_dispatch_to_it(self) -> None:
"""Regression: the positional was named `group`, the dest the top-level
parser already owns, so `wb surface task` ran `wb task` instead."""
code, out, _ = run("surface", "task")
self.assertEqual(0, code)
self.assertIn("wb task clean", out)

def test_an_unknown_group_is_refused_with_the_real_ones(self) -> None:
code, _, err = run("surface", "nope")
self.assertEqual(EXIT_USAGE, code)
self.assertIn("status", err)

def test_it_needs_no_context_and_no_checkout(self) -> None:
"""The command a session runs when it is lost must not need setup."""
self.assertEqual(0, run("surface")[0])


class Status(CliBase):
def test_an_empty_repo_says_so_and_suggests_a_start(self) -> None:
code, out, _ = run("status")
Expand Down
Loading