pulse-ai-cli 0.1.0__tar.gz → 0.2.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.
Files changed (40) hide show
  1. {pulse_ai_cli-0.1.0 → pulse_ai_cli-0.2.0}/PKG-INFO +7 -4
  2. {pulse_ai_cli-0.1.0 → pulse_ai_cli-0.2.0}/README.md +4 -1
  3. {pulse_ai_cli-0.1.0 → pulse_ai_cli-0.2.0}/docs.md +35 -11
  4. pulse_ai_cli-0.2.0/pulse_cli/__init__.py +3 -0
  5. {pulse_ai_cli-0.1.0 → pulse_ai_cli-0.2.0}/pulse_cli/app.py +5 -1
  6. {pulse_ai_cli-0.1.0 → pulse_ai_cli-0.2.0}/pulse_cli/client.py +49 -15
  7. pulse_ai_cli-0.2.0/pulse_cli/commands/auth.py +214 -0
  8. pulse_ai_cli-0.2.0/pulse_cli/commands/charts.py +116 -0
  9. {pulse_ai_cli-0.1.0 → pulse_ai_cli-0.2.0}/pulse_cli/commands/extract.py +89 -36
  10. {pulse_ai_cli-0.1.0 → pulse_ai_cli-0.2.0}/pulse_cli/commands/jobs.py +15 -8
  11. {pulse_ai_cli-0.1.0 → pulse_ai_cli-0.2.0}/pulse_cli/commands/open_platform.py +2 -2
  12. {pulse_ai_cli-0.1.0 → pulse_ai_cli-0.2.0}/pulse_cli/commands/schema.py +36 -22
  13. {pulse_ai_cli-0.1.0 → pulse_ai_cli-0.2.0}/pulse_cli/commands/split.py +13 -7
  14. {pulse_ai_cli-0.1.0 → pulse_ai_cli-0.2.0}/pulse_cli/commands/tables.py +10 -6
  15. {pulse_ai_cli-0.1.0 → pulse_ai_cli-0.2.0}/pulse_cli/commands/usage.py +16 -12
  16. pulse_ai_cli-0.2.0/pulse_cli/config.py +220 -0
  17. {pulse_ai_cli-0.1.0 → pulse_ai_cli-0.2.0}/pulse_cli/errors.py +11 -12
  18. pulse_ai_cli-0.2.0/pulse_cli/oauth.py +272 -0
  19. {pulse_ai_cli-0.1.0 → pulse_ai_cli-0.2.0}/pulse_cli/output.py +9 -4
  20. {pulse_ai_cli-0.1.0 → pulse_ai_cli-0.2.0}/pulse_cli/repl.py +6 -3
  21. {pulse_ai_cli-0.1.0 → pulse_ai_cli-0.2.0}/pulse_cli/runtime.py +11 -12
  22. pulse_ai_cli-0.2.0/pulse_cli/status.py +15 -0
  23. pulse_ai_cli-0.2.0/pulse_cli/templates/callback.html +117 -0
  24. {pulse_ai_cli-0.1.0 → pulse_ai_cli-0.2.0}/pulse_cli/tui/dashboard.py +12 -3
  25. {pulse_ai_cli-0.1.0 → pulse_ai_cli-0.2.0}/pulse_cli/tui/logo.py +14 -4
  26. {pulse_ai_cli-0.1.0 → pulse_ai_cli-0.2.0}/pyproject.toml +3 -2
  27. pulse_ai_cli-0.2.0/tests/test_auth_flow.py +444 -0
  28. pulse_ai_cli-0.2.0/tests/test_charts_command.py +148 -0
  29. pulse_ai_cli-0.2.0/tests/test_extract_command.py +20 -0
  30. pulse_ai_cli-0.2.0/tests/test_oauth_login.py +166 -0
  31. {pulse_ai_cli-0.1.0 → pulse_ai_cli-0.2.0}/tests/test_smoke.py +43 -1
  32. pulse_ai_cli-0.1.0/pulse_cli/__init__.py +0 -3
  33. pulse_ai_cli-0.1.0/pulse_cli/commands/auth.py +0 -114
  34. pulse_ai_cli-0.1.0/pulse_cli/config.py +0 -141
  35. pulse_ai_cli-0.1.0/pulse_cli/oauth.py +0 -95
  36. pulse_ai_cli-0.1.0/pulse_cli/status.py +0 -10
  37. {pulse_ai_cli-0.1.0 → pulse_ai_cli-0.2.0}/.gitignore +0 -0
  38. {pulse_ai_cli-0.1.0 → pulse_ai_cli-0.2.0}/pulse_cli/__main__.py +0 -0
  39. {pulse_ai_cli-0.1.0 → pulse_ai_cli-0.2.0}/pulse_cli/commands/__init__.py +0 -0
  40. {pulse_ai_cli-0.1.0 → pulse_ai_cli-0.2.0}/pulse_cli/tui/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: pulse-ai-cli
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Pulse CLI — document extraction from your terminal (runpulse.com)
5
5
  Project-URL: Homepage, https://runpulse.com
