bugbounty.sa 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,140 @@
1
+ Metadata-Version: 2.4
2
+ Name: bugbounty.sa
3
+ Version: 0.1.0
4
+ Summary: bugbounty.sa(bbsa): read-only CLI + MCP server for bugbounty.sa — programs, reports, finance, leaderboard
5
+ Keywords: bugbounty,mcp,cli,bug-bounty,security,researcher
6
+ Author: shulkwisec
7
+ Author-email: shulkwisec <shulkwisec@gmail.com>
8
+ License-Expression: Apache-2.0
9
+ Requires-Dist: mcp>=1.0.0
10
+ Requires-Dist: httpx>=0.27.0
11
+ Requires-Python: >=3.12
12
+ Project-URL: Repository, https://github.com/ShulkwiSEC/bugbounty.sa
13
+ Description-Content-Type: text/markdown
14
+
15
+ # bugbounty.sa (bbsa)
16
+ ![bbsa demo](demo.gif)
17
+
18
+
19
+ Read-only CLI + MCP server for [bugbounty.sa](https://bugbounty.sa) — query programs, reports, invoices, the leaderboard, and notifications from your terminal or your agent. Report submission stays strictly manual.
20
+
21
+ - **CLI:** `bbsa`
22
+ - **MCP server:** `bbsa-mcp`
23
+ - **Repo:** [github.com/ShulkwiSEC/bugbounty.sa](https://github.com/ShulkwiSEC/bugbounty.sa)
24
+
25
+ ## Features
26
+
27
+ - **`bbsa` CLI** — agent- and human-friendly: stable `--json` on every command, ANSI color only on a TTY, tab-separated plain output when piped, errors on stderr, exit codes `0`/`1`/`2`/`3`.
28
+ - **MCP server** — 14 read-only tools + a `bugbounty://me/profile` resource for Claude, OpenCode, Gemini, etc.
29
+ - **Read-only by construction** — every request is `GET`. No mutations exist anywhere in the codebase.
30
+ - **One HTTP layer** — the CLI and the MCP server share a single `api.py`; no duplicated request handling, no drift.
31
+
32
+ ## Install
33
+
34
+ Requires Python 3.12+ and [uv](https://docs.astral.sh/uv/).
35
+
36
+ ```bash
37
+ uv tool install git+https://github.com/ShulkwiSEC/bugbounty.sa
38
+ # or, from a clone:
39
+ uv pip install -e .
40
+ ```
41
+
42
+ Dependencies: [`httpx`](https://www.python-httpx.org/) (HTTP), [`mcp`](https://github.com/modelcontextprotocol/python-sdk) (MCP server). Build backend: [`uv_build`](https://docs.astral.sh/uv/concepts/build-backend/). The distribution is published on PyPI as `bugbounty.sa`; the import package is `bbsa`.
43
+
44
+ ## Setup
45
+
46
+ ```bash
47
+ export BUGBOUNTY_SA_TOKEN="your-bugbounty-sa-token"
48
+ ```
49
+
50
+ The token is your bugbounty.sa API token. Without it, private endpoints return a clear `401` telling you exactly what to set.
51
+
52
+ ## Usage
53
+
54
+ ### Quick tour
55
+
56
+ ```
57
+ bbsa me your researcher profile
58
+ bbsa programs list active programs
59
+ bbsa programs show <ID> scope, policy, reward ranges, domains
60
+ bbsa reports list your reports
61
+ bbsa reports show <ID-or-slug> one report's detail
62
+ bbsa reports stats [--group] counts by status|severity|type
63
+ bbsa finance invoices your invoices
64
+ bbsa finance stats invoice totals (paid / unpaid)
65
+ bbsa leaderboard top 10 researchers (public)
66
+ bbsa notifications your notifications
67
+ ```
68
+
69
+ ### Examples
70
+
71
+ ```console
72
+ $ bbsa programs list
73
+ ID NAME TYPE STATUS PLATFORM ENDS
74
+ 1475 CoderHub public active Web 2027-08-31T21:00:00.000000Z
75
+ 1474 Tuwaiq Academy public active Web 2027-08-30T21:00:00.000000Z
76
+ 313 Flagyard Platform public active Web 2026-12-31T03:00:05.000000Z
77
+
78
+ Next: bbsa programs show <ID> for scope, policy, reward ranges
79
+ ```
80
+
81
+ Every command produces stable JSON with `--json`, so it drops straight into pipelines:
82
+
83
+ ```bash
84
+ bbsa leaderboard --json | jq -r '.data[] | "\(.rank) \(.username)"'
85
+ bbsa reports list --json | jq -c '.data[] | select(.severity == "high")'
86
+ bbsa programs show 1475 --json | jq .data.domains
87
+ ```
88
+
89
+ Exit codes: `0` ok, `1` error, `2` usage, `3` not found. `--debug` prints full tracebacks; `--no-color` forces plain output for scripting.
90
+
91
+ ### MCP server
92
+
93
+ ```json
94
+ {
95
+ "mcpServers": {
96
+ "bugbounty.sa": {
97
+ "command": "bbsa-mcp",
98
+ "env": { "BUGBOUNTY_SA_TOKEN": "<your-token>" }
99
+ }
100
+ }
101
+ }
102
+ ```
103
+
104
+ Or via `uv run`:
105
+
106
+ ```json
107
+ {
108
+ "mcpServers": {
109
+ "bugbounty.sa": {
110
+ "command": "uv",
111
+ "args": ["run", "bbsa-mcp"],
112
+ "cwd": "/path/to/repo",
113
+ "env": { "BUGBOUNTY_SA_TOKEN": "<your-token>" }
114
+ }
115
+ }
116
+ }
117
+ ```
118
+
119
+ Tools: `list_programs`, `get_program_scope`, `list_reports`, `get_report`, `get_report_stats`, `get_wallet_balance`, `list_invoices`, `get_invoice_stats`, `list_transactions`, `get_transaction_stats`, `get_public_leaderboard`, `list_companies`, `get_company`, `list_notifications`. Resource: `bugbounty://me/profile` (`GET /me`).
120
+
121
+ ## Example
122
+
123
+ Agent prompts that work with the MCP server connected to your client (the agent calls the tools itself — no CLI needed):
124
+
125
+ 1. **Investigate your open work**
126
+ > "List my reports, then for any still in triage pull the full detail and summarize the status, severity, and next step I should take for each."
127
+
128
+ 2. **Recon a program before hunting**
129
+ > "Show me the active programs, then for the highest-bounty public one give me its full scope, reward ranges, and target domains."
130
+
131
+ 3. **Market-scan as a researcher**
132
+ > "Write a short briefing: who's leading the researcher leaderboard, which recent notifications or new programs are relevant to me, and how my profile compares."
133
+
134
+ ## Contributing
135
+
136
+ Issues and PRs welcome at [github.com/ShulkwiSEC/bugbounty.sa/issues](https://github.com/ShulkwiSEC/bugbounty.sa/issues). Keep it read-only: no write endpoints, no new dependencies without a good reason.
137
+
138
+ ## License
139
+
140
+ [Apache-2.0](LICENSE) — full text in [`LICENSE`](LICENSE).
@@ -0,0 +1,126 @@
1
+ # bugbounty.sa (bbsa)
2
+ ![bbsa demo](demo.gif)
3
+
4
+
5
+ Read-only CLI + MCP server for [bugbounty.sa](https://bugbounty.sa) — query programs, reports, invoices, the leaderboard, and notifications from your terminal or your agent. Report submission stays strictly manual.
6
+
7
+ - **CLI:** `bbsa`
8
+ - **MCP server:** `bbsa-mcp`
9
+ - **Repo:** [github.com/ShulkwiSEC/bugbounty.sa](https://github.com/ShulkwiSEC/bugbounty.sa)
10
+
11
+ ## Features
12
+
13
+ - **`bbsa` CLI** — agent- and human-friendly: stable `--json` on every command, ANSI color only on a TTY, tab-separated plain output when piped, errors on stderr, exit codes `0`/`1`/`2`/`3`.
14
+ - **MCP server** — 14 read-only tools + a `bugbounty://me/profile` resource for Claude, OpenCode, Gemini, etc.
15
+ - **Read-only by construction** — every request is `GET`. No mutations exist anywhere in the codebase.
16
+ - **One HTTP layer** — the CLI and the MCP server share a single `api.py`; no duplicated request handling, no drift.
17
+
18
+ ## Install
19
+
20
+ Requires Python 3.12+ and [uv](https://docs.astral.sh/uv/).
21
+
22
+ ```bash
23
+ uv tool install git+https://github.com/ShulkwiSEC/bugbounty.sa
24
+ # or, from a clone:
25
+ uv pip install -e .
26
+ ```
27
+
28
+ Dependencies: [`httpx`](https://www.python-httpx.org/) (HTTP), [`mcp`](https://github.com/modelcontextprotocol/python-sdk) (MCP server). Build backend: [`uv_build`](https://docs.astral.sh/uv/concepts/build-backend/). The distribution is published on PyPI as `bugbounty.sa`; the import package is `bbsa`.
29
+
30
+ ## Setup
31
+
32
+ ```bash
33
+ export BUGBOUNTY_SA_TOKEN="your-bugbounty-sa-token"
34
+ ```
35
+
36
+ The token is your bugbounty.sa API token. Without it, private endpoints return a clear `401` telling you exactly what to set.
37
+
38
+ ## Usage
39
+
40
+ ### Quick tour
41
+
42
+ ```
43
+ bbsa me your researcher profile
44
+ bbsa programs list active programs
45
+ bbsa programs show <ID> scope, policy, reward ranges, domains
46
+ bbsa reports list your reports
47
+ bbsa reports show <ID-or-slug> one report's detail
48
+ bbsa reports stats [--group] counts by status|severity|type
49
+ bbsa finance invoices your invoices
50
+ bbsa finance stats invoice totals (paid / unpaid)
51
+ bbsa leaderboard top 10 researchers (public)
52
+ bbsa notifications your notifications
53
+ ```
54
+
55
+ ### Examples
56
+
57
+ ```console
58
+ $ bbsa programs list
59
+ ID NAME TYPE STATUS PLATFORM ENDS
60
+ 1475 CoderHub public active Web 2027-08-31T21:00:00.000000Z
61
+ 1474 Tuwaiq Academy public active Web 2027-08-30T21:00:00.000000Z
62
+ 313 Flagyard Platform public active Web 2026-12-31T03:00:05.000000Z
63
+
64
+ Next: bbsa programs show <ID> for scope, policy, reward ranges
65
+ ```
66
+
67
+ Every command produces stable JSON with `--json`, so it drops straight into pipelines:
68
+
69
+ ```bash
70
+ bbsa leaderboard --json | jq -r '.data[] | "\(.rank) \(.username)"'
71
+ bbsa reports list --json | jq -c '.data[] | select(.severity == "high")'
72
+ bbsa programs show 1475 --json | jq .data.domains
73
+ ```
74
+
75
+ Exit codes: `0` ok, `1` error, `2` usage, `3` not found. `--debug` prints full tracebacks; `--no-color` forces plain output for scripting.
76
+
77
+ ### MCP server
78
+
79
+ ```json
80
+ {
81
+ "mcpServers": {
82
+ "bugbounty.sa": {
83
+ "command": "bbsa-mcp",
84
+ "env": { "BUGBOUNTY_SA_TOKEN": "<your-token>" }
85
+ }
86
+ }
87
+ }
88
+ ```
89
+
90
+ Or via `uv run`:
91
+
92
+ ```json
93
+ {
94
+ "mcpServers": {
95
+ "bugbounty.sa": {
96
+ "command": "uv",
97
+ "args": ["run", "bbsa-mcp"],
98
+ "cwd": "/path/to/repo",
99
+ "env": { "BUGBOUNTY_SA_TOKEN": "<your-token>" }
100
+ }
101
+ }
102
+ }
103
+ ```
104
+
105
+ Tools: `list_programs`, `get_program_scope`, `list_reports`, `get_report`, `get_report_stats`, `get_wallet_balance`, `list_invoices`, `get_invoice_stats`, `list_transactions`, `get_transaction_stats`, `get_public_leaderboard`, `list_companies`, `get_company`, `list_notifications`. Resource: `bugbounty://me/profile` (`GET /me`).
106
+
107
+ ## Example
108
+
109
+ Agent prompts that work with the MCP server connected to your client (the agent calls the tools itself — no CLI needed):
110
+
111
+ 1. **Investigate your open work**
112
+ > "List my reports, then for any still in triage pull the full detail and summarize the status, severity, and next step I should take for each."
113
+
114
+ 2. **Recon a program before hunting**
115
+ > "Show me the active programs, then for the highest-bounty public one give me its full scope, reward ranges, and target domains."
116
+
117
+ 3. **Market-scan as a researcher**
118
+ > "Write a short briefing: who's leading the researcher leaderboard, which recent notifications or new programs are relevant to me, and how my profile compares."
119
+
120
+ ## Contributing
121
+
122
+ Issues and PRs welcome at [github.com/ShulkwiSEC/bugbounty.sa/issues](https://github.com/ShulkwiSEC/bugbounty.sa/issues). Keep it read-only: no write endpoints, no new dependencies without a good reason.
123
+
124
+ ## License
125
+
126
+ [Apache-2.0](LICENSE) — full text in [`LICENSE`](LICENSE).
@@ -0,0 +1,37 @@
1
+ [project]
2
+ name = "bugbounty.sa"
3
+ version = "0.1.0"
4
+ description = "bugbounty.sa(bbsa): read-only CLI + MCP server for bugbounty.sa — programs, reports, finance, leaderboard"
5
+ readme = "README.md"
6
+ keywords = [
7
+ "bugbounty",
8
+ "mcp",
9
+ "cli",
10
+ "bug-bounty",
11
+ "security",
12
+ "researcher",
13
+ ]
14
+ license = "Apache-2.0"
15
+ requires-python = ">=3.12"
16
+ dependencies = [
17
+ "mcp>=1.0.0",
18
+ "httpx>=0.27.0",
19
+ ]
20
+
21
+ [[project.authors]]
22
+ name = "shulkwisec"
23
+ email = "shulkwisec@gmail.com"
24
+
25
+ [project.scripts]
26
+ bbsa-mcp = "bbsa:main"
27
+ bbsa = "bbsa.cli:main"
28
+
29
+ [project.urls]
30
+ Repository = "https://github.com/ShulkwiSEC/bugbounty.sa"
31
+
32
+ [build-system]
33
+ requires = ["uv_build>=0.12.0,<0.13.0"]
34
+ build-backend = "uv_build"
35
+
36
+ [tool.uv.build-backend]
37
+ module-name = "bbsa"
@@ -0,0 +1,30 @@
1
+ [project]
2
+ name = "bugbounty.sa"
3
+ version = "0.1.0"
4
+ description = "bugbounty.sa(bbsa): read-only CLI + MCP server for bugbounty.sa — programs, reports, finance, leaderboard"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "shulkwisec", email = "shulkwisec@gmail.com" }
8
+ ]
9
+ keywords = ["bugbounty", "mcp", "cli", "bug-bounty", "security", "researcher"]
10
+ license = "Apache-2.0"
11
+ requires-python = ">=3.12"
12
+ dependencies = [
13
+ "mcp>=1.0.0",
14
+ "httpx>=0.27.0",
15
+ ]
16
+
17
+ [project.scripts]
18
+ bbsa-mcp = "bbsa:main"
19
+ bbsa = "bbsa.cli:main"
20
+
21
+ [project.urls]
22
+ Repository = "https://github.com/ShulkwiSEC/bugbounty.sa"
23
+
24
+ [build-system]
25
+ requires = ["uv_build>=0.12.0,<0.13.0"]
26
+ build-backend = "uv_build"
27
+
28
+ # Distribution is "bugbounty.sa"; the import package/module dir is "bbsa".
29
+ [tool.uv.build-backend]
30
+ module-name = "bbsa"
@@ -0,0 +1,134 @@
1
+ """Read-only MCP server for bugbounty.sa — tools + 1 resource.
2
+
3
+ Shares the HTTP layer with the bbsa CLI (see `api.py`).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from mcp.server.mcpserver import MCPServer
9
+
10
+ from bbsa.api import get as _get
11
+
12
+ mcp = MCPServer("bugbounty-sa")
13
+
14
+
15
+ # ── Programs ─────────────────────────────────────────────────────────
16
+
17
+
18
+ @mcp.tool()
19
+ def list_programs() -> dict:
20
+ """List active programs on bugbounty.sa."""
21
+ return _get("/programs")
22
+
23
+
24
+ @mcp.tool()
25
+ def get_program_scope(program_id: int) -> dict:
26
+ """Get full program detail: scope, policy, reward ranges, domains."""
27
+ return _get(f"/programs/{program_id}")
28
+
29
+
30
+ # ── Reports ──────────────────────────────────────────────────────────
31
+
32
+
33
+ @mcp.tool()
34
+ def list_reports() -> dict:
35
+ """List your submitted reports with triage status."""
36
+ return _get("/reports")
37
+
38
+
39
+ @mcp.tool()
40
+ def get_report(report_id_or_slug: str) -> dict:
41
+ """Get single report detail by ID or slug."""
42
+ return _get(f"/reports/{report_id_or_slug}")
43
+
44
+
45
+ @mcp.tool()
46
+ def get_report_stats(group_by: str = "status") -> dict:
47
+ """Dashboard stats: report counts grouped by status/severity/type."""
48
+ return _get("/reports/stats/grouped", {"groupBy": group_by})
49
+
50
+
51
+ # ── Finance ──────────────────────────────────────────────────────────
52
+ # ponytail: role-gated — wallet/transactions verified 403 with a researcher
53
+ # token (invoices work, that's the researcher payout surface).
54
+
55
+
56
+ @mcp.tool()
57
+ def get_wallet_balance() -> dict:
58
+ """Get account wallet balance and total payout stats (company/admin role)."""
59
+ return _get("/wallet/balance")
60
+
61
+
62
+ @mcp.tool()
63
+ def list_invoices() -> dict:
64
+ """List bounty invoices."""
65
+ return _get("/invoices")
66
+
67
+
68
+ @mcp.tool()
69
+ def get_invoice_stats() -> dict:
70
+ """Invoice summary stats."""
71
+ return _get("/invoices/stats")
72
+
73
+
74
+ @mcp.tool()
75
+ def list_transactions() -> dict:
76
+ """List payment/transaction history."""
77
+ return _get("/transactions")
78
+
79
+
80
+ @mcp.tool()
81
+ def get_transaction_stats() -> dict:
82
+ """Transaction summary stats."""
83
+ return _get("/transactions/stats")
84
+
85
+
86
+ # ── Leaderboard ───────────────────────────────────────────────────────
87
+
88
+
89
+ @mcp.tool()
90
+ def get_public_leaderboard() -> dict:
91
+ """Top 10 researchers leaderboard (public, no auth needed)."""
92
+ return _get("/leaderboard")
93
+
94
+
95
+ # ── Companies ────────────────────────────────────────────────────────
96
+ # ponytail: role-gated — verified 403 with a researcher token. Keep for
97
+ # admin/company tokens; drop these tools if the server is researcher-only.
98
+
99
+
100
+ @mcp.tool()
101
+ def list_companies() -> dict:
102
+ """List companies on the platform."""
103
+ return _get("/companies")
104
+
105
+
106
+ @mcp.tool()
107
+ def get_company(company_id: int) -> dict:
108
+ """Get company detail by ID."""
109
+ return _get(f"/companies/{company_id}")
110
+
111
+
112
+ # ── Notifications ────────────────────────────────────────────────────
113
+
114
+
115
+ @mcp.tool()
116
+ def list_notifications() -> dict:
117
+ """List notifications (unread count, recent alerts)."""
118
+ return _get("/notifications")
119
+
120
+
121
+ # ── Resource ─────────────────────────────────────────────────────────
122
+
123
+
124
+ @mcp.resource("bugbounty://me/profile")
125
+ def get_my_profile() -> dict:
126
+ """Current researcher account profile."""
127
+ return _get("/me")
128
+
129
+
130
+ # ── Entry point ──────────────────────────────────────────────────────
131
+
132
+
133
+ def main() -> None:
134
+ mcp.run()
@@ -0,0 +1,86 @@
1
+ """Shared HTTP layer for bugbounty.sa — used by both the MCP server and the CLI.
2
+
3
+ Read-only by design: the only verb ever issued is GET.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ import os
10
+
11
+ import httpx
12
+
13
+ logging.getLogger("httpx").setLevel(logging.WARNING)
14
+ logging.getLogger("httpcore").setLevel(logging.WARNING)
15
+
16
+ BASE_URL = "https://api.bugbounty.sa/api"
17
+ HEADERS = {
18
+ "Accept": "application/json",
19
+ "Content-Type": "application/json",
20
+ "Accept-Language": "en",
21
+ "tz": "Asia/Riyadh",
22
+ }
23
+
24
+
25
+ class ApiError(Exception):
26
+ """Raised on any failed request. code + retryable let consumers act on it."""
27
+
28
+ def __init__(
29
+ self,
30
+ message: str,
31
+ code: str = "api_error",
32
+ retryable: bool = False,
33
+ status: int | None = None,
34
+ ):
35
+ super().__init__(message)
36
+ self.code = code
37
+ self.retryable = retryable
38
+ self.status = status
39
+
40
+
41
+ def _err_text(r: httpx.Response) -> str:
42
+ try:
43
+ return r.json().get("message") or r.text[:200]
44
+ except Exception:
45
+ return r.text[:200]
46
+
47
+
48
+ def get(path: str, params: dict | None = None) -> dict:
49
+ """Single GET helper. Returns the full JSON envelope. Raises ApiError."""
50
+ token = os.environ.get("BUGBOUNTY_SA_TOKEN", "")
51
+ headers = dict(HEADERS)
52
+ if token:
53
+ headers["Authorization"] = f"Bearer {token}"
54
+ try:
55
+ r = httpx.get(f"{BASE_URL}{path}", params=params, headers=headers, timeout=30)
56
+ except httpx.HTTPError as exc:
57
+ raise ApiError(
58
+ f"Request to {path} failed: {exc}", code="network_error", retryable=True
59
+ ) from exc
60
+
61
+ if r.status_code < 400:
62
+ return r.json()
63
+
64
+ msg = _err_text(r)
65
+ if r.status_code == 401:
66
+ raise ApiError(
67
+ "Unauthenticated. Set BUGBOUNTY_SA_TOKEN to a bugbounty.sa bearer token.",
68
+ code="unauthenticated",
69
+ retryable=True,
70
+ status=401,
71
+ )
72
+ if r.status_code == 403:
73
+ raise ApiError(
74
+ msg or "Forbidden — this token's role lacks access to the endpoint.",
75
+ code="forbidden",
76
+ retryable=False,
77
+ status=403,
78
+ )
79
+ if r.status_code == 404:
80
+ raise ApiError(msg or "Not Found.", code="not_found", retryable=False, status=404)
81
+ raise ApiError(
82
+ msg or "API error.",
83
+ code="http_error",
84
+ retryable=r.status_code >= 500,
85
+ status=r.status_code,
86
+ )
@@ -0,0 +1,179 @@
1
+ """bbsa — read-only bugbounty.sa CLI.
2
+
3
+ Divides into a `src/bbsa/api.py` (shared HTTP), a
4
+ `formatters.py` (stable JSON, exit codes, tables) and one `commands/*` module
5
+ per resource, mirroring the oh-my-ctf CLI conventions.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import sys
12
+
13
+ from bbsa import api
14
+ from bbsa.cli.formatters import (
15
+ EXIT_ERROR,
16
+ EXIT_NOT_FOUND,
17
+ EXIT_OK,
18
+ EXIT_USAGE,
19
+ print_json_error,
20
+ red,
21
+ set_color_enabled,
22
+ )
23
+ from bbsa.cli.commands.finance import cmd_finance_invoices, cmd_finance_stats
24
+ from bbsa.cli.commands.me import cmd_me
25
+ from bbsa.cli.commands.misc import cmd_leaderboard, cmd_notifications
26
+ from bbsa.cli.commands.programs import cmd_programs_list, cmd_programs_show
27
+ from bbsa.cli.commands.reports import (
28
+ cmd_reports_list,
29
+ cmd_reports_show,
30
+ cmd_reports_stats,
31
+ )
32
+
33
+ __all__ = ["main"]
34
+
35
+
36
+ def _common_flags() -> argparse.ArgumentParser:
37
+ common = argparse.ArgumentParser(add_help=False)
38
+ common.add_argument("--json", action="store_true", help="Emit stable JSON to stdout")
39
+ common.add_argument("--no-color", action="store_true", help="Disable ANSI color output")
40
+ common.add_argument("--debug", action="store_true", help="Show full stack traces on error")
41
+ return common
42
+
43
+
44
+ def build_parser() -> argparse.ArgumentParser:
45
+ common = _common_flags()
46
+ parser = argparse.ArgumentParser(
47
+ prog="bbsa",
48
+ parents=[common],
49
+ description="bbsa: read-only bugbounty.sa CLI (programs, reports, finance, leaderboard).",
50
+ epilog="Examples:\n"
51
+ " bbsa me\n"
52
+ " bbsa programs list\n"
53
+ " bbsa programs show 1475\n"
54
+ " bbsa reports list --limit 25 --json\n"
55
+ " bbsa reports show <ID-or-slug>\n"
56
+ " bbsa reports stats --group severity\n"
57
+ " bbsa finance invoices\n"
58
+ " bbsa finance stats\n"
59
+ " bbsa leaderboard --json\n"
60
+ " bbsa notifications\n"
61
+ "\nPipeline-friendly: bbsa leaderboard --json | jq -r '.data[] | \"\\\\(.rank) \\\\(.username)\"'",
62
+ formatter_class=argparse.RawDescriptionHelpFormatter,
63
+ )
64
+ sub = parser.add_subparsers(dest="subcommand", title="Commands")
65
+
66
+ # me
67
+ p_me = sub.add_parser("me", parents=[common], help="Show your researcher profile")
68
+ p_me.set_defaults(handler=cmd_me)
69
+
70
+ # programs
71
+ p_programs = sub.add_parser(
72
+ "programs", parents=[common], help="List programs or show one program's scope"
73
+ )
74
+ programs_sub = p_programs.add_subparsers(dest="programs_subcommand", title="Programs Commands")
75
+ p_pl = programs_sub.add_parser("list", parents=[common], help="List programs")
76
+ p_pl.add_argument("--limit", type=int, default=25, help="Max programs to show (default: 25)")
77
+ p_pl.set_defaults(handler=cmd_programs_list)
78
+ p_ps = programs_sub.add_parser("show", parents=[common], help="Show program scope, policy, reward ranges")
79
+ p_ps.add_argument("id", type=int, help="Program ID")
80
+ p_ps.set_defaults(handler=cmd_programs_show)
81
+
82
+ # reports
83
+ p_reports = sub.add_parser(
84
+ "reports", parents=[common], help="List, inspect, or aggregate your reports"
85
+ )
86
+ reports_sub = p_reports.add_subparsers(dest="reports_subcommand", title="Reports Commands")
87
+ p_rl = reports_sub.add_parser("list", parents=[common], help="List your reports")
88
+ p_rl.add_argument("--limit", type=int, default=25, help="Max reports to show (default: 25)")
89
+ p_rl.set_defaults(handler=cmd_reports_list)
90
+ p_rs = reports_sub.add_parser("show", parents=[common], help="Show one report's detail")
91
+ p_rs.add_argument("id", help="Report ID or slug")
92
+ p_rs.set_defaults(handler=cmd_reports_show)
93
+ p_rst = reports_sub.add_parser("stats", parents=[common], help="Report counts grouped by a field")
94
+ p_rst.add_argument(
95
+ "--group",
96
+ choices=("status", "severity", "type"),
97
+ default="status",
98
+ help="Grouping field (default: status)",
99
+ )
100
+ p_rst.set_defaults(handler=cmd_reports_stats)
101
+
102
+ # finance
103
+ p_finance = sub.add_parser(
104
+ "finance", parents=[common], help="Invoices and payout statistics"
105
+ )
106
+ finance_sub = p_finance.add_subparsers(dest="finance_subcommand", title="Finance Commands")
107
+ p_fi = finance_sub.add_parser("invoices", parents=[common], help="List your invoices")
108
+ p_fi.add_argument("--limit", type=int, default=25, help="Max invoices to show (default: 25)")
109
+ p_fi.set_defaults(handler=cmd_finance_invoices)
110
+ p_fs = finance_sub.add_parser("stats", parents=[common], help="Invoice summary stats")
111
+ p_fs.set_defaults(handler=cmd_finance_stats)
112
+
113
+ # leaderboard
114
+ p_lb = sub.add_parser("leaderboard", parents=[common], help="Top 10 researchers (public)")
115
+ p_lb.set_defaults(handler=cmd_leaderboard)
116
+
117
+ # notifications
118
+ p_nt = sub.add_parser("notifications", parents=[common], help="List your notifications")
119
+ p_nt.add_argument("--limit", type=int, default=25, help="Max notifications (default: 25)")
120
+ p_nt.set_defaults(handler=cmd_notifications)
121
+
122
+ return parser
123
+
124
+
125
+ _SUBCOMMAND_CHILDREN = {"programs": ["list", "show"], "reports": ["list", "show", "stats"], "finance": ["invoices", "stats"]}
126
+
127
+
128
+ def main(argv: list[str] | None = None) -> int:
129
+ if argv is None:
130
+ argv = sys.argv[1:]
131
+
132
+ try:
133
+ parser = build_parser()
134
+ if not argv:
135
+ parser.print_help()
136
+ return EXIT_OK
137
+
138
+ args = parser.parse_args(argv)
139
+ if args.no_color:
140
+ set_color_enabled(False)
141
+
142
+ if not hasattr(args, "handler"):
143
+ if args.subcommand in _SUBCOMMAND_CHILDREN:
144
+ kids = ", ".join(_SUBCOMMAND_CHILDREN[args.subcommand])
145
+ if args.json:
146
+ print_json_error(
147
+ "invalid_arguments",
148
+ f"Command 'bbsa {args.subcommand}' requires a subcommand ({kids}).",
149
+ )
150
+ else:
151
+ sys.stderr.write(
152
+ f"Error: 'bbsa {args.subcommand}' requires a subcommand: {kids}\n"
153
+ f"Run 'bbsa {args.subcommand} --help' for details.\n"
154
+ )
155
+ return EXIT_USAGE
156
+ parser.print_help()
157
+ return EXIT_USAGE
158
+
159
+ return args.handler(args)
160
+ except api.ApiError as exc:
161
+ if "--json" in argv:
162
+ print_json_error(exc.code, str(exc), retryable=exc.retryable)
163
+ else:
164
+ sys.stderr.write(red(f"Error: {exc}\n"))
165
+ return EXIT_NOT_FOUND if exc.code == "not_found" else EXIT_ERROR
166
+ except SystemExit as exc:
167
+ return exc.code if isinstance(exc.code, int) else (0 if exc.code is None else 1)
168
+ except Exception as exc:
169
+ if "--debug" in argv:
170
+ raise
171
+ if "--json" in argv:
172
+ print_json_error("unexpected_error", str(exc), retryable=False)
173
+ else:
174
+ sys.stderr.write(red(f"Error: {exc}\n"))
175
+ return EXIT_ERROR
176
+
177
+
178
+ if __name__ == "__main__":
179
+ sys.exit(main())
@@ -0,0 +1 @@
1
+ """CLI command modules."""
@@ -0,0 +1,66 @@
1
+ """bbsa finance — invoices and payout stats."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from bbsa import api
8
+ from bbsa.cli.formatters import (
9
+ EXIT_OK,
10
+ bold,
11
+ dim,
12
+ print_json_success,
13
+ render_kv,
14
+ render_table,
15
+ pick,
16
+ suggest_next_step,
17
+ text,
18
+ )
19
+
20
+
21
+ def cmd_finance_invoices(args: argparse.Namespace) -> int:
22
+ resp = api.get("/invoices")
23
+ items = resp.get("data") or []
24
+ meta = resp.get("meta") or {}
25
+ total = meta.get("total") if meta.get("total") is not None else len(items)
26
+ shown = items[: args.limit] if args.limit else items
27
+
28
+ if args.json:
29
+ print_json_success(shown, meta={"total": total, "shown": len(shown)})
30
+ return EXIT_OK
31
+ if not shown:
32
+ print(dim("No invoices found."))
33
+ return EXIT_OK
34
+
35
+ rows = [
36
+ [
37
+ str(i.get("id", "-")),
38
+ text(pick(i, "amount", "total", "price", default="-")),
39
+ text(pick(i, "status", "state", default="-")),
40
+ str(pick(i, "created_at", "date", "paid_at", default="-")),
41
+ ]
42
+ for i in shown
43
+ ]
44
+ print(render_table(["ID", "AMOUNT", "STATUS", "DATE"], rows))
45
+ if len(shown) < total:
46
+ suggest_next_step(f"Showing {len(shown)} of {total} invoices — --limit {total} to list all")
47
+ else:
48
+ suggest_next_step("bbsa finance stats")
49
+ return EXIT_OK
50
+
51
+
52
+ def cmd_finance_stats(args: argparse.Namespace) -> int:
53
+ stats = api.get("/invoices/stats")
54
+ if args.json:
55
+ print_json_success(stats)
56
+ return EXIT_OK
57
+ pairs = []
58
+ for k, v in stats.items():
59
+ label = str(k).replace("_", " ").title()
60
+ if isinstance(v, dict):
61
+ v = f"{v.get('count', 0)} invoices, total amount {v.get('amount', 0)}"
62
+ pairs.append((label, str(v)))
63
+ print(bold("══ Invoice Stats ══\n"))
64
+ print(render_kv(pairs))
65
+ suggest_next_step("bbsa finance invoices")
66
+ return EXIT_OK
@@ -0,0 +1,36 @@
1
+ """bbsa me — current researcher profile."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from bbsa import api
8
+ from bbsa.cli.formatters import EXIT_OK, bold, print_json_success, render_kv, suggest_next_step
9
+
10
+
11
+ def cmd_me(args: argparse.Namespace) -> int:
12
+ data = api.get("/me")["data"]
13
+ if args.json:
14
+ print_json_success(data)
15
+ return EXIT_OK
16
+
17
+ res = data.get("researcher") or {}
18
+ country = data.get("country")
19
+ if isinstance(country, dict):
20
+ country = country.get("name") or country.get("id")
21
+ pairs = [
22
+ ("Name", str(data.get("full_name") or data.get("name") or "-")),
23
+ ("Username", str(data.get("username") or "-")),
24
+ ("Email", str(data.get("email") or "-")),
25
+ ("Country", str(country or "-")),
26
+ ("Verified", str(bool(data.get("is_verified")))),
27
+ ("Rank", str(res.get("rank") or "-")),
28
+ ("Points", str(res.get("points") or 0)),
29
+ ("Total Bounties", str(res.get("total_bounties") or 0)),
30
+ ("Resolved Reports", str(res.get("resolved_reports") or 0)),
31
+ ("Full data", "run with --json"),
32
+ ]
33
+ print(bold("══ Your Profile ══\n"))
34
+ print(render_kv(pairs))
35
+ suggest_next_step("bbsa programs list")
36
+ return EXIT_OK
@@ -0,0 +1,78 @@
1
+ """bbsa leaderboard / notifications — misc queries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from bbsa import api
8
+ from bbsa.cli.formatters import (
9
+ EXIT_OK,
10
+ bold,
11
+ dim,
12
+ green,
13
+ print_json_success,
14
+ render_table,
15
+ suggest_next_step,
16
+ yellow,
17
+ )
18
+
19
+
20
+ def cmd_leaderboard(args: argparse.Namespace) -> int:
21
+ resp = api.get("/leaderboard")
22
+ items = resp.get("items") or []
23
+ if args.json:
24
+ print_json_success(items, meta={"total": len(items)})
25
+ return EXIT_OK
26
+ if not items:
27
+ print(dim("Leaderboard is empty."))
28
+ return EXIT_OK
29
+ rows = [
30
+ [
31
+ f"#{i.get('rank')}",
32
+ str(i.get("username", "-")),
33
+ str(i.get("company_researcher_points") or i.get("points") or 0),
34
+ green("verified") if i.get("is_verified") else "not verified",
35
+ ]
36
+ for i in items
37
+ ]
38
+ print(bold("══ Top Researchers ══\n"))
39
+ print(render_table(["RANK", "USERNAME", "POINTS", "VERIFIED"], rows))
40
+ suggest_next_step(r"jq filter: bbsa leaderboard --json | jq -r '.data[] | \"\(.rank) \(.username)\"'")
41
+ return EXIT_OK
42
+
43
+
44
+ def cmd_notifications(args: argparse.Namespace) -> int:
45
+ resp = api.get("/notifications")
46
+ items = resp.get("data") or []
47
+ meta = resp.get("meta") or {}
48
+ total = meta.get("total") if meta.get("total") is not None else len(items)
49
+ shown = items[: args.limit] if args.limit else items
50
+
51
+ if args.json:
52
+ print_json_success(shown, meta={"total": total, "shown": len(shown)})
53
+ return EXIT_OK
54
+ if not shown:
55
+ print(dim("No notifications."))
56
+ return EXIT_OK
57
+
58
+ rows = []
59
+ for n in shown:
60
+ d = n.get("data")
61
+ d = d if isinstance(d, dict) else {}
62
+ title = d.get("title") if isinstance(d.get("title"), dict) else {}
63
+ message = d.get("message")
64
+ if isinstance(message, dict):
65
+ message = message.get("message_en") or str(message)
66
+ text = str(title.get("title_en") or message or "-")
67
+ rows.append([
68
+ str(n.get("id", "-")),
69
+ text,
70
+ green("read") if n.get("read_at") else yellow("unread"),
71
+ str(n.get("created_at", "-")),
72
+ ])
73
+ print(render_table(["ID", "MESSAGE", "STATUS", "AT"], rows))
74
+ if len(shown) < total:
75
+ suggest_next_step(f"Showing {len(shown)} of {total} — --limit {total} for all")
76
+ else:
77
+ suggest_next_step("bbsa reports list")
78
+ return EXIT_OK
@@ -0,0 +1,106 @@
1
+ """bbsa programs list / show."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from bbsa import api
8
+ from bbsa.cli.formatters import (
9
+ EXIT_OK,
10
+ bold,
11
+ dim,
12
+ green,
13
+ print_json_success,
14
+ render_kv,
15
+ render_table,
16
+ red,
17
+ suggest_next_step,
18
+ text,
19
+ )
20
+
21
+
22
+ def _platform_name(p: dict) -> str:
23
+ plat = p.get("platform")
24
+ if isinstance(plat, dict):
25
+ return str(plat.get("name") or "-")
26
+ return str(plat or "-")
27
+
28
+
29
+ def cmd_programs_list(args: argparse.Namespace) -> int:
30
+ resp = api.get("/programs")
31
+ items = resp.get("data") or []
32
+ meta = resp.get("meta") or {}
33
+ total = meta.get("total") if meta.get("total") is not None else len(items)
34
+ # ponytail: client-side slice; switch to server page/limit if lists grow large
35
+ shown = items[: args.limit] if args.limit else items
36
+
37
+ if args.json:
38
+ print_json_success(shown, meta={"total": total, "shown": len(shown)})
39
+ return EXIT_OK
40
+ if not shown:
41
+ print(dim("No programs found."))
42
+ return EXIT_OK
43
+
44
+ rows = [
45
+ [
46
+ str(p.get("id", "-")),
47
+ text(p.get("name", "-")),
48
+ str(p.get("type", "-")),
49
+ green("active") if p.get("is_active") else red("inactive"),
50
+ _platform_name(p),
51
+ str(p.get("end_date") or p.get("end") or "-"),
52
+ ]
53
+ for p in shown
54
+ ]
55
+ print(render_table(["ID", "NAME", "TYPE", "STATUS", "PLATFORM", "ENDS"], rows))
56
+ if len(shown) < total:
57
+ suggest_next_step(f"Showing {len(shown)} of {total} — see all with --limit {total}")
58
+ else:
59
+ suggest_next_step("bbsa programs show <ID> for scope, policy, reward ranges")
60
+ return EXIT_OK
61
+
62
+
63
+ def cmd_programs_show(args: argparse.Namespace) -> int:
64
+ prog = api.get(f"/programs/{args.id}")["data"]
65
+ if args.json:
66
+ print_json_success(prog)
67
+ return EXIT_OK
68
+
69
+ pairs = [
70
+ ("ID", str(prog.get("id"))),
71
+ ("Name", str(prog.get("name") or "-")),
72
+ ("Type", str(prog.get("type") or "-")),
73
+ ("Platform", _platform_name(prog)),
74
+ ("Status", green("active") if prog.get("is_active") else red("inactive")),
75
+ ("Start", str(prog.get("start_date") or "-")),
76
+ ("End", str(prog.get("end_date") or "-")),
77
+ ]
78
+ print(bold(f"══ Program {prog.get('id')}: {prog.get('name')} ══\n"))
79
+ print(render_kv(pairs))
80
+
81
+ ranges = [
82
+ (lvl.title(), f"{prog.get(f'{lvl}_range_from')} - {prog.get(f'{lvl}_range_to')}")
83
+ for lvl in ("critical", "high", "medium", "low")
84
+ if prog.get(f"{lvl}_range_from") is not None
85
+ ]
86
+ if ranges:
87
+ print(f"\n{bold('Reward Ranges:')}")
88
+ print(render_kv(ranges))
89
+
90
+ domains = prog.get("domains") or []
91
+ if domains:
92
+ print(f"\n{bold('Domains:')}")
93
+ for d in domains:
94
+ print(f" • {d.get('domain', d) if isinstance(d, dict) else d}")
95
+
96
+ policy = prog.get("policy")
97
+ if policy:
98
+ print(f"\n{bold('Policy:')}\n{policy}")
99
+
100
+ out = prog.get("out_of_scope")
101
+ if out:
102
+ print(f"\n{bold('Out of Scope:')}\n{out}")
103
+
104
+ print()
105
+ suggest_next_step("bbsa reports list --json | jq '.[] | {id, status}'")
106
+ return EXIT_OK
@@ -0,0 +1,102 @@
1
+ """bbsa reports list / show / stats."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from bbsa import api
8
+ from bbsa.cli.formatters import (
9
+ EXIT_OK,
10
+ bold,
11
+ dim,
12
+ print_json_success,
13
+ render_kv,
14
+ render_table,
15
+ pick,
16
+ suggest_next_step,
17
+ text,
18
+ )
19
+
20
+
21
+ def cmd_reports_list(args: argparse.Namespace) -> int:
22
+ resp = api.get("/reports")
23
+ items = resp.get("data") or []
24
+ meta = resp.get("meta") or {}
25
+ total = meta.get("total") if meta.get("total") is not None else len(items)
26
+ # ponytail: client-side slice; switch to server page/limit if get large
27
+ shown = items[: args.limit] if args.limit else items
28
+
29
+ if args.json:
30
+ print_json_success(shown, meta={"total": total, "shown": len(shown)})
31
+ return EXIT_OK
32
+ if not shown:
33
+ print(dim("No reports found."))
34
+ return EXIT_OK
35
+
36
+ rows = [
37
+ [
38
+ str(r.get("id", "-")),
39
+ text(pick(r, "title", "name", default="-")),
40
+ text(pick(r, "status", default="-")),
41
+ text(pick(r, "severity", default="-")),
42
+ str(pick(r, "created_at", "updated_at", default="-")),
43
+ ]
44
+ for r in shown
45
+ ]
46
+ print(render_table(["ID", "TITLE", "STATUS", "SEVERITY", "UPDATED"], rows))
47
+ if len(shown) < total:
48
+ suggest_next_step(f"Showing {len(shown)} of {total} — see all with --limit {total}")
49
+ else:
50
+ suggest_next_step("bbsa reports show <ID-or-slug> for detail")
51
+ return EXIT_OK
52
+
53
+
54
+ def cmd_reports_show(args: argparse.Namespace) -> int:
55
+ r = api.get(f"/reports/{args.id}")["data"]
56
+ if args.json:
57
+ print_json_success(r)
58
+ return EXIT_OK
59
+
60
+ pairs = [
61
+ ("Title", text(pick(r, "title", "name", default="-"))),
62
+ ("Status", text(pick(r, "status", default="-"))),
63
+ ("Severity", text(pick(r, "severity", default="-"))),
64
+ ("Program", text(pick(r, "program", default=""))),
65
+ ("Created", text(pick(r, "created_at", default="-"))),
66
+ ("Updated", text(pick(r, "updated_at", default="-"))),
67
+ ("Summary", text(pick(r, "summary", default=""))),
68
+ ]
69
+ pairs = [(k, v) for k, v in pairs if v]
70
+ print(bold(f"══ Report {args.id} ══\n"))
71
+ print(render_kv(pairs))
72
+
73
+ desc = pick(r, "description", "body", "reproduction_steps", default="")
74
+ if desc:
75
+ print(f"\n{bold('Description:')}\n{desc}")
76
+
77
+ print("\nFull fields available with --json.")
78
+ return EXIT_OK
79
+
80
+
81
+ def cmd_reports_stats(args: argparse.Namespace) -> int:
82
+ resp = api.get("/reports/stats/grouped", {"groupBy": args.group})
83
+ payload = resp.get("data", resp)
84
+ if args.json:
85
+ print_json_success(payload, meta={"group_by": args.group})
86
+ return EXIT_OK
87
+
88
+ if isinstance(payload, dict):
89
+ print(bold(f"══ Report Stats (by {args.group}) ══\n"))
90
+ print(render_kv([(str(k), str(v)) for k, v in payload.items()]))
91
+ elif isinstance(payload, list):
92
+ rows = []
93
+ for x in payload:
94
+ if isinstance(x, dict):
95
+ rows.append([str(pick(x, "group", args.group, default="-")), str(x.get("count", "-"))])
96
+ else:
97
+ rows.append([str(x), "?"])
98
+ print(render_table([args.group.title(), "COUNT"], rows))
99
+ else:
100
+ print(str(payload))
101
+ suggest_next_step(r"jq filter: bbsa reports stats --json | jq '.data'")
102
+ return EXIT_OK
@@ -0,0 +1,187 @@
1
+ """CLI formatters: JSON envelopes, exit codes, ANSI color, tables, hints.
2
+
3
+ Mirrors the oh-my-ctf CLI conventions: stable JSON with --json, tab-separated
4
+ plain text when piped, ANSI color only on a TTY, data on stdout / errors on
5
+ stderr, bounded human output with next-step hints.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import re
13
+ import sys
14
+ from collections.abc import Sequence
15
+ from typing import Any
16
+
17
+ EXIT_OK = 0
18
+ EXIT_ERROR = 1
19
+ EXIT_USAGE = 2
20
+ EXIT_NOT_FOUND = 3
21
+
22
+ _COLOR_OVERRIDE: bool | None = None
23
+
24
+
25
+ def set_color_enabled(enabled: bool | None) -> None:
26
+ global _COLOR_OVERRIDE
27
+ _COLOR_OVERRIDE = enabled
28
+
29
+
30
+ def is_color_enabled(stream=sys.stdout) -> bool:
31
+ if _COLOR_OVERRIDE is not None:
32
+ return _COLOR_OVERRIDE
33
+ if "NO_COLOR" in os.environ:
34
+ return False
35
+ return hasattr(stream, "isatty") and stream.isatty()
36
+
37
+
38
+ def _ansi(code: str, text: str, stream=sys.stdout) -> str:
39
+ if not is_color_enabled(stream):
40
+ return str(text)
41
+ return f"\033[{code}m{text}\033[0m"
42
+
43
+
44
+ def bold(text, stream=sys.stdout):
45
+ return _ansi("1", text, stream)
46
+
47
+
48
+ def dim(text, stream=sys.stdout):
49
+ return _ansi("2", text, stream)
50
+
51
+
52
+ def red(text, stream=sys.stdout):
53
+ return _ansi("31", text, stream)
54
+
55
+
56
+ def green(text, stream=sys.stdout):
57
+ return _ansi("32", text, stream)
58
+
59
+
60
+ def yellow(text, stream=sys.stdout):
61
+ return _ansi("33", text, stream)
62
+
63
+
64
+ def cyan(text, stream=sys.stdout):
65
+ return _ansi("36", text, stream)
66
+
67
+
68
+ def pick(d: dict, *keys, default: Any = "") -> Any:
69
+ """First present key in d, or default. Immune to unknown response shapes."""
70
+ for k in keys:
71
+ if isinstance(d, dict) and d.get(k) is not None:
72
+ return d[k]
73
+ return default
74
+
75
+
76
+ def text(v: Any, max_len: int = 80) -> str:
77
+ """Human-friendly single-line rendering. Nested dicts collapse to their
78
+ name/label/title; long strings are truncated (bounded output)."""
79
+ if isinstance(v, dict):
80
+ for k in ("name", "label", "title", "title_en", "value"):
81
+ if v.get(k) is not None:
82
+ return text(v[k], max_len)
83
+ return str(v)[:max_len]
84
+ if isinstance(v, bool):
85
+ return str(v)
86
+ s = str(v)
87
+ if len(s) <= max_len:
88
+ return s
89
+ return s[: max_len - 1] + "…"
90
+
91
+
92
+ # ── JSON envelopes ────────────────────────────────────────────────────
93
+
94
+
95
+ def format_json_success(data: Any, meta: dict[str, Any] | None = None) -> str:
96
+ payload = {
97
+ "ok": True,
98
+ "data": data,
99
+ "meta": meta
100
+ or {"side_effects": False, "estimated_cost": None, "retryable": False},
101
+ }
102
+ return json.dumps(payload, indent=2)
103
+
104
+
105
+ def format_json_error(
106
+ code: str, message: str, retryable: bool = False, details: Any = None
107
+ ) -> str:
108
+ payload = {
109
+ "ok": False,
110
+ "error": {"code": code, "message": message, "retryable": retryable, "details": details},
111
+ }
112
+ return json.dumps(payload, indent=2)
113
+
114
+
115
+ def print_json_success(data: Any, meta: dict[str, Any] | None = None) -> None:
116
+ print(format_json_success(data, meta))
117
+
118
+
119
+ def print_json_error(
120
+ code: str, message: str, retryable: bool = False, details: Any = None
121
+ ) -> None:
122
+ print(format_json_error(code, message, retryable, details))
123
+
124
+
125
+ # ── Tables & text ─────────────────────────────────────────────────────
126
+
127
+
128
+ def render_table(
129
+ headers: Sequence[str],
130
+ rows: Sequence[Sequence[str]],
131
+ alignments: Sequence[str] | None = None,
132
+ stream=sys.stdout,
133
+ ) -> str:
134
+ """Aligned table on a TTY; tab-separated plain rows when piped."""
135
+ if not rows:
136
+ return ""
137
+ is_tty = is_color_enabled(stream) or (hasattr(stream, "isatty") and stream.isatty())
138
+ align = list(alignments or ["<"] * len(headers))
139
+ while len(align) < len(headers):
140
+ align.append("<")
141
+
142
+ col_widths = [len(str(h)) for h in headers]
143
+ for row in rows:
144
+ for i, val in enumerate(row):
145
+ if i < len(col_widths):
146
+ col_widths[i] = max(col_widths[i], len(_strip_ansi(str(val))))
147
+
148
+ if not is_tty:
149
+ lines = ["\t".join(str(h) for h in headers)]
150
+ for row in rows:
151
+ lines.append("\t".join(str(cell) for cell in row))
152
+ return "\n".join(lines)
153
+
154
+ def cell(text: str, width: int, alignment: str) -> str:
155
+ clean = _strip_ansi(str(text))
156
+ padding = width - len(clean)
157
+ if padding <= 0:
158
+ return str(text)
159
+ return (" " * padding + str(text)) if alignment == ">" else str(text) + " " * padding
160
+
161
+ header_line = " ".join(
162
+ bold(cell(headers[i], col_widths[i], align[i]), stream) for i in range(len(headers))
163
+ )
164
+ separator = " ".join(dim("─" * col_widths[i], stream) for i in range(len(headers)))
165
+ lines = [header_line, separator]
166
+ for row in rows:
167
+ lines.append(
168
+ " ".join(cell(str(row[i]) if i < len(row) else "", col_widths[i], align[i]) for i in range(len(headers)))
169
+ )
170
+ return "\n".join(lines)
171
+
172
+
173
+ def _strip_ansi(text: str) -> str:
174
+ return re.sub(r"\x1b\[[0-9;]*m", "", text)
175
+
176
+
177
+ def render_kv(pairs: list[tuple[str, str]], stream=sys.stdout) -> str:
178
+ if not pairs:
179
+ return ""
180
+ max_k = max(len(k) for k, _ in pairs)
181
+ return "\n".join(f"{bold(k.ljust(max_k), stream)} : {v}" for k, v in pairs)
182
+
183
+
184
+ def suggest_next_step(hint: str, stream=sys.stdout) -> None:
185
+ """Print a context-aware next-step hint, TTY only."""
186
+ if hasattr(stream, "isatty") and stream.isatty():
187
+ print(f"\n{dim('Next:', stream)} {cyan(hint, stream)}")