pulse-ai-cli 0.1.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.
@@ -0,0 +1,49 @@
1
+ Metadata-Version: 2.4
2
+ Name: pulse-ai-cli
3
+ Version: 0.1.0
4
+ Summary: Pulse CLI — document extraction from your terminal (runpulse.com)
5
+ Project-URL: Homepage, https://runpulse.com
6
+ Project-URL: Documentation, https://docs.runpulse.com
7
+ License: Proprietary
8
+ Keywords: cli,document-extraction,ocr,pdf,pulse
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Requires-Python: >=3.11
15
+ Requires-Dist: click>=8
16
+ Requires-Dist: httpx>=0.27
17
+ Requires-Dist: rich>=13.7
18
+ Requires-Dist: textual>=0.80
19
+ Requires-Dist: typer>=0.12
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest>=8; extra == 'dev'
22
+ Requires-Dist: respx>=0.21; extra == 'dev'
23
+ Provides-Extra: keyring
24
+ Requires-Dist: keyring>=24; extra == 'keyring'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # Pulse CLI
28
+
29
+ Document extraction from your terminal — markdown, structured JSON schemas,
30
+ splits, and tables, powered by the [Pulse](https://runpulse.com) API.
31
+
32
+ ```
33
+ uv tool install pulse-ai-cli
34
+ pulse login
35
+ pulse extract invoice.pdf --schema schemas/invoice.json
36
+ ```
37
+
38
+ - `pulse extract` — documents (PDF, Office, images, URLs) → markdown + JSON;
39
+ add `--schema` to pull structured fields in the same run
40
+ - `pulse schema generate / apply` — AI-draft a JSON Schema, run it against
41
+ any extraction
42
+ - `pulse split` / `pulse tables` — topic-based splitting, table extraction
43
+ - `pulse jobs` — watch, resume, or cancel async jobs
44
+ - `pulse usage` — plan and page/credit usage
45
+ - bare `pulse` — interactive shell with the full command set
46
+
47
+ Every command supports `--json` for pipe-safe output and `-h` for full
48
+ options. See the [docs](https://docs.runpulse.com) for the complete
49
+ reference.
@@ -0,0 +1,27 @@
1
+ pulse_cli/__init__.py,sha256=rKvOFqvHdAA7qtswaBle803jzPxzQLbHcBgCq4-opBs,40
2
+ pulse_cli/__main__.py,sha256=IGhIa-Q2mk1JcfJVNOJWh5nYpkKtz0hQzU1OWjnPsG0,68
3
+ pulse_cli/app.py,sha256=jG3NDp-gJCeR-Ft261bE38IcI9X54xSf8lQsL_F67oM,3316
4
+ pulse_cli/client.py,sha256=cmtiSxPrNzUVFuULnzg8mieA5d168PgAmYFauwbDSTc,7740
5
+ pulse_cli/config.py,sha256=faeXOf5J-H1lbI13aEiCuPTcv1XYaow68O4qJazdwp0,4660
6
+ pulse_cli/errors.py,sha256=qIpj8tBcQbzNAHR7wIXd8Ful25bqaGF2SV71YP-ZK0s,3518
7
+ pulse_cli/oauth.py,sha256=eLc5GsPoVQLG5-TGanoW73wPLTI3CkHvrJtJczwDSQM,3812
8
+ pulse_cli/output.py,sha256=Dk_HNpU0_tXMDM7BL0VPRDYtVDLStisIwilxko8skYg,2544
9
+ pulse_cli/repl.py,sha256=wcjwy3MuKxTQz9PU3ZsvZmYsPwLhv0Qb_5MAyNBk_sA,4694
10
+ pulse_cli/runtime.py,sha256=ZxBPJ40NjCE2nlW-qDVwMOodJ3A2JcZXxNQneU3YrLY,2558
11
+ pulse_cli/status.py,sha256=yja6sIGhJV2TleHWjQd69t-y3Pbp3t0N_jz1qZwk_NQ,350
12
+ pulse_cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
+ pulse_cli/commands/auth.py,sha256=YkDP7h7jYocJ2HVXnBeIT_HKSdpFMvIvJmoS8b_UMZQ,4393
14
+ pulse_cli/commands/extract.py,sha256=SAJRL1kUMPlZLosEKkXgE0yPzc6Ug_6E_VfbRjSC3XI,8738
15
+ pulse_cli/commands/jobs.py,sha256=J7LnUD7XK9DOnRtetHPkqyijxu2JXe-rRflZPBw-iT0,2147
16
+ pulse_cli/commands/open_platform.py,sha256=FHn9KSnxrkhERkP81XY7VkR6jLe1wrilcLtgfv0JHA0,848
17
+ pulse_cli/commands/schema.py,sha256=pRMCu1WLBc9WlGNk8sfnYy_blbt_f_exW_dk39rbm94,5701
18
+ pulse_cli/commands/split.py,sha256=bDnNi6nRjub2v7n9LyhPLd3xwWgmo4s6jbFRPVGSXck,2654
19
+ pulse_cli/commands/tables.py,sha256=YrWkcQyZe-YKi2_QRTsxgVQFqCfGlrIAy8ge9skSY0g,2241
20
+ pulse_cli/commands/usage.py,sha256=p6GscHrLAp7z6-5b2TvTdsFLLx2dkjbm2B9KIqywdws,4440
21
+ pulse_cli/tui/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
22
+ pulse_cli/tui/dashboard.py,sha256=hpV03wuyQUbdLdTbWrLN9me3yaiSCHxF2HWKt8yav_0,6312
23
+ pulse_cli/tui/logo.py,sha256=E2h_tck3XuorGxAs2i4QMk42oq60V9-fJoTQy7ffVGA,2831
24
+ pulse_ai_cli-0.1.0.dist-info/METADATA,sha256=bR0b2eLDyKx5wdX_MT9G8usBKiPA6znTUyRRITcqoM0,1800
25
+ pulse_ai_cli-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
26
+ pulse_ai_cli-0.1.0.dist-info/entry_points.txt,sha256=2a29WIj6AS4LDvD9vHvmfQK-QSFBzGpjisDTJDP3sTw,44
27
+ pulse_ai_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pulse = pulse_cli.app:app
pulse_cli/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Pulse CLI."""
2
+
3
+ __version__ = "0.1.0"
pulse_cli/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from pulse_cli.app import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
pulse_cli/app.py ADDED
@@ -0,0 +1,103 @@
1
+ """Pulse CLI root app."""
2
+ from __future__ import annotations
3
+
4
+ import typer
5
+
6
+ from pulse_cli import __version__
7
+ from pulse_cli.commands.auth import login, logout, whoami
8
+ from pulse_cli.commands.extract import extract
9
+ from pulse_cli.commands.jobs import jobs_app
10
+ from pulse_cli.commands.open_platform import open_platform
11
+ from pulse_cli.commands.schema import schema_app
12
+ from pulse_cli.commands.split import split
13
+ from pulse_cli.commands.tables import tables
14
+ from pulse_cli.commands.usage import usage
15
+ from pulse_cli.errors import PulseCliError
16
+ from pulse_cli.output import err_console
17
+
18
+ # typer joins epilog paragraphs (separated by blank lines) with single
19
+ # newlines, so each example below renders on its own line.
20
+ _EXAMPLES = [
21
+ ("pulse extract report.pdf", "document -> report.pulse.md / .json"),
22
+ ("pulse extract invoice.pdf --schema schema.json", "extract + structured fields in one go"),
23
+ ("pulse extract ./docs --no-wait", "submit a folder, fetch results later"),
24
+ ('pulse schema generate -p "vendor, total, date"', "AI-draft a JSON schema"),
25
+ ("pulse schema apply <extraction_id> --schema s.json", "structured fields from a past extract"),
26
+ ('pulse split <extraction_id> -t "intro:first part"', "split a document by topic"),
27
+ ("pulse tables <extraction_id>", "pull out tables"),
28
+ ("pulse jobs get <job_id> --watch", "watch or resume an async job"),
29
+ ("pulse open <extraction_id>", "view an extraction on the platform"),
30
+ ("pulse usage", "plan + credit/page usage"),
31
+ ]
32
+
33
+ _EPILOG = "\n\n".join(
34
+ ["[bold]Examples[/bold]"]
35
+ + [f"[cyan]{cmd:<52}[/cyan] [dim]{desc}[/dim]" for cmd, desc in _EXAMPLES]
36
+ + [
37
+ "",
38
+ "Add [cyan]--json[/cyan] to any command for raw output, [cyan]-o[/cyan] to choose "
39
+ "where files go, and [cyan]-h[/cyan] after any command for its full options.",
40
+ ]
41
+ )
42
+
43
+ cli = typer.Typer(
44
+ name="pulse",
45
+ help="Pulse — document extraction from your terminal (runpulse.com).",
46
+ epilog=_EPILOG,
47
+ rich_markup_mode="rich",
48
+ context_settings={"help_option_names": ["-h", "--help"]},
49
+ )
50
+
51
+
52
+ @cli.callback(invoke_without_command=True)
53
+ def _root(ctx: typer.Context) -> None:
54
+ """Bare `pulse` in a terminal opens the interactive shell; otherwise show help."""
55
+ if ctx.invoked_subcommand is None:
56
+ import sys
57
+
58
+ if sys.stdin.isatty() and sys.stdout.isatty():
59
+ from pulse_cli.repl import run_repl
60
+
61
+ run_repl()
62
+ else:
63
+ print(ctx.get_help())
64
+ raise typer.Exit()
65
+
66
+
67
+ @cli.command()
68
+ def dashboard() -> None:
69
+ """Open the Pulse dashboard (account + usage)."""
70
+ from pulse_cli.tui.dashboard import run_dashboard
71
+
72
+ run_dashboard()
73
+
74
+
75
+ cli.command()(login)
76
+ cli.command()(logout)
77
+ cli.command()(whoami)
78
+ cli.command()(extract)
79
+ cli.command()(split)
80
+ cli.command()(tables)
81
+ cli.command()(usage)
82
+ cli.command("open")(open_platform)
83
+ cli.add_typer(schema_app, name="schema")
84
+ cli.add_typer(jobs_app, name="jobs")
85
+
86
+
87
+ @cli.command()
88
+ def version() -> None:
89
+ """Print the CLI version."""
90
+ print(f"pulse-cli {__version__}")
91
+
92
+
93
+ def app() -> None:
94
+ """Console-script entry point with clean error rendering."""
95
+ try:
96
+ cli()
97
+ except PulseCliError as exc:
98
+ err_console.print(f"[err]error:[/err] {exc}")
99
+ raise SystemExit(1)
100
+
101
+
102
+ if __name__ == "__main__":
103
+ app()
pulse_cli/client.py ADDED
@@ -0,0 +1,197 @@
1
+ """Sync HTTP client for the Pulse engine — a CLI port of mcp_server/pulse_client.py.
2
+
3
+ Same endpoints, same payload shapes, same submit -> poll semantics, so the CLI
4
+ and MCP gateway behave identically against the engine. Auth is the caller's API
5
+ key forwarded verbatim as ``x-api-key``; the engine enforces auth/credits/limits.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import time
11
+ from typing import Any, Callable, Optional
12
+
13
+ import httpx
14
+
15
+ from pulse_cli import __version__
16
+ from pulse_cli.errors import PulseEngineError
17
+ from pulse_cli.status import is_terminal
18
+
19
+ MAX_UPLOAD_BYTES = 50 * 1024 * 1024 # engine upload cap
20
+
21
+ DEFAULT_TIMEOUT = 120.0
22
+ SCHEMA_HELPER_TIMEOUT = 240.0 # /ai-schema-helper is sync and can run long
23
+ POLL_INTERVAL_SECONDS = 2.0
24
+
25
+ # (status, poll_count, job_dict) -> None
26
+ ProgressCallback = Callable[[str, int, dict], None]
27
+
28
+
29
+ def _form_value(v: Any) -> str:
30
+ if isinstance(v, bool):
31
+ return "true" if v else "false"
32
+ if isinstance(v, (dict, list)):
33
+ return json.dumps(v)
34
+ return str(v)
35
+
36
+
37
+ class PulseClient:
38
+ def __init__(self, api_key: str, base_url: str, *, transport: Optional[httpx.BaseTransport] = None) -> None:
39
+ self._client = httpx.Client(
40
+ base_url=base_url.rstrip("/"),
41
+ timeout=DEFAULT_TIMEOUT,
42
+ headers={"x-api-key": api_key, "user-agent": f"pulse-cli/{__version__}"},
43
+ transport=transport,
44
+ )
45
+
46
+ def __enter__(self) -> "PulseClient":
47
+ return self
48
+
49
+ def __exit__(self, *exc: Any) -> None:
50
+ self._client.close()
51
+
52
+ # --- core -----------------------------------------------------------------
53
+
54
+ def _request(self, method: str, path: str, **kwargs: Any) -> Any:
55
+ try:
56
+ resp = self._client.request(method, path, **kwargs)
57
+ except httpx.TimeoutException as exc:
58
+ raise PulseEngineError(504, engine_message="The Pulse engine took too long to respond.") from exc
59
+ except httpx.RequestError as exc:
60
+ raise PulseEngineError(
61
+ 502, engine_message=f"Could not reach the Pulse engine at {self._client.base_url}."
62
+ ) from exc
63
+
64
+ if resp.is_success:
65
+ if "application/json" in resp.headers.get("content-type", ""):
66
+ return resp.json()
67
+ return resp.text
68
+ try:
69
+ body: Any = resp.json()
70
+ except Exception:
71
+ body = resp.text
72
+ raise PulseEngineError.from_response(resp.status_code, body)
73
+
74
+ # --- endpoints (parity with mcp_server) ------------------------------------
75
+
76
+ def get_job(self, job_id: str) -> dict:
77
+ return self._request("GET", f"/job/{job_id}")
78
+
79
+ def cancel_job(self, job_id: str) -> dict:
80
+ return self._request("POST", f"/cancel/{job_id}")
81
+
82
+ def submit_extract(self, payload: dict) -> dict:
83
+ """POST /extract (JSON) — for file_url inputs; set async=True for a job_id."""
84
+ return self._request("POST", "/extract", json=payload)
85
+
86
+ def submit_extract_multipart(self, file: tuple, options: dict) -> dict:
87
+ """POST /extract (multipart) — local file bytes + form options."""
88
+ data = {k: _form_value(v) for k, v in options.items() if v is not None}
89
+ return self._request("POST", "/extract", data=data, files={"file": file})
90
+
91
+ def apply_schema(self, payload: dict) -> dict:
92
+ return self._request("POST", "/schema", json=payload)
93
+
94
+ def split(self, payload: dict) -> dict:
95
+ return self._request("POST", "/split", json=payload)
96
+
97
+ def tables(self, payload: dict) -> dict:
98
+ return self._request("POST", "/tables", json=payload)
99
+
100
+ def generate_schema(self, *, prompt: Optional[str] = None, current_schema: Optional[str] = None) -> dict:
101
+ data: dict[str, str] = {}
102
+ if prompt is not None:
103
+ data["prompt"] = prompt
104
+ if current_schema is not None:
105
+ data["current_schema"] = current_schema
106
+ return self._request("POST", "/ai-schema-helper", data=data, timeout=SCHEMA_HELPER_TIMEOUT)
107
+
108
+ def pages(self) -> dict:
109
+ """GET /pages — total pages processed (api-key safe; also used to validate keys)."""
110
+ return self._request("GET", "/pages")
111
+
112
+ # --- polling ----------------------------------------------------------------
113
+
114
+ def wait_for_job(
115
+ self,
116
+ job_id: str,
117
+ *,
118
+ interval_seconds: float = POLL_INTERVAL_SECONDS,
119
+ on_progress: Optional[ProgressCallback] = None,
120
+ ) -> dict:
121
+ """Poll GET /job/{job_id} until terminal. The CLI polls indefinitely
122
+ (unlike MCP's inline budget) — Ctrl-C leaves the job running server-side."""
123
+ polls = 0
124
+ job = self.get_job(job_id)
125
+ while not is_terminal(job.get("status")):
126
+ polls += 1
127
+ if on_progress is not None:
128
+ on_progress(job.get("status", ""), polls, job)
129
+ time.sleep(interval_seconds)
130
+ job = self.get_job(job_id)
131
+ return job
132
+
133
+ def fetch_url(self, url: str) -> Any:
134
+ """Download an offloaded {is_url, url} result (pre-signed S3)."""
135
+ resp = httpx.get(url, timeout=DEFAULT_TIMEOUT, follow_redirects=True)
136
+ if not resp.is_success:
137
+ raise PulseEngineError(resp.status_code, engine_message="Failed to download result from URL.")
138
+ if "application/json" in resp.headers.get("content-type", ""):
139
+ return resp.json()
140
+ try:
141
+ return json.loads(resp.text)
142
+ except json.JSONDecodeError:
143
+ return resp.text
144
+
145
+
146
+ class PlatformClient:
147
+ """Minimal client for the FastAPI service (api-key-safe, read-only endpoints)."""
148
+
149
+ def __init__(self, api_key: str, base_url: str) -> None:
150
+ self._client = httpx.Client(
151
+ base_url=base_url.rstrip("/"),
152
+ timeout=30.0,
153
+ headers={"x-api-key": api_key, "user-agent": f"pulse-cli/{__version__}"},
154
+ )
155
+
156
+ def __enter__(self) -> "PlatformClient":
157
+ return self
158
+
159
+ def __exit__(self, *exc: Any) -> None:
160
+ self._client.close()
161
+
162
+ def _get(self, path: str, params: Optional[dict] = None) -> dict:
163
+ try:
164
+ resp = self._client.get(path, params=params)
165
+ except httpx.TimeoutException as exc:
166
+ raise PulseEngineError(504, engine_message="The Pulse platform took too long to respond.") from exc
167
+ except httpx.RequestError as exc:
168
+ raise PulseEngineError(
169
+ 502, engine_message=f"Could not reach the Pulse platform at {self._client.base_url}."
170
+ ) from exc
171
+ if resp.is_success:
172
+ return resp.json()
173
+ try:
174
+ body: Any = resp.json()
175
+ except Exception:
176
+ body = resp.text
177
+ raise PulseEngineError.from_response(resp.status_code, body)
178
+
179
+ def subscription(self) -> Optional[dict]:
180
+ """GET /api/v1/billing/subscription — plan/status; None if unreachable."""
181
+ try:
182
+ return self._get("/api/v1/billing/subscription")
183
+ except PulseEngineError:
184
+ return None
185
+
186
+ def current_period_usage(self) -> dict:
187
+ """GET /api/v1/analytics/usage/current-period — plan + pages for the billing cycle."""
188
+ return self._get("/api/v1/analytics/usage/current-period")
189
+
190
+ def usage_breakdown(self, start_date: Optional[str] = None, end_date: Optional[str] = None) -> dict:
191
+ """GET /api/v1/analytics/usage/breakdown — docs/pages/latency, by source.
192
+
193
+ Without dates the platform defaults to the current billing period
194
+ (or lifetime for lifetime plans).
195
+ """
196
+ params = {k: v for k, v in (("start_date", start_date), ("end_date", end_date)) if v}
197
+ return self._get("/api/v1/analytics/usage/breakdown", params=params or None)
File without changes
@@ -0,0 +1,114 @@
1
+ """`pulse login` / `pulse logout` / `pulse whoami`."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Optional
5
+
6
+ import typer
7
+
8
+ from pulse_cli import config as cfg
9
+ from pulse_cli.client import PlatformClient, PulseClient
10
+ from pulse_cli.errors import PulseCliError
11
+ from pulse_cli.output import console, fail, print_json
12
+
13
+
14
+ def login(
15
+ api_key: Optional[str] = typer.Option(None, "--api-key", help="Skip the browser and log in with an API key directly."),
16
+ base_url: Optional[str] = typer.Option(None, "--base-url", help="Engine URL override (default: api.runpulse.com)."),
17
+ platform_url: Optional[str] = typer.Option(None, "--platform-url", help="Platform API URL (enables `pulse usage`)."),
18
+ web: bool = typer.Option(True, "--web/--no-web", help="Browser login (default). --no-web prompts for an API key instead."),
19
+ ) -> None:
20
+ """Log in via the browser (default) or with an API key; the key is validated, then stored securely."""
21
+ if api_key:
22
+ key = api_key
23
+ elif web:
24
+ key = _device_flow_login(platform_url)
25
+ else:
26
+ key = typer.prompt("Pulse API key", hide_input=True)
27
+ key = key.strip()
28
+ if not key:
29
+ raise fail("empty API key")
30
+
31
+ conf = cfg.load(api_url_override=base_url)
32
+ console.print(f"[muted]Validating key against {conf.api_url} …[/muted]")
33
+ try:
34
+ with PulseClient(key, conf.api_url) as client:
35
+ pages = client.pages()
36
+ except PulseCliError as exc:
37
+ raise fail(str(exc))
38
+
39
+ store = cfg.save_api_key(key)
40
+ if base_url or platform_url:
41
+ cfg.save_settings(api_url=base_url, platform_url=platform_url)
42
+
43
+ where = "OS keychain" if store == "keyring" else f"{cfg.CONFIG_PATH} (0600)"
44
+ console.print(f"[ok]Logged in.[/ok] Key {cfg.redact(key)} stored in {where}.")
45
+ console.print(f"[muted]Pages processed so far:[/muted] {pages.get('total_pages_processed', 0)}")
46
+
47
+
48
+ def _device_flow_login(platform_url_flag: Optional[str]) -> str:
49
+ """Browser-based device flow: approve in the platform, get the org API key back."""
50
+ import webbrowser
51
+
52
+ from pulse_cli import oauth
53
+
54
+ conf = cfg.load()
55
+ purl = platform_url_flag or conf.platform_url
56
+
57
+ try:
58
+ auth = oauth.device_authorization(purl)
59
+ console.print(f"\n Your code: [bold]{auth['user_code']}[/bold]")
60
+ console.print(f" Approve at: [link]{auth['verification_uri_complete']}[/link]\n")
61
+ webbrowser.open(auth["verification_uri_complete"])
62
+ with console.status("[muted]Waiting for approval in the browser …[/muted]"):
63
+ tokens = oauth.poll_for_tokens(
64
+ purl,
65
+ auth["device_code"],
66
+ interval=int(auth.get("interval", 5)),
67
+ expires_in=int(auth.get("expires_in", 600)),
68
+ )
69
+ return oauth.fetch_api_key(purl, tokens["access_token"])
70
+ except PulseCliError as exc:
71
+ raise fail(str(exc))
72
+
73
+
74
+ def logout() -> None:
75
+ """Remove the stored API key."""
76
+ if cfg.clear_api_key():
77
+ console.print("[ok]Logged out.[/ok] Stored API key removed.")
78
+ else:
79
+ console.print("[muted]No stored API key found.[/muted]")
80
+
81
+
82
+ def whoami(
83
+ json_out: bool = typer.Option(False, "--json", help="Machine-readable output."),
84
+ base_url: Optional[str] = typer.Option(None, "--base-url", help="Engine URL override."),
85
+ ) -> None:
86
+ """Show the active key (redacted), where it came from, and account status."""
87
+ conf = cfg.load(api_url_override=base_url)
88
+ if not conf.api_key:
89
+ raise fail("not logged in. Run `pulse login`.")
90
+
91
+ info: dict = {
92
+ "api_key": cfg.redact(conf.api_key),
93
+ "key_source": conf.key_source,
94
+ "api_url": conf.api_url,
95
+ }
96
+ try:
97
+ with PulseClient(conf.api_key, conf.api_url) as client:
98
+ info["total_pages_processed"] = client.pages().get("total_pages_processed")
99
+ except PulseCliError as exc:
100
+ info["status"] = f"key check failed: {exc}"
101
+
102
+ if conf.platform_url:
103
+ with PlatformClient(conf.api_key, conf.platform_url) as pc:
104
+ sub = pc.subscription()
105
+ if sub:
106
+ info["plan"] = sub.get("plan") or sub.get("plan_name")
107
+ info["subscription_status"] = sub.get("status")
108
+
109
+ if json_out:
110
+ print_json(info)
111
+ return
112
+ for k, v in info.items():
113
+ if v is not None:
114
+ console.print(f"[muted]{k:24}[/muted] [body]{v}[/body]")