6
6
  Project-URL: Documentation, https://docs.runpulse.com
@@ -14,6 +14,7 @@ Classifier: Programming Language :: Python :: 3.13
14
14
  Requires-Python: >=3.11
15
15
  Requires-Dist: click>=8
16
16
  Requires-Dist: httpx>=0.27
17
+ Requires-Dist: keyring>=24
17
18
  Requires-Dist: rich>=13.7
18
19
  Requires-Dist: textual>=0.80
19
20
  Requires-Dist: typer>=0.12
@@ -21,13 +22,13 @@ Provides-Extra: dev
21
22
  Requires-Dist: pytest>=8; extra == 'dev'
22
23
  Requires-Dist: respx>=0.21; extra == 'dev'
23
24
  Provides-Extra: keyring
24
- Requires-Dist: keyring>=24; extra == 'keyring'
25
25
  Description-Content-Type: text/markdown
26
26
 
27
27
  # Pulse CLI
28
28
 
29
29
  Document extraction from your terminal — markdown, structured JSON schemas,
30
- splits, and tables, powered by the [Pulse](https://runpulse.com) API.
30
+ splits, tables, and chart reconstruction, powered by the
31
+ [Pulse](https://runpulse.com) API.
31
32
 
32
33
  ```
33
34
  uv tool install pulse-ai-cli
@@ -40,6 +41,8 @@ pulse extract invoice.pdf --schema schemas/invoice.json
40
41
  - `pulse schema generate / apply` — AI-draft a JSON Schema, run it against
41
42
  any extraction
42
43
  - `pulse split` / `pulse tables` — topic-based splitting, table extraction
44
+ - `pulse charts` — reconstruct chart series and export Excel, CSV, or LAS data;
45
+ add `--no-wait` to submit an async chart job without polling
43
46
  - `pulse jobs` — watch, resume, or cancel async jobs
44
47
  - `pulse usage` — plan and page/credit usage
45
48
  - bare `pulse` — interactive shell with the full command set
@@ -1,7 +1,8 @@
1
1
  # Pulse CLI
2
2
 
3
3
  Document extraction from your terminal — markdown, structured JSON schemas,
4
- splits, and tables, powered by the [Pulse](https://runpulse.com) API.
4
+ splits, tables, and chart reconstruction, powered by the
5
+ [Pulse](https://runpulse.com) API.
5
6
 
6
7
  ```
7
8
  uv tool install pulse-ai-cli
@@ -14,6 +15,8 @@ pulse extract invoice.pdf --schema schemas/invoice.json
14
15
  - `pulse schema generate / apply` — AI-draft a JSON Schema, run it against
15
16
  any extraction
16
17
  - `pulse split` / `pulse tables` — topic-based splitting, table extraction
18
+ - `pulse charts` — reconstruct chart series and export Excel, CSV, or LAS data;
19
+ add `--no-wait` to submit an async chart job without polling
17
20
  - `pulse jobs` — watch, resume, or cancel async jobs
18
21
  - `pulse usage` — plan and page/credit usage
19
22
  - bare `pulse` — interactive shell with the full command set
@@ -23,15 +23,27 @@ Python 3.11+.
23
23
  pulse login
24
24
  ```
25
25
 
26
- Opens the Pulse platform in your browser; you approve a short code (e.g.
27
- `ABCD-1234`) while signed in, and the CLI picks up your organization's API
28
- key automatically (OAuth 2.1 device flow, RFC 8628). The key is validated
29
- and stored in your OS keychain (or `~/.pulse/config.toml`, chmod 0600, if no
30
- keychain is available).
31
-
32
- No browser handy? `pulse login --api-key <key>` (or `pulse login --no-web`
33
- to be prompted) logs in with an API key directly — find yours in the Pulse
34
- platform under API Keys.
26
+ Opens the Pulse platform in your browser; you approve the request while
27
+ signed in, and the CLI creates an API key for this device (OAuth 2.1
28
+ authorization code + PKCE on a loopback redirect, RFC 8252). The key shows up
29
+ under API Keys as `pulse-cli <hostname>`, is validated, and is stored in your
30
+ OS keychain (or `~/.pulse/config.toml`, chmod 0600, if no keychain is
31
+ available). `pulse logout` deletes it again, as does a later `pulse login`
32
+ that replaces it or a login that fails after the key was created; a key you
33
+ pasted yourself is only forgotten.
34
+
35
+ This key is this machine's login, not a credential for automation: it expires
36
+ after 90 days (`pulse whoami` shows when), and then `pulse login` makes a new
37
+ one. For CI, cron jobs, servers or an MCP client, create a key in the platform
38
+ under API Keys and pass it as `PULSE_API_KEY`.
39
+
40
+ No browser on this machine (SSH, containers)? `pulse login --device` prints a
41
+ URL and a short code (e.g. `ABCD-1234`) to type in a browser on any device
42
+ (OAuth 2.0 device flow, RFC 8628). Only do this for a login you started
43
+ yourself: never enter a code someone sent you.
44
+
45
+ `pulse login --api-key <key>` (or `pulse login --no-web` to be prompted) logs
46
+ in with an API key directly — find yours in the Pulse platform under API Keys.
35
47
 
36
48
  Alternatives, in priority order:
37
49
 
@@ -141,6 +153,20 @@ pulse tables <extraction_id> --format html
141
153
  `--format html|json`, `--merge` (join tables spanning pages),
142
154
  `--charts-to-tables`, `-o <file>`.
143
155
 
156
+ ### `pulse charts [<extraction_id>]`
157
+
158
+ Reconstruct chart series from a completed extraction or split:
159
+
160
+ ```bash
161
+ pulse charts <extraction_id> --data-points 200 --agentic-zoom \
162
+ --export excel --export csv
163
+ pulse charts --split-id <split_id> --export las
164
+ ```
165
+
166
+ Use `--pages "1-3,5"` with an extraction. Split results inherit their pages and
167
+ cannot use `--pages`. `--download-exports <directory>` explicitly consumes the
168
+ authenticated one-time export links; without it the CLI returns the links untouched.
169
+
144
170
  ### `pulse jobs`
145
171
 
146
172
  Every long-running command runs async under the hood and polls for you.
@@ -196,6 +222,4 @@ the interactive shell.
196
222
 
197
223
  - `extract --split … --schema …` end-to-end pipeline (split a document, then apply
198
224
  a schema per topic in one command).
199
- - Platform `/activate` approval page for `pulse login --web` (the API-side
200
- device flow is live; approval UI is rolling out).
201
225
  - Full OAuth token auth for the MCP server (auth-code + PKCE, DCR).
@@ -0,0 +1,3 @@
1
+ """Pulse CLI."""
2
+
3
+ __version__ = "0.2.0"
@@ -1,10 +1,12 @@
1
1
  """Pulse CLI root app."""
2
+
2
3
  from __future__ import annotations
3
4
 
4
5
  import typer
5
6
 
6
7
  from pulse_cli import __version__
7
8
  from pulse_cli.commands.auth import login, logout, whoami
9
+ from pulse_cli.commands.charts import charts
8
10
  from pulse_cli.commands.extract import extract
9
11
  from pulse_cli.commands.jobs import jobs_app
10
12
  from pulse_cli.commands.open_platform import open_platform
@@ -25,6 +27,7 @@ _EXAMPLES = [
25
27
  ("pulse schema apply <extraction_id> --schema s.json", "structured fields from a past extract"),
26
28
  ('pulse split <extraction_id> -t "intro:first part"', "split a document by topic"),
27
29
  ("pulse tables <extraction_id>", "pull out tables"),
30
+ ("pulse charts <extraction_id>", "reconstruct charts and export their data"),
28
31
  ("pulse jobs get <job_id> --watch", "watch or resume an async job"),
29
32
  ("pulse open <extraction_id>", "view an extraction on the platform"),
30
33
  ("pulse usage", "plan + credit/page usage"),
@@ -78,6 +81,7 @@ cli.command()(whoami)
78
81
  cli.command()(extract)
79
82
  cli.command()(split)
80
83
  cli.command()(tables)
84
+ cli.command()(charts)
81
85
  cli.command()(usage)
82
86
  cli.command("open")(open_platform)
83
87
  cli.add_typer(schema_app, name="schema")
@@ -96,7 +100,7 @@ def app() -> None:
96
100
  cli()
97
101
  except PulseCliError as exc:
98
102
  err_console.print(f"[err]error:[/err] {exc}")
99
- raise SystemExit(1)
103
+ raise SystemExit(1) from exc
100
104
 
101
105
 
102
106
  if __name__ == "__main__":
@@ -1,14 +1,17 @@
1
- """Sync HTTP client for the Pulse engine — a CLI port of mcp_server/pulse_client.py.
1
+ """Sync HTTP client for the Pulse engine — a CLI port of pulse_mcp/pulse_client.py.
2
2
 
3
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
4
+ and MCP gateway behave identically against the v1.engine. Auth is the caller's API
5
5
  key forwarded verbatim as ``x-api-key``; the engine enforces auth/credits/limits.
6
6
  """
7
+
7
8
  from __future__ import annotations
8
9
 
9
10
  import json
10
11
  import time
11
- from typing import Any, Callable, Optional
12
+ from collections.abc import Callable
13
+ from typing import Any
14
+ from urllib.parse import urljoin, urlsplit
12
15
 
13
16
  import httpx
14
17
 
@@ -35,7 +38,9 @@ def _form_value(v: Any) -> str:
35
38
 
36
39
 
37
40
  class PulseClient:
38
- def __init__(self, api_key: str, base_url: str, *, transport: Optional[httpx.BaseTransport] = None) -> None:
41
+ def __init__(
42
+ self, api_key: str, base_url: str, *, transport: httpx.BaseTransport | None = None
43
+ ) -> None:
39
44
  self._client = httpx.Client(
40
45
  base_url=base_url.rstrip("/"),
41
46
  timeout=DEFAULT_TIMEOUT,
@@ -43,7 +48,7 @@ class PulseClient:
43
48
  transport=transport,
44
49
  )
45
50
 
46
- def __enter__(self) -> "PulseClient":
51
+ def __enter__(self) -> PulseClient:
47
52
  return self
48
53
 
49
54
  def __exit__(self, *exc: Any) -> None:
@@ -55,7 +60,9 @@ class PulseClient:
55
60
  try:
56
61
  resp = self._client.request(method, path, **kwargs)
57
62
  except httpx.TimeoutException as exc:
58
- raise PulseEngineError(504, engine_message="The Pulse engine took too long to respond.") from exc
63
+ raise PulseEngineError(
64
+ 504, engine_message="The Pulse engine took too long to respond."
65
+ ) from exc
59
66
  except httpx.RequestError as exc:
60
67
  raise PulseEngineError(
61
68
  502, engine_message=f"Could not reach the Pulse engine at {self._client.base_url}."
@@ -97,7 +104,29 @@ class PulseClient:
97
104
  def tables(self, payload: dict) -> dict:
98
105
  return self._request("POST", "/tables", json=payload)
99
106
 
100
- def generate_schema(self, *, prompt: Optional[str] = None, current_schema: Optional[str] = None) -> dict:
107
+ def charts(self, payload: dict) -> dict:
108
+ return self._request("POST", "/charts", json=payload)
109
+
110
+ def download_artifact(self, url: str) -> bytes:
111
+ """Download one chart artifact, authenticating only to the Pulse origin."""
112
+ resolved = urljoin(str(self._client.base_url), url)
113
+ api_origin = urlsplit(str(self._client.base_url))[:2]
114
+ artifact_origin = urlsplit(resolved)[:2]
115
+ if artifact_origin == api_origin:
116
+ response = self._client.get(resolved, follow_redirects=False)
117
+ else:
118
+ response = httpx.get(resolved, timeout=DEFAULT_TIMEOUT, follow_redirects=True)
119
+ if not response.is_success:
120
+ try:
121
+ body: Any = response.json()
122
+ except Exception:
123
+ body = response.text
124
+ raise PulseEngineError.from_response(response.status_code, body)
125
+ return response.content
126
+
127
+ def generate_schema(
128
+ self, *, prompt: str | None = None, current_schema: str | None = None
129
+ ) -> dict:
101
130
  data: dict[str, str] = {}
102
131
  if prompt is not None:
103
132
  data["prompt"] = prompt
@@ -116,7 +145,7 @@ class PulseClient:
116
145
  job_id: str,
117
146
  *,
118
147
  interval_seconds: float = POLL_INTERVAL_SECONDS,
119
- on_progress: Optional[ProgressCallback] = None,
148
+ on_progress: ProgressCallback | None = None,
120
149
  ) -> dict:
121
150
  """Poll GET /job/{job_id} until terminal. The CLI polls indefinitely
122
151
  (unlike MCP's inline budget) — Ctrl-C leaves the job running server-side."""
@@ -134,7 +163,9 @@ class PulseClient:
134
163
  """Download an offloaded {is_url, url} result (pre-signed S3)."""
135
164
  resp = httpx.get(url, timeout=DEFAULT_TIMEOUT, follow_redirects=True)
136
165
  if not resp.is_success:
137
- raise PulseEngineError(resp.status_code, engine_message="Failed to download result from URL.")
166
+ raise PulseEngineError(
167
+ resp.status_code, engine_message="Failed to download result from URL."
168
+ )
138
169
  if "application/json" in resp.headers.get("content-type", ""):
139
170
  return resp.json()
140
171
  try:
@@ -153,20 +184,23 @@ class PlatformClient:
153
184
  headers={"x-api-key": api_key, "user-agent": f"pulse-cli/{__version__}"},
154
185
  )
155
186
 
156
- def __enter__(self) -> "PlatformClient":
187
+ def __enter__(self) -> PlatformClient:
157
188
  return self
158
189
 
159
190
  def __exit__(self, *exc: Any) -> None:
160
191
  self._client.close()
161
192
 
162
- def _get(self, path: str, params: Optional[dict] = None) -> dict:
193
+ def _get(self, path: str, params: dict | None = None) -> dict:
163
194
  try:
164
195
  resp = self._client.get(path, params=params)
165
196
  except httpx.TimeoutException as exc:
166
- raise PulseEngineError(504, engine_message="The Pulse platform took too long to respond.") from exc
197
+ raise PulseEngineError(
198
+ 504, engine_message="The Pulse platform took too long to respond."
199
+ ) from exc
167
200
  except httpx.RequestError as exc:
168
201
  raise PulseEngineError(
169
- 502, engine_message=f"Could not reach the Pulse platform at {self._client.base_url}."
202
+ 502,
203
+ engine_message=f"Could not reach the Pulse platform at {self._client.base_url}.",
170
204
  ) from exc
171
205
  if resp.is_success:
172
206
  return resp.json()
@@ -176,7 +210,7 @@ class PlatformClient:
176
210
  body = resp.text
177
211
  raise PulseEngineError.from_response(resp.status_code, body)
178
212
 
179
- def subscription(self) -> Optional[dict]:
213
+ def subscription(self) -> dict | None:
180
214
  """GET /api/v1/billing/subscription — plan/status; None if unreachable."""
181
215
  try:
182
216
  return self._get("/api/v1/billing/subscription")
@@ -187,7 +221,7 @@ class PlatformClient:
187
221
  """GET /api/v1/analytics/usage/current-period — plan + pages for the billing cycle."""
188
222
  return self._get("/api/v1/analytics/usage/current-period")
189
223
 
190
- def usage_breakdown(self, start_date: Optional[str] = None, end_date: Optional[str] = None) -> dict:
224
+ def usage_breakdown(self, start_date: str | None = None, end_date: str | None = None) -> dict:
191
225
  """GET /api/v1/analytics/usage/breakdown — docs/pages/latency, by source.
192
226
 
193
227
  Without dates the platform defaults to the current billing period
@@ -0,0 +1,214 @@
1
+ """`pulse login` / `pulse logout` / `pulse whoami`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import webbrowser
6
+
7
+ import typer
8
+
9
+ from pulse_cli import config as cfg
10
+ from pulse_cli import oauth
11
+ from pulse_cli.client import PlatformClient, PulseClient
12
+ from pulse_cli.errors import PulseCliError
13
+ from pulse_cli.output import console, fail, print_json
14
+
15
+
16
+ def login(
17
+ api_key: str | None = typer.Option(
18
+ None, "--api-key", help="Skip the browser and log in with an API key directly."
19
+ ),
20
+ base_url: str | None = typer.Option(
21
+ None, "--base-url", help="Engine URL override (default: api.runpulse.com)."
22
+ ),
23
+ platform_url: str | None = typer.Option(
24
+ None, "--platform-url", help="Platform API URL (enables `pulse usage`)."
25
+ ),
26
+ web: bool = typer.Option(
27
+ True,
28
+ "--web/--no-web",
29
+ help="Browser login (default). --no-web prompts for an API key instead.",
30
+ ),
31
+ device: bool = typer.Option(
32
+ False,
33
+ "--device",
34
+ help="Approve from another device, for machines with no browser (SSH, containers).",
35
+ ),
36
+ ) -> None:
37
+ """Log in via the browser (default) or with an API key; the key is validated, then stored securely."""
38
+ try:
39
+ conf = cfg.load(api_url_override=base_url)
40
+ cfg.ensure_keychain_reachable()
41
+ previous, previous_on = cfg.stored_api_key()
42
+ except PulseCliError as exc:
43
+ raise fail(str(exc)) from exc
44
+ except Exception as exc: # a locked keychain or a denied keychain prompt
45
+ raise _keychain_failure("login", exc) from exc
46
+ purl = platform_url or conf.platform_url
47
+ minted: oauth.MintedKey | None = None
48
+ if api_key:
49
+ key = api_key
50
+ elif device or web:
51
+ minted = _device_flow_login(purl) if device else _browser_login(purl)
52
+ key = minted.api_key
53
+ else:
54
+ key = typer.prompt("Pulse API key", hide_input=True)
55
+ try:
56
+ key = key.strip()
57
+ if not key:
58
+ raise fail("empty API key")
59
+ console.print(f"[muted]Validating key against {conf.api_url} …[/muted]")
60
+ try:
61
+ with PulseClient(key, conf.api_url) as client:
62
+ pages = client.pages()
63
+ except PulseCliError as exc:
64
+ raise fail(str(exc)) from exc
65
+ minted_on = purl if minted else (previous_on if key == previous else None)
66
+ try:
67
+ store = cfg.save_api_key(
68
+ key, minted_on=minted_on, expires_at=minted.expires_at if minted else None
69
+ )
70
+ except Exception as exc:
71
+ raise fail(f"could not save the API key ({exc}); the stored key is unchanged.") from exc
72
+ except BaseException:
73
+ # Nothing holds a key minted for a login that did not finish.
74
+ if minted:
75
+ _delete_device_key(purl, key)
76
+ raise
77
+ # The device key this login replaces is held by nothing else.
78
+ if previous and previous_on and previous != key:
79
+ _delete_device_key(previous_on, previous)
80
+ if base_url or platform_url:
81
+ cfg.save_settings(api_url=base_url, platform_url=platform_url)
82
+
83
+ where = "OS keychain" if store == "keyring" else f"{cfg.CONFIG_PATH} (0600)"
84
+ console.print(f"[ok]Logged in.[/ok] Key {cfg.redact(key)} stored in {where}.")
85
+ if store == "file":
86
+ console.print(
87
+ "[yellow]No OS keychain is available here, so the key is stored in plain text, "
88
+ "readable only by you.[/yellow]"
89
+ )
90
+ if minted and minted.expires_at:
91
+ console.print(
92
+ f"[muted]It expires {minted.expires_at}; run `pulse login` again then.[/muted]"
93
+ )
94
+ console.print(f"[muted]Pages processed so far:[/muted] {pages.get('total_pages_processed', 0)}")
95
+
96
+
97
+ def _browser_login(purl: str) -> oauth.MintedKey:
98
+ """Approve in this machine's browser, mint this device's own API key."""
99
+
100
+ def open_browser(url: str) -> None:
101
+ console.print("\n Opening your browser. If it doesn't open, visit:")
102
+ # One unbroken line: rich folds a long word with real newlines, which a
103
+ # pasted URL turns into spaces.
104
+ console.print(f" {url}", soft_wrap=True, markup=False, highlight=False)
105
+ console.print(" [muted]No browser on this machine? Use `pulse login --device`.[/muted]\n")
106
+ webbrowser.open(url)
107
+
108
+ try:
109
+ with console.status("[muted]Waiting for approval in the browser …[/muted]"):
110
+ tokens = oauth.browser_login(purl, open_browser)
111
+ return oauth.mint_api_key(purl, tokens["access_token"])
112
+ except PulseCliError as exc:
113
+ raise fail(str(exc)) from exc
114
+
115
+
116
+ def _device_flow_login(purl: str) -> oauth.MintedKey:
117
+ """Device flow: type a code in a browser on any device, mint this device's own API key."""
118
+
119
+ try:
120
+ auth = oauth.device_authorization(purl)
121
+ console.print(f"\n Go to: [link]{auth['verification_uri']}[/link]")
122
+ console.print(f" Type this code: [bold]{auth['user_code']}[/bold]\n")
123
+ with console.status("[muted]Waiting for approval in the browser …[/muted]"):
124
+ tokens = oauth.poll_for_tokens(
125
+ purl,
126
+ auth["device_code"],
127
+ interval=int(auth.get("interval", 5)),
128
+ expires_in=int(auth.get("expires_in", 600)),
129
+ )
130
+ return oauth.mint_api_key(purl, tokens["access_token"])
131
+ except PulseCliError as exc:
132
+ raise fail(str(exc)) from exc
133
+
134
+
135
+ def _keychain_failure(command: str, exc: Exception) -> SystemExit:
136
+ return fail(
137
+ f"could not read the OS keychain ({exc}); nothing was changed. Run `pulse {command}` again."
138
+ )
139
+
140
+
141
+ def _delete_device_key(purl: str, key: str) -> bool:
142
+ """Delete a key `pulse login` minted; a failure only warns."""
143
+ try:
144
+ oauth.delete_api_key(purl, key)
145
+ except PulseCliError as exc:
146
+ console.print(
147
+ f"[yellow]Could not delete this device's API key {cfg.redact(key)} on the platform "
148
+ f"({exc}); it is still active, so remove it under API Keys in the dashboard.[/yellow]"
149
+ )
150
+ return False
151
+ return True
152
+
153
+
154
+ def logout() -> None:
155
+ """Remove the stored API key; a key `pulse login` minted is deleted on the platform too.
156
+
157
+ The stored key is the one deleted even when ``PULSE_API_KEY`` overrides it for other commands.
158
+ """
159
+ try:
160
+ cfg.ensure_keychain_reachable()
161
+ stored, minted_on = cfg.stored_api_key()
162
+ except PulseCliError as exc:
163
+ raise fail(str(exc)) from exc
164
+ except Exception as exc: # a locked keychain or a denied keychain prompt
165
+ raise _keychain_failure("logout", exc) from exc
166
+ deleted = ""
167
+ if stored and minted_on and _delete_device_key(minted_on, stored):
168
+ deleted = " Its API key was deleted on the platform."
169
+ if cfg.clear_api_key():
170
+ console.print(f"[ok]Logged out.[/ok] Stored API key removed.{deleted}")
171
+ elif stored:
172
+ console.print(
173
+ f"[yellow]Could not remove the stored API key from the OS keychain.{deleted} "
174
+ "Run `pulse logout` again.[/yellow]"
175
+ )
176
+ else:
177
+ console.print("[muted]No stored API key found.[/muted]")
178
+
179
+
180
+ def whoami(
181
+ json_out: bool = typer.Option(False, "--json", help="Machine-readable output."),
182
+ base_url: str | None = typer.Option(None, "--base-url", help="Engine URL override."),
183
+ ) -> None:
184
+ """Show the active key (redacted), where it came from, and account status."""
185
+ conf = cfg.load(api_url_override=base_url)
186
+ if not conf.api_key:
187
+ raise fail("not logged in. Run `pulse login`.")
188
+
189
+ info: dict = {
190
+ "api_key": cfg.redact(conf.api_key),
191
+ "key_source": conf.key_source,
192
+ "expires_at": conf.expires_at
193
+ and f"{conf.expires_at}{' (expired)' if conf.expired else ''}",
194
+ "api_url": conf.api_url,
195
+ }
196
+ try:
197
+ with PulseClient(conf.api_key, conf.api_url) as client:
198
+ info["total_pages_processed"] = client.pages().get("total_pages_processed")
199
+ except PulseCliError as exc:
200
+ info["status"] = f"key check failed: {exc}"
201
+
202
+ if conf.platform_url:
203
+ with PlatformClient(conf.api_key, conf.platform_url) as pc:
204
+ sub = pc.subscription()
205
+ if sub:
206
+ info["plan"] = sub.get("plan") or sub.get("plan_name")
207
+ info["subscription_status"] = sub.get("status")
208
+
209
+ if json_out:
210
+ print_json(info)
211
+ return
212
+ for k, v in info.items():
213
+ if v is not None:
214
+ console.print(f"[muted]{k:24}[/muted] [body]{v}[/body]")
@@ -0,0 +1,116 @@
1
+ """`pulse charts` — reconstruct charts from an extraction or split."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import typer
9
+
10
+ from pulse_cli.output import console, fail, print_json
11
+ from pulse_cli.runtime import get_client, resolve_offloaded_result, submit_and_poll
12
+ from pulse_cli.status import is_terminal
13
+
14
+ EXPORT_FORMATS = {"excel", "csv", "las"}
15
+
16
+
17
+ def charts(
18
+ extraction_id: str | None = typer.Argument(
19
+ None, help="ID of a completed extraction (omit when using --split-id)."
20
+ ),
21
+ split_id: str | None = typer.Option(None, "--split-id", help="ID of a completed split result."),
22
+ data_points: int = typer.Option(
23
+ 20, "--data-points", min=2, max=500, help="Points returned per series."
24
+ ),
25
+ agentic_zoom: bool = typer.Option(
26
+ False, "--agentic-zoom", help="Refine small chart regions at full resolution."
27
+ ),
28
+ page_range: str | None = typer.Option(None, "--pages", help='Extraction pages, e.g. "1-3,5".'),
29
+ export: list[str] = typer.Option(
30
+ [],
31
+ "--export",
32
+ "-e",
33
+ help="Repeatable export format: excel, csv, or las. Defaults to excel.",
34
+ ),
35
+ download_exports: Path | None = typer.Option(
36
+ None,
37
+ "--download-exports",
38
+ help="Consume the one-time export links and write them to this directory.",
39
+ ),
40
+ output: Path | None = typer.Option(
41
+ None, "--output", "-o", help="Write the complete response JSON to a file."
42
+ ),
43
+ no_wait: bool = typer.Option(
44
+ False, "--no-wait", help="Submit and print the chart job ID without polling."
45
+ ),
46
+ json_out: bool = typer.Option(False, "--json"),
47
+ api_key: str | None = typer.Option(None, "--api-key", envvar="PULSE_API_KEY"),
48
+ base_url: str | None = typer.Option(None, "--base-url"),
49
+ ) -> None:
50
+ """Reconstruct chart data from a completed extraction or split."""
51
+ if bool(extraction_id) == bool(split_id):
52
+ raise fail("provide exactly one extraction_id or --split-id")
53
+ if split_id and page_range:
54
+ raise fail("--pages cannot be used with --split-id; pages come from the split")
55
+
56
+ formats = [value.strip().lower() for value in export] if export else ["excel"]
57
+ invalid = sorted(set(formats) - EXPORT_FORMATS)
58
+ if invalid:
59
+ raise fail(f"unsupported --export value: {', '.join(invalid)}")
60
+ formats = list(dict.fromkeys(formats))
61
+
62
+ payload = {
63
+ **({"extraction_id": extraction_id} if extraction_id else {}),
64
+ **({"split_id": split_id} if split_id else {}),
65
+ **({"page_range": page_range.strip()} if page_range and page_range.strip() else {}),
66
+ "async": True,
67
+ "charts_config": {
68
+ "data_points": data_points,
69
+ "agentic_zoom": agentic_zoom,
70
+ "export_formats": formats,
71
+ },
72
+ }
73
+
74
+ with get_client(api_key, base_url) as client:
75
+ submit = client.charts(payload)
76
+ job = submit_and_poll(client, submit, label="extracting charts", no_wait=no_wait)
77
+ if no_wait and job.get("job_id"):
78
+ console.print(f"[muted]submitted →[/muted] [brand]{job['job_id']}[/brand]")
79
+ return
80
+ status = str(job.get("status") or "").lower()
81
+ if is_terminal(status) and status != "completed":
82
+ error = job.get("error") or f"Chart extraction {status}."
83
+ raise fail(str(error))
84
+ job = resolve_offloaded_result(client, job)
85
+ result = job.get("result") if isinstance(job.get("result"), dict) else job
86
+ downloaded: list[str] = []
87
+ if download_exports:
88
+ download_exports.mkdir(parents=True, exist_ok=True)
89
+ for artifact in result.get("exports", []):
90
+ filename = Path(str(artifact.get("filename") or "chart-export")).name
91
+ destination = download_exports / filename
92
+ destination.write_bytes(client.download_artifact(artifact["url"]))
93
+ downloaded.append(str(destination))
94
+
95
+ if downloaded:
96
+ result = {**result, "downloaded_exports": downloaded}
97
+ if output:
98
+ output.write_text(json.dumps(result, indent=2, default=str))
99
+ console.print(f"[ok]✓[/ok] wrote {output}")
100
+ elif json_out:
101
+ print_json(result)
102
+ else:
103
+ console.print(
104
+ f"[muted]charts_id =[/muted] [brand]{result.get('charts_id', 'unknown')}[/brand] "
105
+ f"[muted]· {result.get('count', 0)} chart(s)[/muted]"
106
+ )
107
+ print_json(result.get("charts") or result.get("results") or [])
108
+ if downloaded:
109
+ for path in downloaded:
110
+ console.print(f"[muted]SAVED:[/muted] {path}")
111
+ else:
112
+ for artifact in result.get("exports", []):
113
+ console.print(
114
+ f"[muted]{str(artifact.get('format', 'export')).upper()}:[/muted] "
115
+ f"{artifact.get('url')}"
116
+ )