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_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