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.
- pulse_ai_cli-0.1.0.dist-info/METADATA +49 -0
- pulse_ai_cli-0.1.0.dist-info/RECORD +27 -0
- pulse_ai_cli-0.1.0.dist-info/WHEEL +4 -0
- pulse_ai_cli-0.1.0.dist-info/entry_points.txt +2 -0
- pulse_cli/__init__.py +3 -0
- pulse_cli/__main__.py +4 -0
- pulse_cli/app.py +103 -0
- pulse_cli/client.py +197 -0
- pulse_cli/commands/__init__.py +0 -0
- pulse_cli/commands/auth.py +114 -0
- pulse_cli/commands/extract.py +199 -0
- pulse_cli/commands/jobs.py +53 -0
- pulse_cli/commands/open_platform.py +25 -0
- pulse_cli/commands/schema.py +124 -0
- pulse_cli/commands/split.py +63 -0
- pulse_cli/commands/tables.py +52 -0
- pulse_cli/commands/usage.py +109 -0
- pulse_cli/config.py +141 -0
- pulse_cli/errors.py +85 -0
- pulse_cli/oauth.py +95 -0
- pulse_cli/output.py +87 -0
- pulse_cli/repl.py +149 -0
- pulse_cli/runtime.py +61 -0
- pulse_cli/status.py +10 -0
- pulse_cli/tui/__init__.py +0 -0
- pulse_cli/tui/dashboard.py +153 -0
- pulse_cli/tui/logo.py +76 -0
pulse_cli/config.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Credential + settings store for the Pulse CLI.
|
|
2
|
+
|
|
3
|
+
Resolution order for the API key:
|
|
4
|
+
1. ``--api-key`` flag / ``PULSE_API_KEY`` env (CI-friendly, never persisted)
|
|
5
|
+
2. OS keychain via ``keyring`` (preferred persistent store)
|
|
6
|
+
3. ``~/.pulse/config.toml`` (0600) fallback when keyring is unavailable
|
|
7
|
+
|
|
8
|
+
Non-secret settings (base URLs) always live in the TOML file.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import stat
|
|
14
|
+
import tomllib
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
CONFIG_DIR = Path(os.environ.get("PULSE_CONFIG_DIR", "~/.pulse")).expanduser()
|
|
20
|
+
CONFIG_PATH = CONFIG_DIR / "config.toml"
|
|
21
|
+
|
|
22
|
+
DEFAULT_API_URL = "https://api.runpulse.com" # Flask extraction engine
|
|
23
|
+
# User-facing platform. Also the default OAuth/platform-API base: the web
|
|
24
|
+
# host proxies /api/v1/* straight to the FastAPI service.
|
|
25
|
+
DEFAULT_PLATFORM_URL = "https://platform.runpulse.com"
|
|
26
|
+
PLATFORM_WEB_URL = DEFAULT_PLATFORM_URL # browser links
|
|
27
|
+
KEYRING_SERVICE = "pulse-cli"
|
|
28
|
+
KEYRING_USER = "api_key"
|
|
29
|
+
|
|
30
|
+
try: # optional dependency
|
|
31
|
+
import keyring # type: ignore
|
|
32
|
+
|
|
33
|
+
try: # a backendless keyring raises on use; probe once
|
|
34
|
+
keyring.get_password(KEYRING_SERVICE, "__probe__")
|
|
35
|
+
_KEYRING_OK = True
|
|
36
|
+
except Exception:
|
|
37
|
+
_KEYRING_OK = False
|
|
38
|
+
except ImportError:
|
|
39
|
+
keyring = None # type: ignore
|
|
40
|
+
_KEYRING_OK = False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Config:
|
|
45
|
+
api_url: str = DEFAULT_API_URL
|
|
46
|
+
# Platform base for OAuth login + billing/analytics (override via
|
|
47
|
+
# `pulse login --platform-url` or PULSE_PLATFORM_URL).
|
|
48
|
+
platform_url: str = DEFAULT_PLATFORM_URL
|
|
49
|
+
# Only populated from file/env at load time; never written when keyring is available.
|
|
50
|
+
api_key: Optional[str] = field(default=None, repr=False)
|
|
51
|
+
key_source: str = "none" # env | keyring | file | none
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _read_file() -> dict:
|
|
55
|
+
if not CONFIG_PATH.exists():
|
|
56
|
+
return {}
|
|
57
|
+
try:
|
|
58
|
+
with open(CONFIG_PATH, "rb") as f:
|
|
59
|
+
return tomllib.load(f)
|
|
60
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
61
|
+
return {}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _write_file(values: dict) -> None:
|
|
65
|
+
"""Write a flat TOML file with 0600 perms."""
|
|
66
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
lines = [f'{k} = "{v}"' for k, v in values.items() if v is not None]
|
|
68
|
+
tmp = CONFIG_PATH.with_suffix(".tmp")
|
|
69
|
+
tmp.write_text("\n".join(lines) + "\n")
|
|
70
|
+
tmp.chmod(stat.S_IRUSR | stat.S_IWUSR) # 0600 before it holds anything
|
|
71
|
+
tmp.replace(CONFIG_PATH)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def load(api_key_override: Optional[str] = None, api_url_override: Optional[str] = None) -> Config:
|
|
75
|
+
data = _read_file()
|
|
76
|
+
cfg = Config(
|
|
77
|
+
api_url=api_url_override
|
|
78
|
+
or os.environ.get("PULSE_API_URL")
|
|
79
|
+
or data.get("api_url", DEFAULT_API_URL),
|
|
80
|
+
platform_url=os.environ.get("PULSE_PLATFORM_URL")
|
|
81
|
+
or data.get("platform_url", DEFAULT_PLATFORM_URL),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
if api_key_override:
|
|
85
|
+
cfg.api_key, cfg.key_source = api_key_override, "flag"
|
|
86
|
+
elif os.environ.get("PULSE_API_KEY"):
|
|
87
|
+
cfg.api_key, cfg.key_source = os.environ["PULSE_API_KEY"], "env"
|
|
88
|
+
elif _KEYRING_OK and (k := keyring.get_password(KEYRING_SERVICE, KEYRING_USER)):
|
|
89
|
+
cfg.api_key, cfg.key_source = k, "keyring"
|
|
90
|
+
elif data.get("api_key"):
|
|
91
|
+
cfg.api_key, cfg.key_source = data["api_key"], "file"
|
|
92
|
+
|
|
93
|
+
return cfg
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def save_api_key(api_key: str) -> str:
|
|
97
|
+
"""Persist the key; returns where it was stored ('keyring' or 'file')."""
|
|
98
|
+
data = _read_file()
|
|
99
|
+
if _KEYRING_OK:
|
|
100
|
+
keyring.set_password(KEYRING_SERVICE, KEYRING_USER, api_key)
|
|
101
|
+
data.pop("api_key", None) # never leave a stale plaintext copy
|
|
102
|
+
_write_file(data)
|
|
103
|
+
return "keyring"
|
|
104
|
+
data["api_key"] = api_key
|
|
105
|
+
_write_file(data)
|
|
106
|
+
return "file"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def save_settings(api_url: Optional[str] = None, platform_url: Optional[str] = None) -> None:
|
|
110
|
+
data = _read_file()
|
|
111
|
+
if api_url:
|
|
112
|
+
data["api_url"] = api_url
|
|
113
|
+
if platform_url:
|
|
114
|
+
data["platform_url"] = platform_url
|
|
115
|
+
_write_file(data)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def clear_api_key() -> bool:
|
|
119
|
+
"""Remove the stored key everywhere. Returns True if something was removed."""
|
|
120
|
+
removed = False
|
|
121
|
+
if _KEYRING_OK:
|
|
122
|
+
try:
|
|
123
|
+
if keyring.get_password(KEYRING_SERVICE, KEYRING_USER):
|
|
124
|
+
keyring.delete_password(KEYRING_SERVICE, KEYRING_USER)
|
|
125
|
+
removed = True
|
|
126
|
+
except Exception:
|
|
127
|
+
pass
|
|
128
|
+
data = _read_file()
|
|
129
|
+
if "api_key" in data:
|
|
130
|
+
del data["api_key"]
|
|
131
|
+
_write_file(data)
|
|
132
|
+
removed = True
|
|
133
|
+
return removed
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def redact(key: Optional[str]) -> str:
|
|
137
|
+
if not key:
|
|
138
|
+
return "<none>"
|
|
139
|
+
if len(key) <= 8:
|
|
140
|
+
return "****"
|
|
141
|
+
return f"{key[:4]}…{key[-4:]}"
|
pulse_cli/errors.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Engine error mapping — ported from mcp_server/errors.py so CLI and MCP
|
|
2
|
+
surface identical, actionable messages for the same engine failures.
|
|
3
|
+
|
|
4
|
+
Engine envelope: ``{"error": {"code", "message", "details"}}`` (or legacy
|
|
5
|
+
``{"error": "<message>"}``).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, Optional
|
|
10
|
+
|
|
11
|
+
_BILLING_URL = "https://runpulse.com/billing"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PulseCliError(Exception):
|
|
15
|
+
"""Base CLI error; message is shown to the user, exit code 1."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MissingApiKeyError(PulseCliError):
|
|
19
|
+
def __init__(self) -> None:
|
|
20
|
+
super().__init__(
|
|
21
|
+
"No Pulse API key found. Run `pulse login`, or set PULSE_API_KEY."
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PulseEngineError(PulseCliError):
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
status_code: int,
|
|
29
|
+
code: Optional[str] = None,
|
|
30
|
+
engine_message: Optional[str] = None,
|
|
31
|
+
details: Optional[Any] = None,
|
|
32
|
+
) -> None:
|
|
33
|
+
self.status_code = status_code
|
|
34
|
+
self.code = code
|
|
35
|
+
self.engine_message = engine_message
|
|
36
|
+
self.details = details
|
|
37
|
+
super().__init__(self.user_message())
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def from_response(cls, status_code: int, body: Any) -> "PulseEngineError":
|
|
41
|
+
code: Optional[str] = None
|
|
42
|
+
message: Optional[str] = None
|
|
43
|
+
details: Optional[Any] = None
|
|
44
|
+
if isinstance(body, dict):
|
|
45
|
+
err = body.get("error")
|
|
46
|
+
if isinstance(err, dict):
|
|
47
|
+
code, message, details = err.get("code"), err.get("message"), err.get("details")
|
|
48
|
+
elif isinstance(err, str):
|
|
49
|
+
message = err
|
|
50
|
+
elif "detail" in body:
|
|
51
|
+
d = body.get("detail")
|
|
52
|
+
message = d if isinstance(d, str) else None
|
|
53
|
+
details = None if isinstance(d, str) else d
|
|
54
|
+
elif isinstance(body, str):
|
|
55
|
+
message = body[:500] or None
|
|
56
|
+
return cls(status_code=status_code, code=code, engine_message=message, details=details)
|
|
57
|
+
|
|
58
|
+
def user_message(self) -> str:
|
|
59
|
+
sc = self.status_code
|
|
60
|
+
code = (self.code or "").upper()
|
|
61
|
+
msg = (self.engine_message or "").strip()
|
|
62
|
+
# The actionable reason often lives in details.error (e.g. GENERAL_001 is a
|
|
63
|
+
# generic "Internal server error" whose details carry the real cause).
|
|
64
|
+
if isinstance(self.details, dict) and isinstance(self.details.get("error"), str):
|
|
65
|
+
detail = self.details["error"].strip()
|
|
66
|
+
if detail and detail.lower() not in msg.lower():
|
|
67
|
+
msg = f"{msg}: {detail}" if msg else detail
|
|
68
|
+
|
|
69
|
+
if sc == 401 or code in ("AUTH_001", "AUTH_002", "AUTH_003"):
|
|
70
|
+
return "Authentication failed: missing or invalid API key. Run `pulse login`."
|
|
71
|
+
if sc == 402 or code == "BILLING_003":
|
|
72
|
+
return f"Payment required: plan limit reached. Upgrade at {_BILLING_URL}."
|
|
73
|
+
if code == "BILLING_002":
|
|
74
|
+
return "Insufficient Pulse credits for this operation."
|
|
75
|
+
if code == "BILLING_001":
|
|
76
|
+
return f"Your Pulse trial has expired. Upgrade at {_BILLING_URL}."
|
|
77
|
+
if code == "AUTH_004" or (sc == 403 and not code.startswith("BILLING")):
|
|
78
|
+
return "Insufficient permissions for this API key."
|
|
79
|
+
if sc == 429 or code in ("RATE_001", "GENERAL_004"):
|
|
80
|
+
return "Rate limit exceeded — please retry shortly."
|
|
81
|
+
if sc == 404:
|
|
82
|
+
return msg or "Not found."
|
|
83
|
+
if 400 <= sc < 500:
|
|
84
|
+
return msg or f"Pulse rejected the request (HTTP {sc})."
|
|
85
|
+
return msg or f"Pulse processing error (HTTP {sc})."
|
pulse_cli/oauth.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""OAuth 2.1 device-flow login (RFC 8628) against the Pulse platform API.
|
|
2
|
+
|
|
3
|
+
`pulse login` flow (endpoints are under /api/v1 so the platform web host
|
|
4
|
+
— which proxies /api/v1/* to the FastAPI service — works as the base URL):
|
|
5
|
+
1. POST {platform}/api/v1/oauth/device/authorization -> user_code + URL
|
|
6
|
+
2. user approves in the browser (platform session cookie)
|
|
7
|
+
3. poll POST {platform}/api/v1/oauth/token until tokens are issued
|
|
8
|
+
4. exchange the Bearer access token for the org's API key
|
|
9
|
+
(GET /api/v1/keys) — the API key is what both the engine and the
|
|
10
|
+
platform accept everywhere, so the rest of the CLI is unchanged.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import time
|
|
15
|
+
from typing import Any, Callable, Optional
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from pulse_cli import __version__
|
|
20
|
+
from pulse_cli.errors import PulseCliError
|
|
21
|
+
|
|
22
|
+
DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
|
|
23
|
+
_HEADERS = {"user-agent": f"pulse-cli/{__version__}"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _post(url: str, data: dict) -> httpx.Response:
|
|
27
|
+
try:
|
|
28
|
+
return httpx.post(url, data=data, headers=_HEADERS, timeout=30.0)
|
|
29
|
+
except httpx.RequestError as exc:
|
|
30
|
+
raise PulseCliError(f"could not reach {url}: {exc}") from exc
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def device_authorization(platform_url: str) -> dict:
|
|
34
|
+
"""Start the flow; returns device_code, user_code, verification URIs, interval."""
|
|
35
|
+
resp = _post(f"{platform_url.rstrip('/')}/api/v1/oauth/device/authorization", {"client_id": "pulse-cli"})
|
|
36
|
+
if not resp.is_success:
|
|
37
|
+
raise PulseCliError(f"device authorization failed (HTTP {resp.status_code}): {resp.text[:200]}")
|
|
38
|
+
return resp.json()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def poll_for_tokens(
|
|
42
|
+
platform_url: str,
|
|
43
|
+
device_code: str,
|
|
44
|
+
*,
|
|
45
|
+
interval: int = 5,
|
|
46
|
+
expires_in: int = 600,
|
|
47
|
+
on_tick: Optional[Callable[[], None]] = None,
|
|
48
|
+
) -> dict:
|
|
49
|
+
"""Poll the token endpoint until approval. Returns the token response."""
|
|
50
|
+
token_url = f"{platform_url.rstrip('/')}/api/v1/oauth/token"
|
|
51
|
+
deadline = time.monotonic() + expires_in
|
|
52
|
+
wait = max(interval, 1)
|
|
53
|
+
while time.monotonic() < deadline:
|
|
54
|
+
time.sleep(wait)
|
|
55
|
+
if on_tick is not None:
|
|
56
|
+
on_tick()
|
|
57
|
+
resp = _post(token_url, {"grant_type": DEVICE_GRANT_TYPE, "device_code": device_code})
|
|
58
|
+
body: Any = {}
|
|
59
|
+
try:
|
|
60
|
+
body = resp.json()
|
|
61
|
+
except Exception:
|
|
62
|
+
pass
|
|
63
|
+
if resp.is_success:
|
|
64
|
+
return body
|
|
65
|
+
error = body.get("error") if isinstance(body, dict) else None
|
|
66
|
+
if error == "authorization_pending":
|
|
67
|
+
continue
|
|
68
|
+
if error == "slow_down":
|
|
69
|
+
wait += 5
|
|
70
|
+
continue
|
|
71
|
+
if error == "access_denied":
|
|
72
|
+
raise PulseCliError("login request was denied in the browser")
|
|
73
|
+
if error == "expired_token":
|
|
74
|
+
break
|
|
75
|
+
raise PulseCliError(f"token request failed (HTTP {resp.status_code}): {resp.text[:200]}")
|
|
76
|
+
raise PulseCliError("login code expired before it was approved — run `pulse login --web` again")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def fetch_api_key(platform_url: str, access_token: str) -> str:
|
|
80
|
+
"""Exchange the OAuth access token for the org's active API key."""
|
|
81
|
+
try:
|
|
82
|
+
resp = httpx.get(
|
|
83
|
+
f"{platform_url.rstrip('/')}/api/v1/keys",
|
|
84
|
+
headers={**_HEADERS, "authorization": f"Bearer {access_token}"},
|
|
85
|
+
timeout=30.0,
|
|
86
|
+
)
|
|
87
|
+
except httpx.RequestError as exc:
|
|
88
|
+
raise PulseCliError(f"could not fetch API keys: {exc}") from exc
|
|
89
|
+
if not resp.is_success:
|
|
90
|
+
raise PulseCliError(f"failed to list API keys (HTTP {resp.status_code}): {resp.text[:200]}")
|
|
91
|
+
keys = resp.json() or []
|
|
92
|
+
active = [k for k in keys if k.get("status", "active") == "active" and k.get("api_key")]
|
|
93
|
+
if not active:
|
|
94
|
+
raise PulseCliError("no active API key found for your organization — create one in the platform")
|
|
95
|
+
return active[0]["api_key"]
|
pulse_cli/output.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Console output: brand theme, JSON mode, result writers, polling spinner."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
from urllib.parse import urlparse
|
|
9
|
+
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.theme import Theme
|
|
12
|
+
|
|
13
|
+
# Pulse brand palette
|
|
14
|
+
NAVY_950 = "#02182E"
|
|
15
|
+
NAVY_800 = "#022F56"
|
|
16
|
+
PULSE_500 = "#0E7BC9"
|
|
17
|
+
SKY_300 = "#85C4E4"
|
|
18
|
+
SKY_200 = "#98E2FD"
|
|
19
|
+
GRAY_200 = "#CCDEE4"
|
|
20
|
+
GRAY_50 = "#E9F0F8"
|
|
21
|
+
|
|
22
|
+
theme = Theme(
|
|
23
|
+
{
|
|
24
|
+
"brand": PULSE_500,
|
|
25
|
+
"accent": SKY_200,
|
|
26
|
+
"muted": SKY_300,
|
|
27
|
+
"body": GRAY_200,
|
|
28
|
+
"heading": f"bold {GRAY_50}",
|
|
29
|
+
"ok": "green",
|
|
30
|
+
"err": "bold red",
|
|
31
|
+
}
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
console = Console(theme=theme)
|
|
35
|
+
err_console = Console(theme=theme, stderr=True)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def is_tty() -> bool:
|
|
39
|
+
return sys.stdout.isatty()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def print_json(data: Any) -> None:
|
|
43
|
+
"""Machine-readable output to stdout (plain, pipe-safe)."""
|
|
44
|
+
print(json.dumps(data, indent=2, default=str))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def fail(message: str, code: int = 1) -> "SystemExit":
|
|
48
|
+
err_console.print(f"[err]error:[/err] {message}")
|
|
49
|
+
return SystemExit(code)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def spinner_status(label: str):
|
|
53
|
+
"""Brand-colored spinner context (TTY only callers should guard)."""
|
|
54
|
+
return console.status(f"[muted]{label}[/muted]", spinner="dots", spinner_style="brand")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def derive_output_stem(source: str, output_dir: Optional[Path]) -> Path:
|
|
58
|
+
"""`report.pdf` -> `report.pulse` (in output_dir or alongside the input).
|
|
59
|
+
|
|
60
|
+
URL sources land in output_dir (or cwd) named from the URL path.
|
|
61
|
+
"""
|
|
62
|
+
if source.startswith(("http://", "https://")):
|
|
63
|
+
name = Path(urlparse(source).path).name or "document"
|
|
64
|
+
base = output_dir or Path.cwd()
|
|
65
|
+
else:
|
|
66
|
+
p = Path(source)
|
|
67
|
+
name = p.name
|
|
68
|
+
base = output_dir or p.parent
|
|
69
|
+
stem = name.rsplit(".", 1)[0] if "." in name else name
|
|
70
|
+
return base / f"{stem}.pulse"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def write_result_files(result: dict, stem: Path, *, write_md: bool = True) -> list[Path]:
|
|
74
|
+
"""Write `<stem>.json` always, plus `<stem>.md` when markdown is present."""
|
|
75
|
+
stem.parent.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
written: list[Path] = []
|
|
77
|
+
|
|
78
|
+
json_path = stem.parent / f"{stem.name}.json"
|
|
79
|
+
json_path.write_text(json.dumps(result, indent=2, default=str))
|
|
80
|
+
written.append(json_path)
|
|
81
|
+
|
|
82
|
+
md = result.get("markdown") if isinstance(result, dict) else None
|
|
83
|
+
if write_md and isinstance(md, str) and md.strip():
|
|
84
|
+
md_path = stem.parent / f"{stem.name}.md"
|
|
85
|
+
md_path.write_text(md)
|
|
86
|
+
written.append(md_path)
|
|
87
|
+
return written
|
pulse_cli/repl.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""`pulse` (bare) — interactive shell, Claude Code style.
|
|
2
|
+
|
|
3
|
+
A branded banner (one animated heartbeat sweep), then a persistent prompt.
|
|
4
|
+
Every CLI command works inside it (`extract a.pdf`, `jobs get <id>`, ...);
|
|
5
|
+
plain `input()` + readline keeps arrow keys/history working everywhere.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import shlex
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
try: # typer >= 0.16 vendors click; commands raise the *vendored* exceptions
|
|
13
|
+
from typer import _click as click # type: ignore
|
|
14
|
+
except ImportError: # older typer depends on the real click package
|
|
15
|
+
import click # type: ignore
|
|
16
|
+
|
|
17
|
+
from pulse_cli import __version__
|
|
18
|
+
from pulse_cli import config as cfg
|
|
19
|
+
from pulse_cli.errors import PulseCliError
|
|
20
|
+
from pulse_cli.output import console, err_console
|
|
21
|
+
from pulse_cli.tui.logo import render_logo
|
|
22
|
+
|
|
23
|
+
_EXIT_WORDS = {"exit", "quit", "q"}
|
|
24
|
+
_HISTORY_PATH = cfg.CONFIG_DIR / "history"
|
|
25
|
+
|
|
26
|
+
# \001/\002 mark zero-width sequences so readline computes the prompt width right.
|
|
27
|
+
_PROMPT = "\001\x1b[1;38;2;14;123;201m\002pulse ›\001\x1b[0m\002 "
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _banner_frame(frame: int, conf: cfg.Config):
|
|
31
|
+
from rich.table import Table
|
|
32
|
+
from rich.text import Text
|
|
33
|
+
|
|
34
|
+
if conf.api_key:
|
|
35
|
+
account = (
|
|
36
|
+
f"[#85C4E4]key[/] [#E9F0F8]{cfg.redact(conf.api_key)}[/] [#85C4E4]({conf.key_source})[/]\n"
|
|
37
|
+
f"[#85C4E4]engine[/] [#E9F0F8]{conf.api_url}[/]"
|
|
38
|
+
)
|
|
39
|
+
else:
|
|
40
|
+
account = "[#85C4E4]not logged in — run:[/] [#E9F0F8]login --api-key <key>[/]"
|
|
41
|
+
|
|
42
|
+
info = Text.from_markup(
|
|
43
|
+
"\n\n\n"
|
|
44
|
+
"[bold #E9F0F8]P U L S E[/]\n"
|
|
45
|
+
f"[#85C4E4]document extraction · cli v{__version__}[/]\n"
|
|
46
|
+
"\n"
|
|
47
|
+
f"{account}\n"
|
|
48
|
+
"\n"
|
|
49
|
+
"[#0E7BC9]try[/] [#CCDEE4]extract report.pdf --schema s.json · tables <id> · usage[/]\n"
|
|
50
|
+
"[#0E7BC9] [/] [#CCDEE4]'help' for commands · 'exit' to leave[/]"
|
|
51
|
+
)
|
|
52
|
+
grid = Table.grid(padding=(0, 5))
|
|
53
|
+
grid.add_column()
|
|
54
|
+
grid.add_column()
|
|
55
|
+
grid.add_row(render_logo(frame), info)
|
|
56
|
+
return grid
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _print_banner(conf: cfg.Config) -> None:
|
|
60
|
+
from rich.live import Live
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
with Live(_banner_frame(0, conf), console=console, refresh_per_second=12, transient=False) as live:
|
|
64
|
+
for frame in range(1, 27): # one full heartbeat sweep, ~2s
|
|
65
|
+
time.sleep(1 / 12)
|
|
66
|
+
live.update(_banner_frame(frame, conf))
|
|
67
|
+
except KeyboardInterrupt:
|
|
68
|
+
pass
|
|
69
|
+
console.print()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _setup_history() -> None:
|
|
73
|
+
try:
|
|
74
|
+
import atexit
|
|
75
|
+
import readline
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
readline.read_history_file(_HISTORY_PATH)
|
|
79
|
+
except (FileNotFoundError, OSError):
|
|
80
|
+
pass
|
|
81
|
+
readline.set_history_length(500)
|
|
82
|
+
|
|
83
|
+
def _save() -> None:
|
|
84
|
+
try:
|
|
85
|
+
cfg.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
readline.write_history_file(_HISTORY_PATH)
|
|
87
|
+
except OSError:
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
atexit.register(_save)
|
|
91
|
+
except ImportError:
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _dispatch(command, line: str) -> None:
|
|
96
|
+
try:
|
|
97
|
+
args = shlex.split(line)
|
|
98
|
+
except ValueError as exc:
|
|
99
|
+
err_console.print(f"[err]parse error:[/err] {exc}")
|
|
100
|
+
return
|
|
101
|
+
if args and args[0] == "pulse": # tolerate pasting full commands
|
|
102
|
+
args = args[1:]
|
|
103
|
+
if not args:
|
|
104
|
+
return
|
|
105
|
+
if args[0].lower() in _EXIT_WORDS:
|
|
106
|
+
raise EOFError
|
|
107
|
+
if args[0].lower() in {"help", "?"}:
|
|
108
|
+
args = ["--help"]
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
command.main(args, prog_name="pulse", standalone_mode=False)
|
|
112
|
+
except click.exceptions.Exit:
|
|
113
|
+
pass
|
|
114
|
+
except click.exceptions.UsageError as exc:
|
|
115
|
+
err_console.print(f"[err]error:[/err] {exc.format_message()}")
|
|
116
|
+
err_console.print("[muted]type 'help' to see available commands[/muted]")
|
|
117
|
+
except click.exceptions.ClickException as exc:
|
|
118
|
+
err_console.print(f"[err]error:[/err] {exc.format_message()}")
|
|
119
|
+
except (click.exceptions.Abort, KeyboardInterrupt):
|
|
120
|
+
console.print()
|
|
121
|
+
except PulseCliError as exc:
|
|
122
|
+
err_console.print(f"[err]error:[/err] {exc}")
|
|
123
|
+
except SystemExit:
|
|
124
|
+
pass # e.g. Ctrl-C during polling prints its own resume hint
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def run_repl() -> None:
|
|
128
|
+
from typer.main import get_command
|
|
129
|
+
|
|
130
|
+
from pulse_cli.app import cli
|
|
131
|
+
|
|
132
|
+
command = get_command(cli)
|
|
133
|
+
_setup_history()
|
|
134
|
+
_print_banner(cfg.load())
|
|
135
|
+
|
|
136
|
+
while True:
|
|
137
|
+
try:
|
|
138
|
+
line = input(_PROMPT)
|
|
139
|
+
except KeyboardInterrupt:
|
|
140
|
+
console.print("[muted] (ctrl-d or 'exit' to leave)[/muted]")
|
|
141
|
+
continue
|
|
142
|
+
except EOFError:
|
|
143
|
+
console.print()
|
|
144
|
+
break
|
|
145
|
+
try:
|
|
146
|
+
_dispatch(command, line)
|
|
147
|
+
except EOFError:
|
|
148
|
+
break
|
|
149
|
+
console.print("[muted]bye.[/muted]")
|
pulse_cli/runtime.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Shared command runtime: config resolution, client construction, submit+poll."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from pulse_cli import config as cfg
|
|
7
|
+
from pulse_cli.client import PulseClient
|
|
8
|
+
from pulse_cli.errors import MissingApiKeyError
|
|
9
|
+
from pulse_cli.output import console, is_tty, spinner_status
|
|
10
|
+
from pulse_cli.status import is_terminal
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_client(api_key: Optional[str] = None, base_url: Optional[str] = None) -> PulseClient:
|
|
14
|
+
conf = cfg.load(api_key_override=api_key, api_url_override=base_url)
|
|
15
|
+
if not conf.api_key:
|
|
16
|
+
raise MissingApiKeyError()
|
|
17
|
+
return PulseClient(conf.api_key, conf.api_url)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def submit_and_poll(client: PulseClient, submit_result: dict, *, label: str, no_wait: bool = False) -> dict:
|
|
21
|
+
"""CLI analogue of mcp_server.tools._common.submit_and_poll.
|
|
22
|
+
|
|
23
|
+
Engine-ran-sync results (no job_id) pass through. Otherwise poll to a
|
|
24
|
+
terminal state with a spinner — or return the submit stub when --no-wait.
|
|
25
|
+
"""
|
|
26
|
+
# /tables names its pollable id tables_id ("Poll GET /job/{tables_id}").
|
|
27
|
+
job_id = submit_result.get("job_id") or submit_result.get("tables_id")
|
|
28
|
+
if not job_id or no_wait or is_terminal(submit_result.get("status")):
|
|
29
|
+
return submit_result
|
|
30
|
+
|
|
31
|
+
if is_tty():
|
|
32
|
+
with spinner_status(f"{label} · job {job_id} · submitted") as status:
|
|
33
|
+
def on_progress(s: str, _n: int, _job: dict) -> None:
|
|
34
|
+
status.update(f"[muted]{label} · job {job_id} · {s or 'processing'}[/muted]")
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
return client.wait_for_job(job_id, on_progress=on_progress)
|
|
38
|
+
except KeyboardInterrupt:
|
|
39
|
+
console.print(
|
|
40
|
+
f"\n[muted]Still running server-side. Resume with:[/muted] "
|
|
41
|
+
f"[brand]pulse jobs get {job_id} --watch[/brand]"
|
|
42
|
+
)
|
|
43
|
+
raise SystemExit(130)
|
|
44
|
+
# non-TTY: quiet poll
|
|
45
|
+
try:
|
|
46
|
+
return client.wait_for_job(job_id)
|
|
47
|
+
except KeyboardInterrupt:
|
|
48
|
+
print(f"interrupted; job still running: {job_id}")
|
|
49
|
+
raise SystemExit(130)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def resolve_offloaded_result(client: PulseClient, job: dict) -> dict:
|
|
53
|
+
"""If the terminal result is an {is_url, url} stub, download and inline it."""
|
|
54
|
+
result = job.get("result")
|
|
55
|
+
if isinstance(result, dict) and result.get("is_url") and result.get("url"):
|
|
56
|
+
fetched = client.fetch_url(result["url"])
|
|
57
|
+
if isinstance(fetched, dict):
|
|
58
|
+
job = {**job, "result": fetched}
|
|
59
|
+
else:
|
|
60
|
+
job = {**job, "result": {"raw": fetched}}
|
|
61
|
+
return job
|
pulse_cli/status.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Job-status values, mirrored from the engine (and mcp_server/status.py)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
TERMINAL_STATUSES: frozenset[str] = frozenset({"completed", "failed", "canceled", "expired"})
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def is_terminal(status: str | None) -> bool:
|
|
8
|
+
if not status:
|
|
9
|
+
return False
|
|
10
|
+
return status.strip().lower() in TERMINAL_STATUSES
|
|
File without changes
|