jabb-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,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: jabb-cli
3
+ Version: 0.1.0
4
+ Summary: CLI for JABB Public API v1 — locations, CX scores, reviews, PNIF, sentiment.
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: click>=8.1
7
+ Requires-Dist: requests>=2.31
8
+ Provides-Extra: dev
9
+ Requires-Dist: pytest>=8.0; extra == "dev"
@@ -0,0 +1,69 @@
1
+ # jabb
2
+
3
+ CLI for JABB Public API v1 — locations, CX scores, reviews, PNIF, sentiment. A thin wrapper: every command is one HTTP GET, no business logic.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install -e .
9
+ ```
10
+
11
+ ## Quickstart
12
+
13
+ ```bash
14
+ export JABB_API_KEY=jabb_test_...
15
+ jabb locations list
16
+ ```
17
+
18
+ Time to first result: under 5 minutes from a minted key.
19
+
20
+ ## Usage
21
+
22
+ ```
23
+ jabb [--json] [--detailed] <resource> list [--cursor <opaque>] [--limit <1-100>]
24
+ ```
25
+
26
+ Resources: `locations`, `scores`, `reviews`, `pnif`, `sentiment`.
27
+
28
+ - `--json` — print the raw API envelope (`{"data": [...], "next_cursor": ...}`) instead of a table. Use for scripting/agents.
29
+ - `--detailed` — request more fields per row (costs more tokens/bandwidth).
30
+ - `--cursor` — pass the previous response's `next_cursor` to page forward.
31
+ - `--limit` — rows per page, 1-100, default 20.
32
+
33
+ Examples:
34
+
35
+ ```bash
36
+ jabb scores list --detailed
37
+ jabb reviews list --limit 5
38
+ jabb pnif list --json
39
+ jabb locations list --cursor eyJwIjo... --limit 50
40
+ ```
41
+
42
+ ## Auth
43
+
44
+ Set `JABB_API_KEY` to a key minted by your JABB admin (`jabb_live_...` in production, `jabb_test_...` for sandbox). No config file, no `--api-key` flag — env var only, so keys don't land in shell history.
45
+
46
+ For local development against a jabb-server instance running on your machine:
47
+
48
+ ```bash
49
+ export JABB_API_BASE_URL=http://127.0.0.1:5500
50
+ ```
51
+
52
+ Default base URL is `https://api.jabb.cx`. There is no automatic failover to `api.jabb.pro` — that host runs a separate, diverged database, and silently reading from it would risk returning stale data.
53
+
54
+ ## Exit codes
55
+
56
+ | Code | Meaning |
57
+ |---|---|
58
+ | 0 | Success |
59
+ | 1 | Generic API error |
60
+ | 2 | Auth/config error (missing/invalid/revoked key, wrong scope) |
61
+ | 3 | Rate limited (message includes retry-after seconds) |
62
+
63
+ ## For Claude Code users
64
+
65
+ See `skills/jabb-cx/SKILL.md` — copy it into your project's `.claude/skills/jabb-cx/` to let Claude Code drive this CLI directly when you ask about your CX data.
66
+
67
+ ## Scope
68
+
69
+ Read-only. `list` operations against 5 resources, nothing else. No write/create/update commands exist.
File without changes
@@ -0,0 +1,77 @@
1
+ import sys
2
+ import json as jsonlib
3
+ import click
4
+ from jabb_cli.client import ApiClient, AuthError, RateLimitError, ApiError
5
+ from jabb_cli.output import render_table
6
+
7
+
8
+ @click.group()
9
+ @click.option("--json", "json_mode", is_flag=True, help="Print raw JSON envelope instead of a table. Use this when piping output to another program or an agent.")
10
+ @click.option("--detailed", is_flag=True, help="Request response_format=detailed (more fields per row). Costs more tokens — use only when concise fields are insufficient.")
11
+ @click.pass_context
12
+ def main(ctx, json_mode, detailed):
13
+ """jabb — CLI for JABB Public API v1 (locations, scores, reviews, pnif, sentiment).
14
+
15
+ Auth: set JABB_API_KEY to a key minted by your JABB admin.
16
+ """
17
+ ctx.ensure_object(dict)
18
+ ctx.obj["json"] = json_mode
19
+ ctx.obj["detailed"] = detailed
20
+
21
+
22
+ def list_resource(ctx, path):
23
+ params = {"limit": ctx.obj.get("limit", 20)}
24
+ if ctx.obj.get("cursor"):
25
+ params["cursor"] = ctx.obj["cursor"]
26
+ params["response_format"] = "detailed" if ctx.obj["detailed"] else "concise"
27
+ try:
28
+ client = ApiClient()
29
+ result = client.get(path, params=params)
30
+ except RateLimitError as e:
31
+ click.echo(jsonlib.dumps({"error": {"code": e.code, "message": e.message, "hint": e.hint}}) if ctx.obj["json"] else f"Rate limited: {e.message}. Retry after {e.retry_after}s. {e.hint}", err=True)
32
+ sys.exit(3)
33
+ except AuthError as e:
34
+ click.echo(jsonlib.dumps({"error": {"code": e.code, "message": e.message, "hint": e.hint}}) if ctx.obj["json"] else f"Auth error: {e.message}. {e.hint}", err=True)
35
+ sys.exit(2)
36
+ except ApiError as e:
37
+ click.echo(jsonlib.dumps({"error": {"code": e.code, "message": e.message, "hint": e.hint}}) if ctx.obj["json"] else f"API error: {e.message}. {e.hint}", err=True)
38
+ sys.exit(1)
39
+
40
+ if ctx.obj["json"]:
41
+ click.echo(jsonlib.dumps(result))
42
+ else:
43
+ data = result["data"]
44
+ rows = data if isinstance(data, list) else [data]
45
+ click.echo(render_table(rows))
46
+ if result.get("next_cursor"):
47
+ click.echo(f"\n(more results: --cursor {result['next_cursor']})", err=True)
48
+
49
+
50
+ RESOURCES = {
51
+ "locations": ("/api/public/v1/locations", "Business locations. Call when the agent needs a store/venue list or IDs to cross-reference other resources."),
52
+ "scores": ("/api/public/v1/cx-scores", "CX scores per entity. Call when the agent needs quantitative quality metrics, not raw review text."),
53
+ "reviews": ("/api/public/v1/quick-jabbs", "Quick JABB evaluations (reviews). Call when the agent needs individual review content/ratings."),
54
+ "pnif": ("/api/public/v1/pnif", "PNIF analysis. Call when the agent needs the Price/Need/Impact/Frequency breakdown, not raw scores."),
55
+ "sentiment": ("/api/public/v1/sentiment", "Sentiment mapping. Call when the agent needs sentiment labels/trends, not raw scores or reviews."),
56
+ }
57
+
58
+
59
+ def _make_group(name, path, help_text):
60
+ @click.group(name=name, help=help_text)
61
+ def group():
62
+ pass
63
+
64
+ @group.command(name="list", help=f"List {name} (paginated). {help_text}")
65
+ @click.option("--cursor", default=None, help="Opaque pagination cursor from a previous response's next_cursor.")
66
+ @click.option("--limit", default=20, type=click.IntRange(1, 100), help="Rows per page (1-100, default 20). Keep low for agent contexts.")
67
+ @click.pass_context
68
+ def list_cmd(ctx, cursor, limit):
69
+ ctx.obj["cursor"] = cursor
70
+ ctx.obj["limit"] = limit
71
+ list_resource(ctx, path)
72
+
73
+ return group
74
+
75
+
76
+ for _name, (_path, _help) in RESOURCES.items():
77
+ main.add_command(_make_group(_name, _path, _help))
@@ -0,0 +1,56 @@
1
+ import os
2
+ import requests
3
+
4
+ DEFAULT_BASE_URL = "https://api.jabb.cx"
5
+
6
+
7
+ class ApiError(Exception):
8
+ def __init__(self, code, message, hint):
9
+ super().__init__(message)
10
+ self.code = code
11
+ self.message = message
12
+ self.hint = hint
13
+
14
+
15
+ class AuthError(ApiError):
16
+ pass
17
+
18
+
19
+ class RateLimitError(ApiError):
20
+ def __init__(self, code, message, hint, retry_after):
21
+ super().__init__(code, message, hint)
22
+ self.retry_after = retry_after
23
+
24
+
25
+ class ApiClient:
26
+ def __init__(self):
27
+ key = os.environ.get("JABB_API_KEY")
28
+ if not key:
29
+ raise AuthError(
30
+ "missing_api_key",
31
+ "JABB_API_KEY is not set.",
32
+ "export JABB_API_KEY=jabb_live_... (mint one via your JABB admin).",
33
+ )
34
+ self.key = key
35
+ self.base_url = os.environ.get("JABB_API_BASE_URL", DEFAULT_BASE_URL)
36
+
37
+ def get(self, path, params=None):
38
+ resp = requests.request(
39
+ "GET",
40
+ url=f"{self.base_url}{path}",
41
+ headers={"Authorization": f"Bearer {self.key}"},
42
+ params=params or {},
43
+ timeout=15,
44
+ )
45
+ if resp.status_code == 200:
46
+ return resp.json()
47
+ try:
48
+ err = resp.json().get("error", {})
49
+ except (ValueError, KeyError):
50
+ raise ApiError("unknown", f"HTTP {resp.status_code}", "Server returned non-JSON response.")
51
+ code, message, hint = err.get("code"), err.get("message"), err.get("hint")
52
+ if resp.status_code == 429:
53
+ raise RateLimitError(code, message, hint, int(resp.headers.get("Retry-After", 0)))
54
+ if resp.status_code in (401, 403):
55
+ raise AuthError(code, message, hint)
56
+ raise ApiError(code, message, hint)
@@ -0,0 +1,18 @@
1
+ MAX_CELL = 40
2
+
3
+
4
+ def _cell(v):
5
+ s = str(v)
6
+ return s if len(s) <= MAX_CELL else s[: MAX_CELL - 3] + "..."
7
+
8
+
9
+ def render_table(rows):
10
+ if not rows:
11
+ return "(no results)"
12
+ cols = list(rows[0].keys())
13
+ cells = [[_cell(r.get(c, "")) for c in cols] for r in rows]
14
+ widths = [max(len(cols[i]), *(len(row[i]) for row in cells)) for i in range(len(cols))]
15
+ header = " ".join(c.ljust(w) for c, w in zip(cols, widths))
16
+ sep = " ".join("-" * w for w in widths)
17
+ body = "\n".join(" ".join(cell.ljust(w) for cell, w in zip(row, widths)) for row in cells)
18
+ return f"{header}\n{sep}\n{body}"
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: jabb-cli
3
+ Version: 0.1.0
4
+ Summary: CLI for JABB Public API v1 — locations, CX scores, reviews, PNIF, sentiment.
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: click>=8.1
7
+ Requires-Dist: requests>=2.31
8
+ Provides-Extra: dev
9
+ Requires-Dist: pytest>=8.0; extra == "dev"
@@ -0,0 +1,16 @@
1
+ README.md
2
+ pyproject.toml
3
+ jabb_cli/__init__.py
4
+ jabb_cli/cli.py
5
+ jabb_cli/client.py
6
+ jabb_cli/output.py
7
+ jabb_cli.egg-info/PKG-INFO
8
+ jabb_cli.egg-info/SOURCES.txt
9
+ jabb_cli.egg-info/dependency_links.txt
10
+ jabb_cli.egg-info/entry_points.txt
11
+ jabb_cli.egg-info/requires.txt
12
+ jabb_cli.egg-info/top_level.txt
13
+ tests/test_cli_errors.py
14
+ tests/test_cli_resources.py
15
+ tests/test_client.py
16
+ tests/test_output.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ jabb = jabb_cli.cli:main
@@ -0,0 +1,5 @@
1
+ click>=8.1
2
+ requests>=2.31
3
+
4
+ [dev]
5
+ pytest>=8.0
@@ -0,0 +1 @@
1
+ jabb_cli
@@ -0,0 +1,19 @@
1
+ [project]
2
+ name = "jabb-cli"
3
+ version = "0.1.0"
4
+ description = "CLI for JABB Public API v1 — locations, CX scores, reviews, PNIF, sentiment."
5
+ requires-python = ">=3.11"
6
+ dependencies = ["click>=8.1", "requests>=2.31"]
7
+
8
+ [project.optional-dependencies]
9
+ dev = ["pytest>=8.0"]
10
+
11
+ [project.scripts]
12
+ jabb = "jabb_cli.cli:main"
13
+
14
+ [tool.setuptools.packages.find]
15
+ include = ["jabb_cli*"]
16
+
17
+ [build-system]
18
+ requires = ["setuptools>=68"]
19
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,24 @@
1
+ from unittest.mock import patch
2
+ from click.testing import CliRunner
3
+ from jabb_cli.cli import main
4
+ from jabb_cli.client import AuthError, RateLimitError
5
+
6
+ def test_missing_key_exit_code_2(monkeypatch):
7
+ monkeypatch.delenv("JABB_API_KEY", raising=False)
8
+ result = CliRunner().invoke(main, ["locations", "list"])
9
+ assert result.exit_code == 2
10
+ assert "JABB_API_KEY" in result.output
11
+
12
+ def test_auth_error_exit_code_2(monkeypatch):
13
+ monkeypatch.setenv("JABB_API_KEY", "jabb_test_x")
14
+ with patch("jabb_cli.cli.ApiClient.get", side_effect=AuthError("invalid_api_key", "bad", "mint a new one")):
15
+ result = CliRunner().invoke(main, ["locations", "list"])
16
+ assert result.exit_code == 2
17
+ assert "mint a new one" in result.output
18
+
19
+ def test_rate_limit_exit_code_3(monkeypatch):
20
+ monkeypatch.setenv("JABB_API_KEY", "jabb_test_x")
21
+ with patch("jabb_cli.cli.ApiClient.get", side_effect=RateLimitError("rate_limited", "slow down", "wait", 7)):
22
+ result = CliRunner().invoke(main, ["locations", "list"])
23
+ assert result.exit_code == 3
24
+ assert "7" in result.output
@@ -0,0 +1,45 @@
1
+ import pytest
2
+ from unittest.mock import patch
3
+ from click.testing import CliRunner
4
+ from jabb_cli.cli import main
5
+
6
+ CASES = [
7
+ (["locations", "list"], "/api/public/v1/locations"),
8
+ (["scores", "list"], "/api/public/v1/cx-scores"),
9
+ (["reviews", "list"], "/api/public/v1/quick-jabbs"),
10
+ (["pnif", "list"], "/api/public/v1/pnif"),
11
+ (["sentiment", "list"], "/api/public/v1/sentiment"),
12
+ ]
13
+
14
+ @pytest.mark.parametrize("args,expected_path", CASES)
15
+ def test_resource_hits_expected_endpoint(monkeypatch, args, expected_path):
16
+ monkeypatch.setenv("JABB_API_KEY", "jabb_test_x")
17
+ with patch("jabb_cli.cli.ApiClient.get", return_value={"data": [], "next_cursor": None}) as m:
18
+ result = CliRunner().invoke(main, args)
19
+ assert result.exit_code == 0
20
+ assert m.call_args.args[0] == expected_path
21
+
22
+ def test_cursor_and_limit_flags_forwarded(monkeypatch):
23
+ monkeypatch.setenv("JABB_API_KEY", "jabb_test_x")
24
+ with patch("jabb_cli.cli.ApiClient.get", return_value={"data": [], "next_cursor": None}) as m:
25
+ CliRunner().invoke(main, ["locations", "list", "--cursor", "abc", "--limit", "5"])
26
+ assert m.call_args.kwargs["params"]["cursor"] == "abc"
27
+ assert m.call_args.kwargs["params"]["limit"] == 5
28
+
29
+ def test_dict_shaped_data_renders_as_single_row(monkeypatch):
30
+ """pnif/sentiment return an aggregate object, not a list of rows — must not crash."""
31
+ monkeypatch.setenv("JABB_API_KEY", "jabb_test_x")
32
+ dict_data = {"topics": [], "counts": {"total_positive": 0}}
33
+ with patch("jabb_cli.cli.ApiClient.get", return_value={"data": dict_data, "next_cursor": None}):
34
+ result = CliRunner().invoke(main, ["sentiment", "list"])
35
+ assert result.exit_code == 0
36
+ assert "topics" in result.output
37
+ assert "counts" in result.output
38
+
39
+ def test_dict_shaped_data_json_mode_unaffected(monkeypatch):
40
+ monkeypatch.setenv("JABB_API_KEY", "jabb_test_x")
41
+ dict_data = {"topics": []}
42
+ with patch("jabb_cli.cli.ApiClient.get", return_value={"data": dict_data, "next_cursor": None}):
43
+ result = CliRunner().invoke(main, ["--json", "sentiment", "list"])
44
+ assert result.exit_code == 0
45
+ assert result.output.strip() == '{"data": {"topics": []}, "next_cursor": null}'
@@ -0,0 +1,48 @@
1
+ import pytest
2
+ from unittest.mock import patch, MagicMock
3
+ from jabb_cli.client import ApiClient, AuthError, RateLimitError, ApiError
4
+
5
+ def _resp(status, body, headers=None):
6
+ m = MagicMock()
7
+ m.status_code = status
8
+ m.json.return_value = body
9
+ m.headers = headers or {}
10
+ return m
11
+
12
+ def test_missing_api_key_raises(monkeypatch):
13
+ monkeypatch.delenv("JABB_API_KEY", raising=False)
14
+ with pytest.raises(AuthError):
15
+ ApiClient()
16
+
17
+ def test_get_success(monkeypatch):
18
+ monkeypatch.setenv("JABB_API_KEY", "jabb_test_x")
19
+ client = ApiClient()
20
+ with patch("jabb_cli.client.requests.request", return_value=_resp(200, {"data": [{"id": "1"}], "next_cursor": None})) as m:
21
+ result = client.get("/api/public/v1/locations", params={"limit": 20})
22
+ assert result["data"] == [{"id": "1"}]
23
+ called_headers = m.call_args.kwargs["headers"]
24
+ assert called_headers["Authorization"] == "Bearer jabb_test_x"
25
+
26
+ def test_401_raises_auth_error(monkeypatch):
27
+ monkeypatch.setenv("JABB_API_KEY", "jabb_test_x")
28
+ client = ApiClient()
29
+ body = {"error": {"code": "invalid_api_key", "message": "bad key", "hint": "mint a new one"}}
30
+ with patch("jabb_cli.client.requests.request", return_value=_resp(401, body)):
31
+ with pytest.raises(AuthError) as exc:
32
+ client.get("/api/public/v1/locations")
33
+ assert exc.value.hint == "mint a new one"
34
+
35
+ def test_429_raises_rate_limit_with_retry_after(monkeypatch):
36
+ monkeypatch.setenv("JABB_API_KEY", "jabb_test_x")
37
+ client = ApiClient()
38
+ body = {"error": {"code": "rate_limited", "message": "slow down", "hint": "retry later"}}
39
+ with patch("jabb_cli.client.requests.request", return_value=_resp(429, body, {"Retry-After": "12"})):
40
+ with pytest.raises(RateLimitError) as exc:
41
+ client.get("/api/public/v1/locations")
42
+ assert exc.value.retry_after == 12
43
+
44
+ def test_base_url_override(monkeypatch):
45
+ monkeypatch.setenv("JABB_API_KEY", "jabb_test_x")
46
+ monkeypatch.setenv("JABB_API_BASE_URL", "http://127.0.0.1:5500")
47
+ client = ApiClient()
48
+ assert client.base_url == "http://127.0.0.1:5500"
@@ -0,0 +1,14 @@
1
+ from jabb_cli.output import render_table
2
+
3
+ def test_render_table_empty():
4
+ assert render_table([]) == "(no results)"
5
+
6
+ def test_render_table_columns_from_first_row():
7
+ out = render_table([{"id": "1", "name": "Cafe X"}, {"id": "2", "name": "Cafe Y"}])
8
+ lines = out.splitlines()
9
+ assert lines[0].split() == ["id", "name"]
10
+ assert "Cafe X" in out and "Cafe Y" in out
11
+
12
+ def test_render_table_truncates_long_values():
13
+ out = render_table([{"id": "1", "note": "x" * 80}])
14
+ assert "..." in out