-
Notifications
You must be signed in to change notification settings - Fork 1
feat(tooling-ux): minimal 3-tool surface with progressive discovery + escalation #48
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
829a3e6
feat(tooling-ux): minimal 3-tool surface with progressive discovery +…
protostatis bfac772
chore(harness): sanitized site_matrix for minimal-3 wrapper (9 sites)
protostatis 0349d83
refine(tooling-ux): stable escalation + --mcp-profile + bounded timeo…
protostatis fc9947c
style: cargo fmt
protostatis 0a3a973
fix(review): address Sky nits — shared executor, Brave retry, strict …
protostatis f910419
test(harness): protocol test + escalation fixtures + warning split
protostatis 1c88cf2
feat(mcp): Python smart_mcp server for minimal 3 (search/open/help)
protostatis 06fa5e9
fix(review): profile-aware help, invariant tests, tidy comments per Sky
protostatis d2911a6
feat(smart): micro_hint — concrete next-step when auto-discovery is thin
protostatis 29d422f
feat(packaging): unbrowser-smart console script + SmartClient re-export
protostatis b9cfb98
fix(ci): resolve clippy if_same_then_else + collapsible_if in arg par…
protostatis 0d06d1b
feat(smart): calibrated hints — evidence-gated tables, avoid-list, en…
protostatis e77ad37
docs(policy): §13 tool-invocation routing — posterior chain over agen…
protostatis 4617826
feat(discovery): clig.dev + MCP-spec discovery conventions across all…
protostatis 5c2ba8d
style: cargo fmt
protostatis 8868681
fix(cli): keep 'session prune' literal in help (release_check contract)
protostatis File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,12 +5,17 @@ | |
| agents and MCP hosts can use directly (e.g. `command: "unbrowser"` in | ||
| .mcp.json). | ||
|
|
||
| The wrapper keeps the native binary as the execution engine and exposes a | ||
| useful `--help` surface. Invocations are passed through to the binary. | ||
| The wrapper keeps the native binary as the execution engine. Help follows | ||
| progressive-disclosure conventions (clig.dev): `--help` shows the core path | ||
| plus grouped tool families; `unbrowser help <topic>` drills into any family | ||
| or tool; unknown commands get did-you-mean suggestions on stderr with | ||
| exit code 2. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import difflib | ||
| import json | ||
| import os | ||
| import subprocess | ||
| import sys | ||
|
|
@@ -19,42 +24,137 @@ | |
| from . import find_binary | ||
|
|
||
|
|
||
| # Grouped tool families — mirrors HELP_CATALOG in unbrowser/smart.py and the | ||
| # Rust MCP surface. Kept as plain data so `--help` renders without importing | ||
| # the smart layer. | ||
| TOOL_FAMILIES: dict[str, list[str]] = { | ||
| "reading": ["text", "text_main", "text_clean", "blockmap", "body"], | ||
| "query": ["query", "query_debug", "query_text", "find_text", "text_around"], | ||
| "extraction": ["extract", "extract_table", "extract_list", "extract_cards", "table_to_json"], | ||
| "discovery": ["discover", "route_discover", "page_model", "network_extract", "network_stores"], | ||
| "interaction": ["click", "type", "submit", "activate", "settle", "eval"], | ||
| "session": ["cookies_set", "cookies_get", "cookies_clear", "report_outcome"], | ||
| } | ||
|
|
||
| _KNOWN_COMMANDS = [ | ||
| "navigate", "search", "open", "help", "exec", "session", | ||
| "router", "cookie-service", "policy-check", "--mcp", "--version", | ||
| "--list-profiles", "--prefit-info", | ||
| ] | ||
|
|
||
|
|
||
| def _usage() -> None: | ||
| fams = "\n".join(f" {fam:<12} {' '.join(tools)}" for fam, tools in TOOL_FAMILIES.items()) | ||
| print( | ||
| """unbrowser | ||
| f"""unbrowser — web access for LLM agents. One static binary. No Chrome. | ||
|
|
||
| Usage: | ||
| unbrowser session start [--id <id>] [--profile <name>] [--policy=blocklist] [--shims stable|enhanced] | ||
| unbrowser session exec [--pretty] <id|socket> <method> [params-json | shorthand args] | ||
| START HERE | ||
| unbrowser navigate <url> [--exec-scripts] fetch a page -> low-token BlockMap | ||
| unbrowser search "<query>" [--count N] web search (Brave->DDG) -> [{{title,url,snippet}}] | ||
| unbrowser open <url> [--goal G] fetch + auto-discover + next-step hints | ||
| unbrowser --mcp MCP server mode for agent hosts | ||
|
|
||
| MULTI-STEP SESSIONS (cookies + last page persist) | ||
| unbrowser session start [--id <id>] [--profile <name>] [--policy=blocklist] | ||
| unbrowser exec [--pretty] <id|socket> <method> [params-json | shorthand args] | ||
| unbrowser session stop <id|socket> | ||
| unbrowser session list | ||
| unbrowser session prune | ||
| unbrowser navigate <url> [--exec-scripts] [--json] [--events] [--shims stable|enhanced] | ||
| unbrowser router <url> [--cookie-service <url>] [--allow-remote-cookie-service] [--no-auto-cookie-service] | ||
| unbrowser cookie-service [--headless|--no-headless] [--port <port>] [--allow-host <host>] [--allow-remote-bind] | ||
| unbrowser session stop <id|socket> | session list | session prune | ||
|
|
||
| TOOLS — call via `unbrowser exec <id> <method> '{{...}}'`, or over MCP | ||
| {fams} | ||
|
|
||
| unbrowser help <family|tool> details + examples (e.g. `unbrowser help extraction`) | ||
|
|
||
| MORE | ||
| unbrowser router <url> bot-wall cookie handoff via local Chrome | ||
| unbrowser cookie-service [--headless] local solver service (needs [solver] extra) | ||
| unbrowser policy-check <url> [<url>...] | ||
| unbrowser --list-profiles | ||
| unbrowser --prefit-info | ||
| unbrowser [--profile <name>] [--policy=blocklist] [--shims stable|enhanced] [--mcp] | ||
| unbrowser --version | ||
|
|
||
| Examples: | ||
| unbrowser session start --id demo | ||
| unbrowser exec demo navigate https://news.ycombinator.com | ||
| unbrowser exec --pretty demo blockmap | ||
| unbrowser session stop demo | ||
| unbrowser navigate https://news.ycombinator.com --json | ||
| unbrowser cookie-service --headless --profile unbrowser-cookie-service | ||
| unbrowser router https://example.com/protected | ||
| unbrowser policy-check https://www.bbc.com/news | ||
| printf '{\"id\":1,\"method\":\"navigate\",\"params\":{\"url\":\"https://news.ycombinator.com\"}}\n' | unbrowser | ||
|
|
||
| `navigate` delegates to the native binary; output is always the binary's JSON. | ||
| unbrowser --list-profiles | --prefit-info | --version | ||
|
|
||
| Every result carries routing hints: micro_hint (the next concrete step), | ||
| next_tools (ranked candidates), avoid (tools with nothing to act on). | ||
| """ | ||
| ) | ||
|
|
||
|
|
||
| def _help_topic(topic: str | None) -> int: | ||
| """Render the grouped catalog, one family, or one tool. Exit 0.""" | ||
| try: | ||
| from .smart import HELP_CATALOG | ||
| except ImportError: | ||
| print("help catalog unavailable in this install", file=sys.stderr) | ||
| return 1 | ||
| if not topic: | ||
| for fam, tools in HELP_CATALOG.items(): | ||
| print(f"{fam}:") | ||
| for name, info in tools.items(): | ||
| print(f" {name:<16} {info.get('when', '')}") | ||
| print("\nDrill in: unbrowser help <family|tool> e.g. unbrowser help extract_table") | ||
| return 0 | ||
| t = topic.lower() | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. _cmd_open: goal = args[i + 1] has the same missing-value IndexError risk as --count when --goal is the trailing arg. Guard args length before indexing. |
||
| for fam, tools in HELP_CATALOG.items(): | ||
| if t == fam: | ||
| print(f"{fam}:") | ||
| for name, info in tools.items(): | ||
| print(f"\n {name}\n {info.get('when', '')}") | ||
| if info.get("example"): | ||
| print(f" e.g. {info['example']}") | ||
| return 0 | ||
| if t in tools: | ||
| info = tools[t] | ||
| print(f"{t} ({fam})\n {info.get('when', '')}") | ||
| if info.get("example"): | ||
| print(f" e.g. {info['example']}") | ||
| return 0 | ||
| # fuzzy fallback | ||
| matches = difflib.get_close_matches(t, [n for f_ in HELP_CATALOG.values() for n in f_], n=3) | ||
| if matches: | ||
| print(f"unknown topic '{topic}'. Did you mean: {', '.join(matches)}?") | ||
| else: | ||
| print(f"unknown topic '{topic}'") | ||
| return 1 | ||
|
|
||
|
|
||
| def _suggest_and_exit(bad: str) -> None: | ||
| matches = difflib.get_close_matches(bad, _KNOWN_COMMANDS + [n for f_ in TOOL_FAMILIES.values() for n in f_], n=3) | ||
| hint = f" Did you mean: {', '.join(matches)}?" if matches else "" | ||
| print(f"unbrowser: unknown command '{bad}'.{hint}\nRun `unbrowser --help` to see what's available.", file=sys.stderr) | ||
| raise SystemExit(2) | ||
|
|
||
|
|
||
| def _cmd_search(args: list[str]) -> None: | ||
| count = 5 | ||
| if "--count" in args: | ||
| i = args.index("--count") | ||
| count = int(args[i + 1]) | ||
| del args[i : i + 2] | ||
| query = " ".join(a for a in args if not a.startswith("-")) | ||
| if not query: | ||
| print("usage: unbrowser search \"<query>\" [--count N]", file=sys.stderr) | ||
| raise SystemExit(2) | ||
| from .smart import SmartClient | ||
|
|
||
| with SmartClient() as ub: | ||
| hits = ub.search(query, count=count) | ||
| print(json.dumps(hits, indent=2)) | ||
|
|
||
|
|
||
| def _cmd_open(args: list[str]) -> None: | ||
| goal = None | ||
| if "--goal" in args: | ||
| i = args.index("--goal") | ||
| goal = args[i + 1] | ||
| del args[i : i + 2] | ||
| url = next((a for a in args if not a.startswith("-")), None) | ||
| if not url: | ||
| print("usage: unbrowser open <url> [--goal G]", file=sys.stderr) | ||
| raise SystemExit(2) | ||
| from .smart import SmartClient | ||
|
|
||
| with SmartClient() as ub: | ||
| bundle = ub.navigate_auto(url, goal=goal) | ||
| print(json.dumps(bundle, indent=2)) | ||
|
|
||
|
|
||
| def _is_help_flag(arg: str) -> bool: | ||
| return arg in {"-h", "--help"} | ||
|
|
||
|
|
@@ -102,6 +202,17 @@ def main() -> None: | |
| _usage() | ||
| return | ||
|
|
||
| if argv[0] == "help": | ||
| raise SystemExit(_help_topic(argv[1] if len(argv) > 1 else None)) | ||
|
|
||
| if argv[0] == "search": | ||
| _cmd_search(argv[1:]) | ||
| return | ||
|
|
||
| if argv[0] == "open": | ||
| _cmd_open(argv[1:]) | ||
| return | ||
|
|
||
| if argv[0] == "navigate": | ||
| _navigate(argv[1:]) | ||
| return | ||
|
|
@@ -114,9 +225,12 @@ def main() -> None: | |
| _router(argv[1:]) | ||
| return | ||
|
|
||
| binary = find_binary() | ||
| # Preserve the native binary behavior for every other command. | ||
| os.execv(binary, ["unbrowser", *argv]) | ||
| # Pass through known binary commands and flags; anything else gets a | ||
| # did-you-mean instead of a cryptic binary error. | ||
| if argv[0].startswith("-") or argv[0] in {"session", "exec", "policy-check"}: | ||
| binary = find_binary() | ||
| os.execv(binary, ["unbrowser", *argv]) | ||
| _suggest_and_exit(argv[0]) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
|
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
count = int(args[i + 1]) will raise IndexError if --count is the last arg (no value), or ValueError on a non-integer. Same pattern in _cmd_open for --goal (args[i + 1]). Add bounds/type checking and emit a clean usage error instead of a raw traceback.