bookai-cli 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,14 @@
1
+ .idea/
2
+ .venv/
3
+ .env
4
+ .env.*
5
+ !.env.example
6
+ data/db.json
7
+ __pycache__/
8
+ *.pyc
9
+ *.egg-info/
10
+ /cli/dist/
11
+ /cli/build/
12
+ data/
13
+ .coverage
14
+ /coverage.json
@@ -0,0 +1,73 @@
1
+ Metadata-Version: 2.5
2
+ Name: bookai-cli
3
+ Version: 0.1.0
4
+ Summary: Command-line client for the bookai group-sales back office API
5
+ Requires-Python: >=3.9
6
+ Requires-Dist: click<9,>=8
7
+ Requires-Dist: httpx<1,>=0.27
8
+ Requires-Dist: rich>=13
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest<9,>=8; extra == 'dev'
11
+ Requires-Dist: respx<1,>=0.21; extra == 'dev'
12
+ Description-Content-Type: text/markdown
13
+
14
+ # bookai-cli
15
+
16
+ Command-line client for the bookai group-sales back office API — manage venue
17
+ pricing and view confirmed orders from the terminal or a script, instead of
18
+ clicking through the admin web app.
19
+
20
+ This is a thin HTTP client: it talks only to the public, authenticated JSON
21
+ API and contains no business logic or backend code.
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ pip install bookai-cli
27
+ ```
28
+
29
+ ## Authenticate
30
+
31
+ Ask a venue admin to create an API key for you from the back office (Settings
32
+ → API Keys), then set it as an environment variable:
33
+
34
+ ```bash
35
+ export GROUPSALES_API_KEY=gsk_...
36
+ ```
37
+
38
+ Or pass it per-command with `--api-key`. By default the CLI talks to
39
+ `https://b2b.bookai.now`; override with `--base-url` or `GROUPSALES_BASE_URL`
40
+ if you're pointed at a different environment.
41
+
42
+ Global options (`--api-key`, `--base-url`, `--json`) go **before** the
43
+ subcommand: `bookai --json venues list`, not `bookai venues list --json`.
44
+
45
+ ## Usage
46
+
47
+ ```bash
48
+ bookai venues list
49
+
50
+ bookai pricing list <venue_id>
51
+ bookai pricing get <venue_id> <rule_id>
52
+ bookai pricing set <venue_id> --min-group 10 --max-group 50 --min-price 20 --max-price 30
53
+ bookai pricing set <venue_id> --min-group 10 --max-group 50 --min-price 20 --max-price 30 \
54
+ --source acme-isv --external-id acme-rule-42 # tag a rule as synced from an external system
55
+ bookai pricing update <venue_id> <rule_id> --min-group 10 --max-group 50 --min-price 18 --max-price 28
56
+ bookai pricing delete <venue_id> <rule_id>
57
+
58
+ bookai orders list
59
+ bookai orders list --page 2
60
+ ```
61
+
62
+ Add `--json` anywhere for machine-readable output instead of a table — useful
63
+ for piping into `jq` or scripting in CI.
64
+
65
+ ## Development
66
+
67
+ ```bash
68
+ pip install -e ".[dev]"
69
+ pytest
70
+ ```
71
+
72
+ Tests are fully offline (HTTP is mocked via `respx`) — no server or database
73
+ required.
@@ -0,0 +1,60 @@
1
+ # bookai-cli
2
+
3
+ Command-line client for the bookai group-sales back office API — manage venue
4
+ pricing and view confirmed orders from the terminal or a script, instead of
5
+ clicking through the admin web app.
6
+
7
+ This is a thin HTTP client: it talks only to the public, authenticated JSON
8
+ API and contains no business logic or backend code.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ pip install bookai-cli
14
+ ```
15
+
16
+ ## Authenticate
17
+
18
+ Ask a venue admin to create an API key for you from the back office (Settings
19
+ → API Keys), then set it as an environment variable:
20
+
21
+ ```bash
22
+ export GROUPSALES_API_KEY=gsk_...
23
+ ```
24
+
25
+ Or pass it per-command with `--api-key`. By default the CLI talks to
26
+ `https://b2b.bookai.now`; override with `--base-url` or `GROUPSALES_BASE_URL`
27
+ if you're pointed at a different environment.
28
+
29
+ Global options (`--api-key`, `--base-url`, `--json`) go **before** the
30
+ subcommand: `bookai --json venues list`, not `bookai venues list --json`.
31
+
32
+ ## Usage
33
+
34
+ ```bash
35
+ bookai venues list
36
+
37
+ bookai pricing list <venue_id>
38
+ bookai pricing get <venue_id> <rule_id>
39
+ bookai pricing set <venue_id> --min-group 10 --max-group 50 --min-price 20 --max-price 30
40
+ bookai pricing set <venue_id> --min-group 10 --max-group 50 --min-price 20 --max-price 30 \
41
+ --source acme-isv --external-id acme-rule-42 # tag a rule as synced from an external system
42
+ bookai pricing update <venue_id> <rule_id> --min-group 10 --max-group 50 --min-price 18 --max-price 28
43
+ bookai pricing delete <venue_id> <rule_id>
44
+
45
+ bookai orders list
46
+ bookai orders list --page 2
47
+ ```
48
+
49
+ Add `--json` anywhere for machine-readable output instead of a table — useful
50
+ for piping into `jq` or scripting in CI.
51
+
52
+ ## Development
53
+
54
+ ```bash
55
+ pip install -e ".[dev]"
56
+ pytest
57
+ ```
58
+
59
+ Tests are fully offline (HTTP is mocked via `respx`) — no server or database
60
+ required.
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "bookai-cli"
7
+ version = "0.1.0"
8
+ description = "Command-line client for the bookai group-sales back office API"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ dependencies = [
12
+ "click>=8,<9",
13
+ "httpx>=0.27,<1",
14
+ "rich>=13",
15
+ ]
16
+
17
+ [project.optional-dependencies]
18
+ dev = [
19
+ "pytest>=8,<9",
20
+ "respx>=0.21,<1",
21
+ ]
22
+
23
+ [project.scripts]
24
+ bookai = "groupsales_cli.__main__:cli"
25
+
26
+ [tool.hatch.build.targets.wheel]
27
+ packages = ["src/groupsales_cli"]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,32 @@
1
+ """bookai -- CLI client for the bookai group-sales back office API."""
2
+
3
+ import click
4
+
5
+ from groupsales_cli import __version__
6
+ from groupsales_cli.client import DEFAULT_BASE_URL
7
+ from groupsales_cli.commands.orders import orders
8
+ from groupsales_cli.commands.pricing import pricing
9
+ from groupsales_cli.commands.venues import venues
10
+
11
+
12
+ @click.group()
13
+ @click.option("--api-key", envvar="GROUPSALES_API_KEY", default=None,
14
+ help="API key (or set GROUPSALES_API_KEY).")
15
+ @click.option("--base-url", envvar="GROUPSALES_BASE_URL", default=DEFAULT_BASE_URL, show_default=True,
16
+ help="Back office API base URL (or set GROUPSALES_BASE_URL).")
17
+ @click.option("--json", "as_json", is_flag=True, default=False,
18
+ help="Print raw JSON instead of a table.")
19
+ @click.version_option(__version__, prog_name="bookai")
20
+ @click.pass_context
21
+ def cli(ctx: click.Context, api_key: str | None, base_url: str, as_json: bool):
22
+ """bookai: manage venues, pricing, and orders from the command line."""
23
+ ctx.obj = {"api_key": api_key, "base_url": base_url, "json": as_json}
24
+
25
+
26
+ cli.add_command(venues)
27
+ cli.add_command(pricing)
28
+ cli.add_command(orders)
29
+
30
+
31
+ if __name__ == "__main__":
32
+ cli()
@@ -0,0 +1,75 @@
1
+ """Thin HTTP client for the bookai group-sales back office API.
2
+
3
+ Talks only to the public, authenticated JSON API over `Authorization: Bearer
4
+ <api key>` -- this package never imports anything from the private backend
5
+ repo (services/models/routers), which is what makes it safe to publish this
6
+ CLI publicly while the backend stays closed.
7
+ """
8
+
9
+ import click
10
+ import httpx
11
+
12
+ DEFAULT_BASE_URL = "https://b2b.bookai.now"
13
+
14
+
15
+ class ApiError(click.ClickException):
16
+ """click prints .format_message() to stderr and exits 1 -- integrators
17
+ see a clean message, never a Python traceback."""
18
+
19
+ def __init__(self, status_code: int, detail: str):
20
+ super().__init__(f"API error {status_code}: {detail}")
21
+ self.status_code = status_code
22
+
23
+
24
+ class Client:
25
+ def __init__(self, base_url: str, api_key: str):
26
+ self._http = httpx.Client(
27
+ base_url=base_url,
28
+ headers={"Authorization": f"Bearer {api_key}"},
29
+ timeout=10,
30
+ )
31
+
32
+ def request(self, method: str, path: str, **kwargs):
33
+ try:
34
+ resp = self._http.request(method, path, **kwargs)
35
+ except httpx.RequestError as exc:
36
+ raise click.ClickException(f"Could not reach {self._http.base_url}: {exc}") from exc
37
+ if resp.status_code >= 400:
38
+ raise ApiError(resp.status_code, _error_detail(resp))
39
+ if resp.status_code == 204 or not resp.content:
40
+ return None
41
+ return resp.json()
42
+
43
+ def get(self, path: str, params: dict | None = None):
44
+ return self.request("GET", path, params=params)
45
+
46
+ def post(self, path: str, json: dict):
47
+ return self.request("POST", path, json=json)
48
+
49
+ def put(self, path: str, json: dict):
50
+ return self.request("PUT", path, json=json)
51
+
52
+ def delete(self, path: str):
53
+ return self.request("DELETE", path)
54
+
55
+
56
+ def _error_detail(resp: httpx.Response) -> str:
57
+ try:
58
+ data = resp.json()
59
+ except ValueError:
60
+ return resp.text or resp.reason_phrase
61
+ detail = data.get("detail") if isinstance(data, dict) else None
62
+ if isinstance(detail, str):
63
+ return detail
64
+ if isinstance(detail, list): # FastAPI 422 validation errors
65
+ return "; ".join(f"{'.'.join(str(p) for p in d.get('loc', []))}: {d.get('msg')}" for d in detail)
66
+ return str(data)
67
+
68
+
69
+ def build_client(ctx: click.Context) -> Client:
70
+ api_key = ctx.obj["api_key"]
71
+ if not api_key:
72
+ raise click.ClickException(
73
+ "No API key set. Pass --api-key or set the GROUPSALES_API_KEY environment variable."
74
+ )
75
+ return Client(ctx.obj["base_url"], api_key)
@@ -0,0 +1,21 @@
1
+ import click
2
+
3
+ from groupsales_cli.client import build_client
4
+ from groupsales_cli.output import render
5
+
6
+
7
+ @click.group()
8
+ def orders():
9
+ """View confirmed orders."""
10
+
11
+
12
+ @orders.command("list")
13
+ @click.option("--page", type=int, default=1, show_default=True)
14
+ @click.pass_context
15
+ def list_orders(ctx: click.Context, page: int):
16
+ """List confirmed orders (paginated)."""
17
+ client = build_client(ctx)
18
+ data = client.get("/admin/orders", params={"page": page})
19
+ columns = ["id", "group_name", "contact_email", "group_size", "channel", "total_price", "status",
20
+ "created_at_local"]
21
+ render(data, ctx.obj["json"], title="Orders", columns=columns)
@@ -0,0 +1,99 @@
1
+ import click
2
+
3
+ from groupsales_cli.client import build_client
4
+ from groupsales_cli.output import render
5
+
6
+ _RULE_OPTIONS = [
7
+ click.option("--min-group", type=int, required=True, help="Minimum group size this rule applies to."),
8
+ click.option("--max-group", type=int, required=True, help="Maximum group size this rule applies to."),
9
+ click.option("--min-price", type=float, required=True, help="Price floor per person."),
10
+ click.option("--max-price", type=float, required=True, help="Price ceiling per person."),
11
+ click.option("--event-id", default=None, help="Scope this rule to one event instead of the whole venue."),
12
+ click.option("--source", default="app", show_default=True, help="Origin tag, e.g. a 3P vendor name."),
13
+ click.option("--external-id", default=None, help="3P upsert key -- ignored while --source is 'app'."),
14
+ ]
15
+
16
+
17
+ def _add_rule_options(f):
18
+ for option in reversed(_RULE_OPTIONS):
19
+ f = option(f)
20
+ return f
21
+
22
+
23
+ def _rule_body(min_group, max_group, min_price, max_price, event_id, source, external_id) -> dict:
24
+ return {
25
+ "min_group": min_group,
26
+ "max_group": max_group,
27
+ "min_price": min_price,
28
+ "max_price": max_price,
29
+ "event_id": event_id,
30
+ "source": source,
31
+ "external_id": external_id,
32
+ }
33
+
34
+
35
+ _RULE_COLUMNS = ["id", "min_group", "max_group", "min_price", "max_price", "event_id", "source", "external_id"]
36
+
37
+
38
+ @click.group()
39
+ def pricing():
40
+ """Manage per-venue pricing rules."""
41
+
42
+
43
+ @pricing.command("list")
44
+ @click.argument("venue_id")
45
+ @click.pass_context
46
+ def list_pricing(ctx: click.Context, venue_id: str):
47
+ """List pricing rules for a venue."""
48
+ client = build_client(ctx)
49
+ data = client.get(f"/admin/venues/{venue_id}/pricing")
50
+ render(data, ctx.obj["json"], title=f"Pricing rules -- {venue_id}", columns=_RULE_COLUMNS)
51
+
52
+
53
+ @pricing.command("get")
54
+ @click.argument("venue_id")
55
+ @click.argument("rule_id")
56
+ @click.pass_context
57
+ def get_pricing(ctx: click.Context, venue_id: str, rule_id: str):
58
+ """Show one pricing rule."""
59
+ client = build_client(ctx)
60
+ data = client.get(f"/admin/venues/{venue_id}/pricing/{rule_id}")
61
+ render(data, ctx.obj["json"], columns=_RULE_COLUMNS)
62
+
63
+
64
+ @pricing.command("set")
65
+ @click.argument("venue_id")
66
+ @_add_rule_options
67
+ @click.pass_context
68
+ def set_pricing(ctx: click.Context, venue_id: str, min_group, max_group, min_price, max_price,
69
+ event_id, source, external_id):
70
+ """Create a new pricing rule."""
71
+ client = build_client(ctx)
72
+ body = _rule_body(min_group, max_group, min_price, max_price, event_id, source, external_id)
73
+ data = client.post(f"/admin/venues/{venue_id}/pricing", json=body)
74
+ render(data, ctx.obj["json"], columns=_RULE_COLUMNS)
75
+
76
+
77
+ @pricing.command("update")
78
+ @click.argument("venue_id")
79
+ @click.argument("rule_id")
80
+ @_add_rule_options
81
+ @click.pass_context
82
+ def update_pricing(ctx: click.Context, venue_id: str, rule_id: str, min_group, max_group, min_price, max_price,
83
+ event_id, source, external_id):
84
+ """Replace an existing pricing rule."""
85
+ client = build_client(ctx)
86
+ body = _rule_body(min_group, max_group, min_price, max_price, event_id, source, external_id)
87
+ data = client.put(f"/admin/venues/{venue_id}/pricing/{rule_id}", json=body)
88
+ render(data, ctx.obj["json"], columns=_RULE_COLUMNS)
89
+
90
+
91
+ @pricing.command("delete")
92
+ @click.argument("venue_id")
93
+ @click.argument("rule_id")
94
+ @click.pass_context
95
+ def delete_pricing(ctx: click.Context, venue_id: str, rule_id: str):
96
+ """Delete a pricing rule."""
97
+ client = build_client(ctx)
98
+ client.delete(f"/admin/venues/{venue_id}/pricing/{rule_id}")
99
+ click.echo(f"Deleted {rule_id}")
@@ -0,0 +1,18 @@
1
+ import click
2
+
3
+ from groupsales_cli.client import build_client
4
+ from groupsales_cli.output import render
5
+
6
+
7
+ @click.group()
8
+ def venues():
9
+ """Manage venues."""
10
+
11
+
12
+ @venues.command("list")
13
+ @click.pass_context
14
+ def list_venues(ctx: click.Context):
15
+ """List venues visible to this API key's account."""
16
+ client = build_client(ctx)
17
+ data = client.get("/admin/venues")
18
+ render(data, ctx.obj["json"], title="Venues", columns=["id", "name", "city_name", "state", "country"])
@@ -0,0 +1,59 @@
1
+ """Rendering: a rich table for humans, raw JSON for --json / scripts -- the
2
+ CLI's integrators range from non-technical staff to CI pipelines, so both
3
+ need to be first-class, not one bolted onto the other."""
4
+
5
+ import json as json_lib
6
+
7
+ import click
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+
11
+ _console = Console()
12
+
13
+
14
+ def render(data, as_json: bool, title: str | None = None, columns: list[str] | None = None) -> None:
15
+ """columns curates which fields show in table view (order matters) --
16
+ without it every JSON key becomes a column, which is unreadable once a
17
+ resource has a nested/long field (e.g. Venue.branding, Order.lines).
18
+ --json always returns the full record regardless of columns."""
19
+ if as_json:
20
+ click.echo(json_lib.dumps(data, indent=2, default=str))
21
+ return
22
+ if data is None:
23
+ click.echo("OK")
24
+ return
25
+ if isinstance(data, list):
26
+ _render_list(data, title, columns)
27
+ elif isinstance(data, dict) and isinstance(data.get("items"), list):
28
+ _render_list(data["items"], title, columns)
29
+ click.echo(f"page {data.get('page')}/{data.get('total_pages')} ({data.get('total')} total)")
30
+ else:
31
+ _render_list([data], title, columns)
32
+
33
+
34
+ def _render_list(rows: list, title: str | None, columns: list[str] | None) -> None:
35
+ if not rows:
36
+ click.echo("(none)")
37
+ return
38
+ cols = columns or list(rows[0].keys())
39
+ table = Table(title=title)
40
+ for col in cols:
41
+ if col == "id":
42
+ # Never truncate or wrap the row's own id -- it's the handle a
43
+ # follow-up get/update/delete command needs verbatim, unlike
44
+ # every other column here, where losing a few characters to an
45
+ # ellipsis is a fine, normal CLI table tradeoff.
46
+ table.add_column(col, no_wrap=True)
47
+ else:
48
+ table.add_column(col, overflow="fold")
49
+ for row in rows:
50
+ table.add_row(*(_cell(row.get(col)) for col in cols))
51
+ _console.print(table)
52
+
53
+
54
+ def _cell(value) -> str:
55
+ if value is None:
56
+ return ""
57
+ if isinstance(value, (dict, list)):
58
+ return json_lib.dumps(value)
59
+ return str(value)
@@ -0,0 +1,7 @@
1
+ import pytest
2
+ from click.testing import CliRunner
3
+
4
+
5
+ @pytest.fixture
6
+ def runner():
7
+ return CliRunner()
@@ -0,0 +1,13 @@
1
+ from groupsales_cli.__main__ import cli
2
+
3
+ BASE_URL = "https://test.example"
4
+
5
+
6
+ def test_orders_list(runner, respx_mock):
7
+ respx_mock.get(f"{BASE_URL}/admin/orders", params={"page": "1"}).respond(
8
+ json={"items": [{"id": "o1", "group_name": "Test Group"}], "page": 1, "total_pages": 1, "total": 1}
9
+ )
10
+ result = runner.invoke(cli, ["--api-key", "k", "--base-url", BASE_URL, "orders", "list"])
11
+ assert result.exit_code == 0
12
+ assert "o1" in result.output
13
+ assert "page 1/1" in result.output
@@ -0,0 +1,55 @@
1
+ from groupsales_cli.__main__ import cli
2
+
3
+ BASE_URL = "https://test.example"
4
+ _BASE_ARGS = ["--api-key", "k", "--base-url", BASE_URL]
5
+
6
+ _RULE = {
7
+ "id": "r1", "venue_id": "amnh", "min_group": 5, "max_group": 20,
8
+ "min_price": 10.0, "max_price": 20.0, "event_id": None, "source": "app", "external_id": None,
9
+ }
10
+
11
+
12
+ def test_pricing_list(runner, respx_mock):
13
+ respx_mock.get(f"{BASE_URL}/admin/venues/amnh/pricing").respond(json=[_RULE])
14
+ result = runner.invoke(cli, [*_BASE_ARGS, "pricing", "list", "amnh"])
15
+ assert result.exit_code == 0
16
+ assert "r1" in result.output
17
+
18
+
19
+ def test_pricing_set_sends_bearer_auth_and_all_fields(runner, respx_mock):
20
+ route = respx_mock.post(f"{BASE_URL}/admin/venues/amnh/pricing").respond(status_code=201, json=_RULE)
21
+ result = runner.invoke(cli, [
22
+ *_BASE_ARGS, "pricing", "set", "amnh",
23
+ "--min-group", "5", "--max-group", "20", "--min-price", "10", "--max-price", "20",
24
+ "--source", "acme-isv", "--external-id", "ext-42",
25
+ ])
26
+ assert result.exit_code == 0, result.output
27
+ assert route.called
28
+ request = route.calls.last.request
29
+ assert request.headers["authorization"] == "Bearer k"
30
+ body = request.content
31
+ assert b'"source":"acme-isv"' in body or b'"source": "acme-isv"' in body
32
+
33
+
34
+ def test_pricing_delete(runner, respx_mock):
35
+ respx_mock.delete(f"{BASE_URL}/admin/venues/amnh/pricing/r1").respond(status_code=204)
36
+ result = runner.invoke(cli, [*_BASE_ARGS, "pricing", "delete", "amnh", "r1"])
37
+ assert result.exit_code == 0
38
+ assert "Deleted r1" in result.output
39
+
40
+
41
+ def test_api_error_shows_clean_message_not_a_traceback(runner, respx_mock):
42
+ respx_mock.get(f"{BASE_URL}/admin/venues/other/pricing").respond(
43
+ status_code=404, json={"detail": "Venue not found"}
44
+ )
45
+ result = runner.invoke(cli, [*_BASE_ARGS, "pricing", "list", "other"])
46
+ assert result.exit_code != 0
47
+ assert "Venue not found" in result.output
48
+ assert "Traceback" not in result.output
49
+
50
+
51
+ def test_json_flag_prints_raw_json(runner, respx_mock):
52
+ respx_mock.get(f"{BASE_URL}/admin/venues/amnh/pricing").respond(json=[_RULE])
53
+ result = runner.invoke(cli, ["--api-key", "k", "--base-url", BASE_URL, "--json", "pricing", "list", "amnh"])
54
+ assert result.exit_code == 0
55
+ assert '"id": "r1"' in result.output
@@ -0,0 +1,21 @@
1
+ from groupsales_cli.__main__ import cli
2
+
3
+ BASE_URL = "https://test.example"
4
+
5
+
6
+ def test_list_venues(runner, respx_mock):
7
+ respx_mock.get(f"{BASE_URL}/admin/venues").respond(
8
+ json=[{"id": "amnh", "name": "American National AI History Museum"}]
9
+ )
10
+ result = runner.invoke(cli, ["--api-key", "k", "--base-url", BASE_URL, "venues", "list"])
11
+ assert result.exit_code == 0
12
+ assert "amnh" in result.output
13
+
14
+
15
+ def test_missing_api_key_fails_clean_not_with_a_traceback(runner, respx_mock):
16
+ result = runner.invoke(
17
+ cli, ["--base-url", BASE_URL, "venues", "list"], env={"GROUPSALES_API_KEY": ""},
18
+ )
19
+ assert result.exit_code != 0
20
+ assert "GROUPSALES_API_KEY" in result.output
21
+ assert "Traceback" not in result.output