akta-pro-cli 0.3.0__py3-none-any.whl
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.
- akta_pro_cli/__init__.py +8 -0
- akta_pro_cli/__main__.py +25 -0
- akta_pro_cli/app.py +94 -0
- akta_pro_cli/client.py +102 -0
- akta_pro_cli/commands/__init__.py +1 -0
- akta_pro_cli/commands/account.py +35 -0
- akta_pro_cli/commands/alternative.py +109 -0
- akta_pro_cli/commands/auth.py +123 -0
- akta_pro_cli/commands/company.py +184 -0
- akta_pro_cli/commands/config.py +54 -0
- akta_pro_cli/commands/industry.py +46 -0
- akta_pro_cli/commands/news.py +232 -0
- akta_pro_cli/commands/update.py +64 -0
- akta_pro_cli/config.py +71 -0
- akta_pro_cli/console.py +10 -0
- akta_pro_cli/news_tags.py +102 -0
- akta_pro_cli/options.py +21 -0
- akta_pro_cli/runtime.py +157 -0
- akta_pro_cli/update.py +79 -0
- akta_pro_cli-0.3.0.dist-info/METADATA +134 -0
- akta_pro_cli-0.3.0.dist-info/RECORD +25 -0
- akta_pro_cli-0.3.0.dist-info/WHEEL +5 -0
- akta_pro_cli-0.3.0.dist-info/entry_points.txt +2 -0
- akta_pro_cli-0.3.0.dist-info/licenses/LICENSE +21 -0
- akta_pro_cli-0.3.0.dist-info/top_level.txt +1 -0
akta_pro_cli/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""akta.pro CLI — a command-line client for the akta.pro REST API (https://api.akta.pro).
|
|
2
|
+
|
|
3
|
+
A standalone client (its own `akta-pro-cli` distribution) exposing the `akta-pro`
|
|
4
|
+
console command; a sibling of the akta.pro MCP server over the same `/api/v1`
|
|
5
|
+
endpoints, with no MCP-server code.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.3.0"
|
akta_pro_cli/__main__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Entry point for `akta-pro` and `python -m akta_pro_cli`.
|
|
2
|
+
|
|
3
|
+
Lazily imports the Typer app so a broken/partial install prints a helpful hint
|
|
4
|
+
instead of an ImportError traceback.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main() -> None:
|
|
13
|
+
try:
|
|
14
|
+
from akta_pro_cli.app import app
|
|
15
|
+
except ModuleNotFoundError as exc: # e.g. a broken install missing typer/rich
|
|
16
|
+
sys.stderr.write(
|
|
17
|
+
f"The akta.pro CLI is missing a dependency ({exc.name}).\n"
|
|
18
|
+
"Reinstall with: pipx install akta-pro-cli\n"
|
|
19
|
+
)
|
|
20
|
+
raise SystemExit(1) from exc
|
|
21
|
+
app()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
if __name__ == "__main__":
|
|
25
|
+
main()
|
akta_pro_cli/app.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Assembles the `akta-pro` Typer application: root callback + command tree."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from akta_pro_cli import __version__
|
|
10
|
+
from akta_pro_cli.client import DEFAULT_BASE_URL
|
|
11
|
+
from akta_pro_cli.commands import (
|
|
12
|
+
account,
|
|
13
|
+
alternative,
|
|
14
|
+
auth,
|
|
15
|
+
company,
|
|
16
|
+
config,
|
|
17
|
+
industry,
|
|
18
|
+
news,
|
|
19
|
+
update,
|
|
20
|
+
)
|
|
21
|
+
from akta_pro_cli.console import err, out
|
|
22
|
+
from akta_pro_cli.runtime import AppContext
|
|
23
|
+
|
|
24
|
+
app = typer.Typer(
|
|
25
|
+
no_args_is_help=True,
|
|
26
|
+
add_completion=True,
|
|
27
|
+
rich_markup_mode="rich",
|
|
28
|
+
help="akta.pro CLI — company & market intelligence from api.akta.pro.",
|
|
29
|
+
epilog="Auth: set AKTA_PRO_API_KEY, pass --api-key, or run `akta-pro login`. Docs: https://docs.akta.pro",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _version_callback(value: bool) -> None:
|
|
34
|
+
if value:
|
|
35
|
+
out.print(f"akta-pro {__version__}")
|
|
36
|
+
# Best-effort, cached (~daily) hint — interactive only, to stderr so it
|
|
37
|
+
# never pollutes a scripted `akta-pro --version`. Never blocks or errors.
|
|
38
|
+
if out.is_terminal:
|
|
39
|
+
try:
|
|
40
|
+
from akta_pro_cli.update import cached_latest, is_newer
|
|
41
|
+
|
|
42
|
+
latest = cached_latest(timeout=2.0)
|
|
43
|
+
if latest and is_newer(latest, __version__):
|
|
44
|
+
err.print(f"[dim]A newer version v{latest} is available — run `akta-pro update`.[/]")
|
|
45
|
+
except Exception:
|
|
46
|
+
pass
|
|
47
|
+
raise typer.Exit()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@app.callback()
|
|
51
|
+
def main(
|
|
52
|
+
ctx: typer.Context,
|
|
53
|
+
api_key: Annotated[
|
|
54
|
+
str | None,
|
|
55
|
+
typer.Option("--api-key", envvar="AKTA_PRO_API_KEY", show_default=False, help="akta.pro API key (wk_...)."),
|
|
56
|
+
] = None,
|
|
57
|
+
base_url: Annotated[
|
|
58
|
+
str | None,
|
|
59
|
+
typer.Option(
|
|
60
|
+
"--base-url",
|
|
61
|
+
envvar="AKTA_PRO_API_BASE_URL",
|
|
62
|
+
show_default=False,
|
|
63
|
+
help=f"Override the API base URL (default {DEFAULT_BASE_URL}; or persist it via `akta-pro login --base-url …`).",
|
|
64
|
+
),
|
|
65
|
+
] = None,
|
|
66
|
+
quiet: Annotated[
|
|
67
|
+
bool,
|
|
68
|
+
typer.Option("--quiet", "-q", help="Suppress the credits line on stderr."),
|
|
69
|
+
] = False,
|
|
70
|
+
timeout: Annotated[
|
|
71
|
+
float,
|
|
72
|
+
typer.Option("--timeout", help="HTTP request timeout in seconds."),
|
|
73
|
+
] = 30.0,
|
|
74
|
+
version: Annotated[
|
|
75
|
+
bool | None,
|
|
76
|
+
typer.Option("--version", callback=_version_callback, is_eager=True, help="Show version and exit."),
|
|
77
|
+
] = None,
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Global options. Pass these before the command, e.g. `akta-pro --api-key wk_… company search Canva`."""
|
|
80
|
+
ctx.obj = AppContext(api_key=api_key, base_url=base_url, quiet=quiet, timeout=timeout)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# Command groups
|
|
84
|
+
app.add_typer(company.app, name="company")
|
|
85
|
+
app.add_typer(industry.app, name="industry")
|
|
86
|
+
app.add_typer(news.app, name="news") # signals, detail, types
|
|
87
|
+
app.add_typer(alternative.reviews_app, name="reviews")
|
|
88
|
+
|
|
89
|
+
# Top-level commands
|
|
90
|
+
auth.register(app) # login, logout, whoami
|
|
91
|
+
account.register(app) # account
|
|
92
|
+
config.register(app) # config show / base-url
|
|
93
|
+
update.register(app) # update (self-update / check)
|
|
94
|
+
alternative.register(app) # headcount, traffic, jobs, posts
|
akta_pro_cli/client.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Standalone synchronous HTTP client for the akta.pro REST API.
|
|
2
|
+
|
|
3
|
+
Deliberately self-contained (no dependency on `akta_mcp.*`): the server's
|
|
4
|
+
`AktaClient` reads its key from a request-scoped ContextVar set by the OAuth
|
|
5
|
+
middleware and transitively imports the Redis/Postgres token stores, none of
|
|
6
|
+
which a CLI wants. This mirrors the server's error handling and redirect
|
|
7
|
+
behaviour but takes the API key explicitly and runs synchronously.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from . import __version__
|
|
15
|
+
|
|
16
|
+
DEFAULT_BASE_URL = "https://api.akta.pro/api/v1"
|
|
17
|
+
|
|
18
|
+
# Sent on every request so the backend can distinguish (and version-track) CLI
|
|
19
|
+
# traffic. Mirrors the MCP's `X-Client-Source: AKTA-MCP`.
|
|
20
|
+
CLIENT_SOURCE = f"AKTA-PRO-CLI/{__version__}"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AktaAPIError(RuntimeError):
|
|
24
|
+
"""An HTTP error from the akta.pro API, preserving the status code and body."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, message: str, *, status_code: int, body: dict | None = None):
|
|
27
|
+
super().__init__(message)
|
|
28
|
+
self.status_code = status_code
|
|
29
|
+
self.body = body
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_ERROR_MESSAGES = {
|
|
33
|
+
400: "Bad request — check the parameters.",
|
|
34
|
+
401: "Authentication failed. Check your API key (`akta-pro login`).",
|
|
35
|
+
403: (
|
|
36
|
+
"Access denied — your plan or credit balance does not cover this data "
|
|
37
|
+
"(alternative signals require Subscription/Enterprise; Funding and M&A "
|
|
38
|
+
"sections are enterprise-only)."
|
|
39
|
+
),
|
|
40
|
+
404: "Not found.",
|
|
41
|
+
429: "Rate limit exceeded. Retry with backoff.",
|
|
42
|
+
500: "akta.pro server error. Please try again later.",
|
|
43
|
+
502: "akta.pro service unavailable. Please retry.",
|
|
44
|
+
503: "akta.pro service unavailable. Please retry.",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _sanitize_http_error(exc: httpx.HTTPStatusError) -> AktaAPIError:
|
|
49
|
+
status = exc.response.status_code
|
|
50
|
+
msg = _ERROR_MESSAGES.get(status, f"Request failed with status {status}.")
|
|
51
|
+
try:
|
|
52
|
+
body = exc.response.json()
|
|
53
|
+
except ValueError:
|
|
54
|
+
body = None
|
|
55
|
+
if isinstance(body, dict):
|
|
56
|
+
detail = body.get("detail") or body.get("message") or body.get("error")
|
|
57
|
+
if detail:
|
|
58
|
+
msg = f"{msg} ({detail})"
|
|
59
|
+
return AktaAPIError(msg, status_code=status, body=body if isinstance(body, dict) else None)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _clean(params: dict | None) -> dict:
|
|
63
|
+
# Drop unset optional params so they aren't serialized onto the query string.
|
|
64
|
+
return {k: v for k, v in (params or {}).items() if v is not None}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class AktaClient:
|
|
68
|
+
"""Thin synchronous httpx wrapper that sends `x-api-key` on every GET."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, base_url: str, api_key: str, timeout: float = 30.0):
|
|
71
|
+
self._api_key = api_key
|
|
72
|
+
# follow_redirects: several akta.pro routes are declared with a trailing slash,
|
|
73
|
+
# so a slashless path 307-redirects to the canonical one. The redirect is
|
|
74
|
+
# same-origin, so x-api-key is re-sent.
|
|
75
|
+
self._http = httpx.Client(base_url=base_url, timeout=timeout, follow_redirects=True)
|
|
76
|
+
|
|
77
|
+
def _headers(self) -> dict:
|
|
78
|
+
return {"x-api-key": self._api_key, "X-Client-Source": CLIENT_SOURCE}
|
|
79
|
+
|
|
80
|
+
def get(self, path: str, params: dict | None = None):
|
|
81
|
+
"""GET, returning parsed JSON when the response is JSON, else raw text.
|
|
82
|
+
|
|
83
|
+
Text is returned for Akta's server-rendered Markdown endpoints (e.g.
|
|
84
|
+
`/company/enrichment/markdown`).
|
|
85
|
+
"""
|
|
86
|
+
resp = self._http.get(path, params=_clean(params), headers=self._headers())
|
|
87
|
+
try:
|
|
88
|
+
resp.raise_for_status()
|
|
89
|
+
except httpx.HTTPStatusError as exc:
|
|
90
|
+
raise _sanitize_http_error(exc) from None
|
|
91
|
+
if "json" in resp.headers.get("content-type", "").lower():
|
|
92
|
+
return resp.json()
|
|
93
|
+
return resp.text
|
|
94
|
+
|
|
95
|
+
def close(self) -> None:
|
|
96
|
+
self._http.close()
|
|
97
|
+
|
|
98
|
+
def __enter__(self) -> AktaClient:
|
|
99
|
+
return self
|
|
100
|
+
|
|
101
|
+
def __exit__(self, *exc: object) -> None:
|
|
102
|
+
self.close()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""akta.pro CLI command modules."""
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""`akta-pro account` — the caller's plan tier and credit balance."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
|
|
8
|
+
from akta_pro_cli.options import JsonOpt, OutOpt
|
|
9
|
+
from akta_pro_cli.runtime import emit, fetch
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _account_table(result: object) -> Table | None:
|
|
13
|
+
if not isinstance(result, dict):
|
|
14
|
+
return None
|
|
15
|
+
table = Table(title="akta.pro account", show_header=False)
|
|
16
|
+
table.add_column("Field", style="bold")
|
|
17
|
+
table.add_column("Value")
|
|
18
|
+
for key in ("package_type", "is_enterprise", "credit_balance", "currency"):
|
|
19
|
+
if key in result:
|
|
20
|
+
table.add_row(key.replace("_", " ").title(), str(result[key]))
|
|
21
|
+
return table
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def account(ctx: typer.Context, json_out: JsonOpt = False, output: OutOpt = None) -> None:
|
|
25
|
+
"""Show your plan tier (is_enterprise, package_type) and credit balance (free).
|
|
26
|
+
|
|
27
|
+
Check this before Subscription/Enterprise-only commands (headcount, traffic,
|
|
28
|
+
jobs, posts, reviews) to know whether they'll be allowed.
|
|
29
|
+
"""
|
|
30
|
+
result = fetch(ctx.obj, "/mcp/account")
|
|
31
|
+
emit(ctx.obj, result, json_out=json_out, output=output, renderer=_account_table)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def register(app: typer.Typer) -> None:
|
|
35
|
+
app.command("account")(account)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Alternative-signal commands: headcount, traffic, jobs, posts, and reviews.
|
|
2
|
+
|
|
3
|
+
All require Subscription/Enterprise access; Pay-as-you-go returns 403.
|
|
4
|
+
`headcount`/`traffic`/`jobs`/`posts` register at the top level; employee and
|
|
5
|
+
product reviews live under the `akta-pro reviews` group.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Annotated
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from akta_pro_cli.options import CompanyArg, JsonOpt, LimitOpt, OffsetOpt, OutOpt
|
|
15
|
+
from akta_pro_cli.runtime import emit, fetch
|
|
16
|
+
|
|
17
|
+
reviews_app = typer.Typer(
|
|
18
|
+
no_args_is_help=True,
|
|
19
|
+
help="Employee and product reviews (Subscription/Enterprise).",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def headcount(ctx: typer.Context, company: CompanyArg, json_out: JsonOpt = False, output: OutOpt = None) -> None:
|
|
24
|
+
"""Headcount trends: total employees, historical growth, function breakdown. 2.5 credits."""
|
|
25
|
+
emit(ctx.obj, fetch(ctx.obj, "/company/headcount-trends", {"company": company}), json_out=json_out, output=output)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def traffic(ctx: typer.Context, company: CompanyArg, json_out: JsonOpt = False, output: OutOpt = None) -> None:
|
|
29
|
+
"""Website traffic: engagement, monthly visits, and channel breakdown. 1.5 credits."""
|
|
30
|
+
emit(ctx.obj, fetch(ctx.obj, "/company/website-traffic", {"company": company}), json_out=json_out, output=output)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def jobs(
|
|
34
|
+
ctx: typer.Context,
|
|
35
|
+
company: CompanyArg,
|
|
36
|
+
limit: LimitOpt = 10,
|
|
37
|
+
offset: OffsetOpt = 0,
|
|
38
|
+
json_out: JsonOpt = False,
|
|
39
|
+
output: OutOpt = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Live job posts: title, location, description, comp, level, skills. 3 credits."""
|
|
42
|
+
emit(ctx.obj, fetch(ctx.obj, "/company/jobs", {"company": company, "limit": limit, "offset": offset}), json_out=json_out, output=output)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def posts(
|
|
46
|
+
ctx: typer.Context,
|
|
47
|
+
company: CompanyArg,
|
|
48
|
+
limit: LimitOpt = 10,
|
|
49
|
+
offset: OffsetOpt = 0,
|
|
50
|
+
json_out: JsonOpt = False,
|
|
51
|
+
output: OutOpt = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Company social posts: content, date, paid/repost flags, engagement. 1.5 credits."""
|
|
54
|
+
emit(ctx.obj, fetch(ctx.obj, "/company/posts", {"company": company, "limit": limit, "offset": offset}), json_out=json_out, output=output)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@reviews_app.command("employees")
|
|
58
|
+
def employee_reviews(
|
|
59
|
+
ctx: typer.Context,
|
|
60
|
+
company: CompanyArg,
|
|
61
|
+
limit: Annotated[int, typer.Option("-n", "--limit", min=1, max=100, help="Max reviews to return (max 100).")] = 10,
|
|
62
|
+
offset: OffsetOpt = 0,
|
|
63
|
+
json_out: JsonOpt = False,
|
|
64
|
+
output: OutOpt = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Employee reviews: overall + dimension ratings (Glassdoor et al.). 1.5 credits / 50."""
|
|
67
|
+
emit(
|
|
68
|
+
ctx.obj,
|
|
69
|
+
fetch(ctx.obj, "/company/employee-reviews", {"company": company, "limit": limit, "offset": offset}),
|
|
70
|
+
json_out=json_out,
|
|
71
|
+
output=output,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@reviews_app.command("products")
|
|
76
|
+
def product_reviews(
|
|
77
|
+
ctx: typer.Context,
|
|
78
|
+
company: CompanyArg,
|
|
79
|
+
product_id: Annotated[
|
|
80
|
+
list[str] | None,
|
|
81
|
+
typer.Option("--product-id", help="Product ID(s) to fetch reviews for (repeatable). Omit to list the catalog."),
|
|
82
|
+
] = None,
|
|
83
|
+
limit: Annotated[int, typer.Option("-n", "--limit", min=0, max=50, help="Max reviews per product (max 50; only with --product-id).")] = 0,
|
|
84
|
+
offset: OffsetOpt = 0,
|
|
85
|
+
json_out: JsonOpt = False,
|
|
86
|
+
output: OutOpt = None,
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Product catalog and per-product reviews (G2 et al.).
|
|
89
|
+
|
|
90
|
+
Call once without --product-id to get the catalog + each product's `id`,
|
|
91
|
+
then again with those ids. 1.5 credits / 50 reviews.
|
|
92
|
+
"""
|
|
93
|
+
emit(
|
|
94
|
+
ctx.obj,
|
|
95
|
+
fetch(
|
|
96
|
+
ctx.obj,
|
|
97
|
+
"/company/product-reviews",
|
|
98
|
+
{"company": company, "products": product_id or None, "limit": limit or None, "offset": offset or None},
|
|
99
|
+
),
|
|
100
|
+
json_out=json_out,
|
|
101
|
+
output=output,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def register(app: typer.Typer) -> None:
|
|
106
|
+
app.command("headcount")(headcount)
|
|
107
|
+
app.command("traffic")(traffic)
|
|
108
|
+
app.command("jobs")(jobs)
|
|
109
|
+
app.command("posts")(posts)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""`akta-pro login` / `logout` / `whoami` — API-key credential management (v1).
|
|
2
|
+
|
|
3
|
+
Browser OAuth (`akta-pro login` without a key) is planned for a later version and
|
|
4
|
+
depends on the akta.pro backend exposing a public/native OAuth client; today the
|
|
5
|
+
CLI authenticates with an `x-api-key` minted at https://playground.akta.pro.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Annotated
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from akta_pro_cli.client import AktaAPIError, AktaClient
|
|
16
|
+
from akta_pro_cli.config import (
|
|
17
|
+
clear_credentials,
|
|
18
|
+
credentials_path,
|
|
19
|
+
save_credentials,
|
|
20
|
+
stored_api_key,
|
|
21
|
+
)
|
|
22
|
+
from akta_pro_cli.console import err, out
|
|
23
|
+
from akta_pro_cli.runtime import EXIT_AUTH, EXIT_BAD_INPUT, AppContext, resolve_base_url
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _validate_key(base_url: str, key: str) -> tuple[bool, str]:
|
|
27
|
+
"""Probe a free endpoint to check the key. Returns (ok, message)."""
|
|
28
|
+
client = AktaClient(base_url, key)
|
|
29
|
+
try:
|
|
30
|
+
client.get("/company/search", params={"query": "akta"})
|
|
31
|
+
return True, "key is valid"
|
|
32
|
+
except AktaAPIError as exc:
|
|
33
|
+
if exc.status_code in (401, 403):
|
|
34
|
+
return False, f"key rejected ({exc.status_code})"
|
|
35
|
+
return True, f"could not fully verify (error {exc.status_code}), key stored anyway"
|
|
36
|
+
except httpx.HTTPError as exc:
|
|
37
|
+
return True, f"could not reach akta.pro to verify ({exc})"
|
|
38
|
+
finally:
|
|
39
|
+
client.close()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def login(
|
|
43
|
+
ctx: typer.Context,
|
|
44
|
+
api_key: Annotated[
|
|
45
|
+
str | None,
|
|
46
|
+
typer.Option("--api-key", help="akta.pro API key (wk_...). Omit to be prompted.", show_default=False),
|
|
47
|
+
] = None,
|
|
48
|
+
base_url: Annotated[
|
|
49
|
+
str | None,
|
|
50
|
+
typer.Option("--base-url", help="API base URL to log into (persisted). Defaults to --base-url/env or api.akta.pro.", show_default=False),
|
|
51
|
+
] = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Store an akta.pro API key for future commands.
|
|
54
|
+
|
|
55
|
+
Get a key at https://playground.akta.pro (sign up → API Keys). Passing only
|
|
56
|
+
`--base-url` (no `--api-key`) keeps your already-stored key and just changes
|
|
57
|
+
the endpoint — no re-prompt. To change only the base URL later you can also
|
|
58
|
+
use `akta-pro config base-url <url>`.
|
|
59
|
+
"""
|
|
60
|
+
cfg: AppContext = ctx.obj
|
|
61
|
+
key = (api_key or cfg.api_key or "").strip()
|
|
62
|
+
if not key:
|
|
63
|
+
stored = stored_api_key()
|
|
64
|
+
if base_url is not None and stored:
|
|
65
|
+
# Only changing the base URL — keep the stored key, don't re-prompt.
|
|
66
|
+
key = stored
|
|
67
|
+
else:
|
|
68
|
+
key = typer.prompt("Paste your akta.pro API key (wk_...)", hide_input=True).strip()
|
|
69
|
+
if not key:
|
|
70
|
+
err.print("[red]No key provided.[/]")
|
|
71
|
+
raise typer.Exit(code=EXIT_BAD_INPUT)
|
|
72
|
+
|
|
73
|
+
# Local --base-url wins; else fall back to global flag/env → stored → default.
|
|
74
|
+
base_url = base_url or resolve_base_url(cfg)
|
|
75
|
+
ok, message = _validate_key(base_url, key)
|
|
76
|
+
if not ok:
|
|
77
|
+
err.print(f"[red]{message}.[/]")
|
|
78
|
+
raise typer.Exit(code=EXIT_AUTH)
|
|
79
|
+
|
|
80
|
+
path = save_credentials({"api_key": key, "base_url": base_url})
|
|
81
|
+
err.print(f"[green]✓[/] Logged in ({message}) against {base_url}. Stored at {path}")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def logout(ctx: typer.Context) -> None:
|
|
85
|
+
"""Remove the stored akta.pro API key."""
|
|
86
|
+
if clear_credentials():
|
|
87
|
+
err.print(f"[green]✓[/] Removed stored credentials ({credentials_path()}).")
|
|
88
|
+
else:
|
|
89
|
+
err.print("No stored credentials to remove.")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def whoami(ctx: typer.Context) -> None:
|
|
93
|
+
"""Show the active API key (masked), its source, and validate it."""
|
|
94
|
+
cfg: AppContext = ctx.obj
|
|
95
|
+
if cfg.api_key:
|
|
96
|
+
source, key = "flag / AKTA_PRO_API_KEY", cfg.api_key
|
|
97
|
+
else:
|
|
98
|
+
key = stored_api_key()
|
|
99
|
+
source = f"stored ({credentials_path()})" if key else None
|
|
100
|
+
|
|
101
|
+
if not key:
|
|
102
|
+
err.print("[yellow]Not logged in.[/] Run [bold]akta-pro login[/] or set AKTA_PRO_API_KEY.")
|
|
103
|
+
raise typer.Exit(code=EXIT_AUTH)
|
|
104
|
+
|
|
105
|
+
base_url = resolve_base_url(cfg)
|
|
106
|
+
masked = f"{key[:5]}…{key[-4:]}" if len(key) > 12 else "…"
|
|
107
|
+
out.print(f"API key : [bold]{masked}[/] (source: {source})")
|
|
108
|
+
out.print(f"Base URL: {base_url}")
|
|
109
|
+
|
|
110
|
+
ok, message = _validate_key(base_url, key)
|
|
111
|
+
if ok and message == "key is valid":
|
|
112
|
+
out.print(f"[green]✓ {message}[/]")
|
|
113
|
+
elif ok:
|
|
114
|
+
out.print(f"[yellow]{message}[/]")
|
|
115
|
+
else:
|
|
116
|
+
out.print(f"[red]✗ {message}[/]")
|
|
117
|
+
raise typer.Exit(code=EXIT_AUTH)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def register(app: typer.Typer) -> None:
|
|
121
|
+
app.command("login")(login)
|
|
122
|
+
app.command("logout")(logout)
|
|
123
|
+
app.command("whoami")(whoami)
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""`akta-pro company` — search, enrichment (Markdown), and concise overview."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from akta_pro_cli.console import err
|
|
12
|
+
from akta_pro_cli.options import JsonOpt, OutOpt
|
|
13
|
+
from akta_pro_cli.runtime import EXIT_BAD_INPUT, emit, fetch, probe_is_enterprise
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(no_args_is_help=True, help="Company search, enrichment, and concise overview.")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Section(str, Enum):
|
|
19
|
+
"""Enrichment sections. `funding_detail` / `mna_and_investment` are
|
|
20
|
+
enterprise-only: selectable, but auto-skipped for non-enterprise callers
|
|
21
|
+
(the backend 403s the whole request otherwise)."""
|
|
22
|
+
|
|
23
|
+
firmographic = "firmographic"
|
|
24
|
+
business_model = "business_model"
|
|
25
|
+
company_assessment = "company_assessment"
|
|
26
|
+
trust_signal = "trust_signal"
|
|
27
|
+
company_hierarchy = "company_hierarchy"
|
|
28
|
+
digital_presence = "digital_presence"
|
|
29
|
+
financial_estimate = "financial_estimate"
|
|
30
|
+
location = "location"
|
|
31
|
+
management_profile = "management_profile"
|
|
32
|
+
product_offering = "product_offering"
|
|
33
|
+
strategic_signal = "strategic_signal"
|
|
34
|
+
customer_profile = "customer_profile"
|
|
35
|
+
industry = "industry"
|
|
36
|
+
technology = "technology"
|
|
37
|
+
funding_detail = "funding_detail"
|
|
38
|
+
mna_and_investment = "mna_and_investment"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Sections the akta.pro backend gates to Enterprise plans (mirrors the MCP tool).
|
|
42
|
+
ENTERPRISE_SECTIONS = {"funding_detail", "mna_and_investment"}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _search_table(result: object) -> Table | None:
|
|
46
|
+
rows = result.get("data") if isinstance(result, dict) else None
|
|
47
|
+
if not rows:
|
|
48
|
+
return None
|
|
49
|
+
table = Table(title="Company search results")
|
|
50
|
+
for col in ("Name", "Website", "Category", "Status", "UUID"):
|
|
51
|
+
table.add_column(col, overflow="fold")
|
|
52
|
+
for row in rows:
|
|
53
|
+
table.add_row(
|
|
54
|
+
str(row.get("name", "")),
|
|
55
|
+
str(row.get("website", "")),
|
|
56
|
+
str(row.get("product_category", "")),
|
|
57
|
+
str(row.get("company_status", "")),
|
|
58
|
+
str(row.get("uuid", "")),
|
|
59
|
+
)
|
|
60
|
+
return table
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@app.command("search")
|
|
64
|
+
def search(
|
|
65
|
+
ctx: typer.Context,
|
|
66
|
+
query: Annotated[str, typer.Argument(help="Company name or website, e.g. 'Canva' or 'canva.com'.")],
|
|
67
|
+
json_out: JsonOpt = False,
|
|
68
|
+
output: OutOpt = None,
|
|
69
|
+
) -> None:
|
|
70
|
+
"""Resolve a company by name or website to its akta.pro identifiers (free).
|
|
71
|
+
|
|
72
|
+
Run this first — every other company command needs the `uuid` (or website)
|
|
73
|
+
returned here.
|
|
74
|
+
"""
|
|
75
|
+
result = fetch(ctx.obj, "/company/search", {"query": query})
|
|
76
|
+
emit(ctx.obj, result, json_out=json_out, output=output, renderer=_search_table)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@app.command("data")
|
|
80
|
+
def data(
|
|
81
|
+
ctx: typer.Context,
|
|
82
|
+
company: Annotated[str, typer.Argument(help="Company website or akta.pro UUID.")],
|
|
83
|
+
sections: Annotated[
|
|
84
|
+
list[Section] | None,
|
|
85
|
+
typer.Option("-s", "--section", help="Section(s) to fetch (repeatable). Required — there is no 'all'."),
|
|
86
|
+
] = None,
|
|
87
|
+
markdown: Annotated[
|
|
88
|
+
bool,
|
|
89
|
+
typer.Option("-m", "--markdown", help="Return server-rendered Markdown instead of the default JSON."),
|
|
90
|
+
] = False,
|
|
91
|
+
raw: Annotated[
|
|
92
|
+
bool,
|
|
93
|
+
typer.Option("--raw", "--json", help="Emit the raw payload unrendered (raw Markdown with --markdown, else plain JSON)."),
|
|
94
|
+
] = False,
|
|
95
|
+
output: OutOpt = None,
|
|
96
|
+
) -> None:
|
|
97
|
+
"""Enrich a company with the chosen sections.
|
|
98
|
+
|
|
99
|
+
Returns structured JSON by default (from `/company/enrichment`); pass
|
|
100
|
+
`--markdown` for the server-rendered Markdown variant
|
|
101
|
+
(`/company/enrichment/markdown`). Both build — and bill — identical sections.
|
|
102
|
+
|
|
103
|
+
Credits per section: firmographic 2, business_model 2, company_assessment 2,
|
|
104
|
+
trust_signal 0.5, company_hierarchy 0.5, digital_presence 0.5,
|
|
105
|
+
financial_estimate 0.5, location 0.5, management_profile 1.5,
|
|
106
|
+
product_offering 2, strategic_signal 1.5, customer_profile 1, industry 1,
|
|
107
|
+
technology 2, funding_detail 3 (enterprise), mna_and_investment 5 (enterprise).
|
|
108
|
+
|
|
109
|
+
The two enterprise-only sections are auto-skipped (not an error) for
|
|
110
|
+
non-enterprise callers; a note lists any dropped.
|
|
111
|
+
"""
|
|
112
|
+
if not sections:
|
|
113
|
+
err.print(
|
|
114
|
+
"[red]Choose at least one --section.[/] Options: "
|
|
115
|
+
+ ", ".join(s.value for s in Section)
|
|
116
|
+
)
|
|
117
|
+
raise typer.Exit(code=EXIT_BAD_INPUT)
|
|
118
|
+
|
|
119
|
+
requested = list(dict.fromkeys(s.value for s in sections)) # de-dupe, keep order
|
|
120
|
+
enterprise_req = [s for s in requested if s in ENTERPRISE_SECTIONS]
|
|
121
|
+
skipped: list[str] = []
|
|
122
|
+
if enterprise_req and not probe_is_enterprise(ctx.obj):
|
|
123
|
+
requested = [s for s in requested if s not in ENTERPRISE_SECTIONS]
|
|
124
|
+
skipped = enterprise_req
|
|
125
|
+
if not requested:
|
|
126
|
+
err.print(
|
|
127
|
+
f"[yellow]Only enterprise-only section(s) requested ({', '.join(skipped)}); "
|
|
128
|
+
"your plan doesn't include them.[/] Pick non-enterprise sections or upgrade."
|
|
129
|
+
)
|
|
130
|
+
raise typer.Exit(code=EXIT_BAD_INPUT)
|
|
131
|
+
|
|
132
|
+
params = {"company": company, "sections": ",".join(requested)}
|
|
133
|
+
|
|
134
|
+
# Default: structured JSON from /company/enrichment. Both endpoints bill the
|
|
135
|
+
# same sections; only the shape differs (JSON object vs server-rendered MD).
|
|
136
|
+
if not markdown:
|
|
137
|
+
result = fetch(ctx.obj, "/company/enrichment", params)
|
|
138
|
+
if skipped and not ctx.obj.quiet:
|
|
139
|
+
err.print(
|
|
140
|
+
f"[yellow]Skipped enterprise-only section(s): {', '.join(skipped)} — "
|
|
141
|
+
"not in your plan.[/]"
|
|
142
|
+
)
|
|
143
|
+
emit(ctx.obj, result, json_out=raw, output=output)
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
result = fetch(ctx.obj, "/company/enrichment/markdown", params)
|
|
147
|
+
# The Markdown endpoint returns a JSON envelope {data: markdown,
|
|
148
|
+
# sections_included, credits_consumed, …}, or (fallback) raw Markdown text.
|
|
149
|
+
# Unwrap to the body, then append credits + sections as an in-body footer
|
|
150
|
+
# so the info survives rendering, --raw, piping, and -o.
|
|
151
|
+
envelope = result if isinstance(result, dict) else {}
|
|
152
|
+
body = envelope.get("data", result) if envelope else result
|
|
153
|
+
if not isinstance(body, str):
|
|
154
|
+
body = str(body)
|
|
155
|
+
|
|
156
|
+
if skipped:
|
|
157
|
+
body = (
|
|
158
|
+
f"> _Skipped enterprise-only section(s): {', '.join(skipped)} — "
|
|
159
|
+
"not in your plan._\n\n" + body
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
footer_parts: list[str] = []
|
|
163
|
+
included = envelope.get("sections_included")
|
|
164
|
+
if included:
|
|
165
|
+
footer_parts.append(f"Sections included: {', '.join(included)}")
|
|
166
|
+
credits = envelope.get("credits_consumed")
|
|
167
|
+
if credits is not None:
|
|
168
|
+
footer_parts.append(f"Credits consumed: {credits}")
|
|
169
|
+
if footer_parts:
|
|
170
|
+
body = f"{body.rstrip()}\n\n---\n_{' · '.join(footer_parts)}_\n"
|
|
171
|
+
|
|
172
|
+
emit(ctx.obj, body, json_out=raw, output=output, markdown=True)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@app.command("concise")
|
|
176
|
+
def concise(
|
|
177
|
+
ctx: typer.Context,
|
|
178
|
+
company: Annotated[str, typer.Argument(help="Company website or akta.pro UUID.")],
|
|
179
|
+
json_out: JsonOpt = False,
|
|
180
|
+
output: OutOpt = None,
|
|
181
|
+
) -> None:
|
|
182
|
+
"""Condensed company overview — slimmed JSON with the fluff redacted."""
|
|
183
|
+
result = fetch(ctx.obj, "/company/enrichment/concise", {"company": company})
|
|
184
|
+
emit(ctx.obj, result, json_out=json_out, output=output)
|